/** * 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 | 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, scrollHeight: number): number { const scrollable = scrollHeight - view.innerHeight if (scrollable <= 0) return 0 return Math.min(Math.max(view.scrollY / scrollable, 0), 1) }