From 64cc293878b9294a21943b3341e1550394ef659d Mon Sep 17 00:00:00 2001 From: XingfenD Date: Wed, 16 Sep 2026 15:15:08 +0800 Subject: [PATCH] feat(frontend): readerPrefs v2 unified structure (TDD) --- frontend/src/lib/readerPrefs.ts | 109 +++++++++++++++++++++++----- frontend/src/readers/TextReader.tsx | 27 ++++--- frontend/test/readerPrefs.test.ts | 38 ++++++++++ 3 files changed, 146 insertions(+), 28 deletions(-) create mode 100644 frontend/test/readerPrefs.test.ts diff --git a/frontend/src/lib/readerPrefs.ts b/frontend/src/lib/readerPrefs.ts index 23278b1..ab71ed1 100644 --- a/frontend/src/lib/readerPrefs.ts +++ b/frontend/src/lib/readerPrefs.ts @@ -1,35 +1,108 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; export const RD_THEMES = ["paper", "sepia", "night"] as const; export type RdTheme = (typeof RD_THEMES)[number]; export const RD_THEME_LABEL: Record = { paper: "纸", sepia: "米", night: "夜" }; export const FONT_SIZES = [16, 18, 20, 22, 25, 28]; +export const LINE_HEIGHTS = [1.2, 1.4, 1.6, 1.8, 2.0]; +export const MARGINS = ["narrow", "medium", "wide"] as const; +export type Margin = (typeof MARGINS)[number]; +export const MARGIN_LABEL: Record = { narrow: "窄", medium: "中", wide: "宽" }; +/** 正文限宽档位:窄=宽度量、宽=窄度量(阅读习惯:边距越大行宽越窄) */ +export const MARGIN_MAXW: Record = { narrow: "42em", medium: "34em", wide: "26em" }; +export const CBZ_MODES = ["strip", "page", "spread"] as const; +export type CbzMode = (typeof CBZ_MODES)[number]; +export const CBZ_MODE_LABEL: Record = { strip: "长卷", page: "单页", spread: "双页" }; const KEY = "reader-prefs"; export interface ReaderPrefs { - theme: RdTheme; - sizeIdx: number; + themeMode: "auto" | RdTheme; + text: { sizeIdx: number; lineIdx: number; margin: Margin }; + cbz: { mode: CbzMode; rtl: boolean; continuous: boolean; prefetch: number }; + epub: { sizeIdx: number; lineIdx: number; marginIdx: number }; +} + +export const DEFAULT_PREFS: ReaderPrefs = { + themeMode: "auto", + text: { sizeIdx: 2, lineIdx: 2, margin: "medium" }, + cbz: { mode: "strip", rtl: false, continuous: false, prefetch: 2 }, + epub: { sizeIdx: 2, lineIdx: 2, marginIdx: 1 }, +}; + +function idx(v: unknown, len: number, def: number): number { + return Number.isInteger(v) && (v as number) >= 0 && (v as number) < len ? (v as number) : def; +} + +/** 宽容解析:逐字段校验,缺失/坏值回默认(新结构,无旧键迁移——spec 决策)。 */ +export function parsePrefs(raw: string | null): ReaderPrefs { + let o: Record = {}; + try { + const parsed: unknown = JSON.parse(raw ?? ""); + if (parsed && typeof parsed === "object") o = parsed as Record; + } catch { + /* 首次使用或坏值 */ + } + const t = (o.text ?? {}) as Record; + const c = (o.cbz ?? {}) as Record; + const e = (o.epub ?? {}) as Record; + const mode = o.themeMode; + return { + themeMode: + mode === "auto" || RD_THEMES.includes(mode as RdTheme) + ? (mode as ReaderPrefs["themeMode"]) + : DEFAULT_PREFS.themeMode, + text: { + sizeIdx: idx(t.sizeIdx, FONT_SIZES.length, DEFAULT_PREFS.text.sizeIdx), + lineIdx: idx(t.lineIdx, LINE_HEIGHTS.length, DEFAULT_PREFS.text.lineIdx), + margin: MARGINS.includes(t.margin as Margin) ? (t.margin as Margin) : DEFAULT_PREFS.text.margin, + }, + cbz: { + mode: CBZ_MODES.includes(c.mode as CbzMode) ? (c.mode as CbzMode) : DEFAULT_PREFS.cbz.mode, + rtl: typeof c.rtl === "boolean" ? c.rtl : DEFAULT_PREFS.cbz.rtl, + continuous: typeof c.continuous === "boolean" ? c.continuous : DEFAULT_PREFS.cbz.continuous, + prefetch: + Number.isInteger(c.prefetch) && (c.prefetch as number) >= 0 && (c.prefetch as number) <= 3 + ? (c.prefetch as number) + : DEFAULT_PREFS.cbz.prefetch, + }, + epub: { + sizeIdx: idx(e.sizeIdx, FONT_SIZES.length, DEFAULT_PREFS.epub.sizeIdx), + lineIdx: idx(e.lineIdx, LINE_HEIGHTS.length, DEFAULT_PREFS.epub.lineIdx), + marginIdx: idx(e.marginIdx, MARGINS.length, DEFAULT_PREFS.epub.marginIdx), + }, + }; +} + +/** auto = 全局 dark→夜、light→纸;手动值直通(spec S1 联动规则)。 */ +export function resolveRdTheme(mode: ReaderPrefs["themeMode"], globalDark: boolean): RdTheme { + if (mode !== "auto") return mode; + return globalDark ? "night" : "paper"; } export function useReaderPrefs() { - const [prefs, setPrefs] = useState(() => { - try { - const p = JSON.parse(localStorage.getItem(KEY) ?? "") as ReaderPrefs; - if (RD_THEMES.includes(p.theme) && Number.isInteger(p.sizeIdx) && p.sizeIdx >= 0 && p.sizeIdx < FONT_SIZES.length) - return p; - } catch { - /* 首次使用或存了坏值 */ - } - return { theme: "sepia", sizeIdx: 2 }; - }); + const [prefs, setPrefs] = useState(() => parsePrefs(localStorage.getItem(KEY))); useEffect(() => localStorage.setItem(KEY, JSON.stringify(prefs)), [prefs]); - + const update = useCallback((fn: (p: ReaderPrefs) => ReaderPrefs) => setPrefs(fn), []); return { prefs, - fontSize: FONT_SIZES[prefs.sizeIdx], - setTheme: (theme: RdTheme) => setPrefs((p) => ({ ...p, theme })), - bump: (d: number) => - setPrefs((p) => ({ ...p, sizeIdx: Math.min(FONT_SIZES.length - 1, Math.max(0, p.sizeIdx + d)) })), + update, + fontSize: FONT_SIZES[prefs.text.sizeIdx], + lineHeight: LINE_HEIGHTS[prefs.text.lineIdx], + marginMaxW: MARGIN_MAXW[prefs.text.margin], + epubFontSize: FONT_SIZES[prefs.epub.sizeIdx], + setThemeMode: (themeMode: ReaderPrefs["themeMode"]) => update((p) => ({ ...p, themeMode })), + bumpText: (d: number) => + update((p) => ({ + ...p, + text: { ...p.text, sizeIdx: Math.min(FONT_SIZES.length - 1, Math.max(0, p.text.sizeIdx + d)) }, + })), + bumpEpub: (d: number) => + update((p) => ({ + ...p, + epub: { ...p.epub, sizeIdx: Math.min(FONT_SIZES.length - 1, Math.max(0, p.epub.sizeIdx + d)) }, + })), }; } + +export type ReaderPrefsApi = ReturnType; diff --git a/frontend/src/readers/TextReader.tsx b/frontend/src/readers/TextReader.tsx index 11047bc..5dd4199 100644 --- a/frontend/src/readers/TextReader.tsx +++ b/frontend/src/readers/TextReader.tsx @@ -8,7 +8,14 @@ import { ReaderNav, type NavTab } from "../components/reader-nav"; import { btn } from "../components/ui"; import { chapterText, seekChapter, splitChapters } from "../lib/chapters"; import { decodeText } from "../lib/encoding"; -import { FONT_SIZES, RD_THEMES, RD_THEME_LABEL, useReaderPrefs, type RdTheme } from "../lib/readerPrefs"; +import { + FONT_SIZES, + RD_THEMES, + RD_THEME_LABEL, + resolveRdTheme, + useReaderPrefs, + type RdTheme, +} from "../lib/readerPrefs"; import { useProgressSaver, type ReaderProps } from "../lib/useProgress"; type Prefs = ReturnType; @@ -91,13 +98,13 @@ function ReaderSheet({