- 主题(浅色/深色/跟随系统)与正文字号(标准/大/特大)按账号存在本机, 账号切换不串、退出回默认;深色走 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 与需求更新
80 lines
3.1 KiB
TypeScript
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)
|
|
}
|