Files
lexgo/learner/src/composables/readingPosition.ts
T
ila e069894c4e feat: 补齐桌面与手机体验、显示偏好与键盘操作 (#14)
- 主题(浅色/深色/跟随系统)与正文字号(标准/大/特大)按账号存在本机,
  账号切换不串、退出回默认;深色走 html[data-theme] 与 Element Plus 的 html.dark
- style.css 收敛为语义调色板::root 的 53 个变量是文件内仅有的颜色字面量,
  其余规则全部走 var(),暗色只覆盖变量
- 字号经 --reader-font-scale 只作用于阅读面,不做全局缩放
- 阅读位置按账号+章节保存滚动比例与该章 content_sha256,正文换版本后不恢复
- 复习页键盘:空格/Enter 显示答案、1/2/3 评分;输入控件与聚焦按钮的按键不被劫持
- 站点头部新增「显示」控件,七个学习页面共用
- Playwright 新增 390×844 hasTouch 的 mobile 项目与移动用例(无溢出、可返回、
  触摸滑动不误开面板、深色与字号持久化、账号隔离);新增主题变量回归用例
- Wiki 记录 Architecture、Business-Rules、Local-Development 与需求更新
2026-09-15 15:11:30 +08:00

80 lines
3.1 KiB
TypeScript

/**
* Remembers where the reader stopped in a chapter. The position belongs to one account and one
* chapter, and it is tied to the chapter's content version: after the text is replaced, the old
* offset would point at different words, so it is dropped instead of restored.
*
* The value is a scroll ratio rather than a pixel offset, because the same chapter is read at
* different window widths (desktop and phone) where a pixel offset would land in the wrong place.
* This is local to the browser: it is not synced across devices.
*/
export const POSITION_KEY_PREFIX = 'lexgo-learner-position:'
export interface ReadingPosition {
ratio: number
sha: string
at: string
}
export function positionKey(userId: number | null, chapterId: number): string | null {
if (userId === null || !Number.isInteger(chapterId) || chapterId <= 0) return null
return `${POSITION_KEY_PREFIX}${userId}:${chapterId}`
}
/** Clamps a stored ratio into [0,1]; anything else is treated as "no usable position". */
export function normalizeRatio(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isFinite(value)) return null
if (value <= 0) return null
return Math.min(value, 1)
}
export function readPosition(storage: Storage, userId: number | null, chapterId: number, sha: string): ReadingPosition | null {
const key = positionKey(userId, chapterId)
if (key === null) return null
try {
const raw = storage.getItem(key)
if (raw === null) return null
const parsed = JSON.parse(raw) as Partial<ReadingPosition> | null
const ratio = normalizeRatio(parsed?.ratio)
// A different content version means the offset describes words that are no longer there.
if (ratio === null || !parsed || parsed.sha !== sha) return null
return { ratio, sha, at: typeof parsed.at === 'string' ? parsed.at : '' }
} catch {
return null
}
}
export function writePosition(storage: Storage, userId: number | null, chapterId: number, sha: string, ratio: number, now = new Date()): void {
const key = positionKey(userId, chapterId)
const usable = normalizeRatio(ratio)
if (key === null || usable === null || !sha) return
try {
storage.setItem(key, JSON.stringify({ ratio: usable, sha, at: now.toISOString() } satisfies ReadingPosition))
} catch {
// A full or disabled store must not break reading.
}
}
export function clearPosition(storage: Storage, userId: number | null, chapterId: number): void {
const key = positionKey(userId, chapterId)
if (key === null) return
try {
storage.removeItem(key)
} catch {
// Ignored for the same reason as above.
}
}
export interface ReadingPositionOptions {
/** The scrollable document the reader lives in. */
view: () => Window
/** The current scrollable height, used to turn a pixel offset into a ratio. */
scrollHeight: () => number
}
/** Turns the current window scroll offset into the ratio that is stored. */
export function currentRatio(view: Pick<Window, 'scrollY' | 'innerHeight'>, scrollHeight: number): number {
const scrollable = scrollHeight - view.innerHeight
if (scrollable <= 0) return 0
return Math.min(Math.max(view.scrollY / scrollable, 0), 1)
}