feat(frontend): readerPrefs v2 unified structure (TDD)
This commit is contained in:
@@ -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 const RD_THEMES = ["paper", "sepia", "night"] as const;
|
||||||
export type RdTheme = (typeof RD_THEMES)[number];
|
export type RdTheme = (typeof RD_THEMES)[number];
|
||||||
export const RD_THEME_LABEL: Record<RdTheme, string> = { paper: "纸", sepia: "米", night: "夜" };
|
export const RD_THEME_LABEL: Record<RdTheme, string> = { paper: "纸", sepia: "米", night: "夜" };
|
||||||
export const FONT_SIZES = [16, 18, 20, 22, 25, 28];
|
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<Margin, string> = { narrow: "窄", medium: "中", wide: "宽" };
|
||||||
|
/** 正文限宽档位:窄=宽度量、宽=窄度量(阅读习惯:边距越大行宽越窄) */
|
||||||
|
export const MARGIN_MAXW: Record<Margin, string> = { 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<CbzMode, string> = { strip: "长卷", page: "单页", spread: "双页" };
|
||||||
|
|
||||||
const KEY = "reader-prefs";
|
const KEY = "reader-prefs";
|
||||||
|
|
||||||
export interface ReaderPrefs {
|
export interface ReaderPrefs {
|
||||||
theme: RdTheme;
|
themeMode: "auto" | RdTheme;
|
||||||
sizeIdx: number;
|
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<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(raw ?? "");
|
||||||
|
if (parsed && typeof parsed === "object") o = parsed as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
/* 首次使用或坏值 */
|
||||||
|
}
|
||||||
|
const t = (o.text ?? {}) as Record<string, unknown>;
|
||||||
|
const c = (o.cbz ?? {}) as Record<string, unknown>;
|
||||||
|
const e = (o.epub ?? {}) as Record<string, unknown>;
|
||||||
|
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() {
|
export function useReaderPrefs() {
|
||||||
const [prefs, setPrefs] = useState<ReaderPrefs>(() => {
|
const [prefs, setPrefs] = useState<ReaderPrefs>(() => parsePrefs(localStorage.getItem(KEY)));
|
||||||
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 };
|
|
||||||
});
|
|
||||||
useEffect(() => localStorage.setItem(KEY, JSON.stringify(prefs)), [prefs]);
|
useEffect(() => localStorage.setItem(KEY, JSON.stringify(prefs)), [prefs]);
|
||||||
|
const update = useCallback((fn: (p: ReaderPrefs) => ReaderPrefs) => setPrefs(fn), []);
|
||||||
return {
|
return {
|
||||||
prefs,
|
prefs,
|
||||||
fontSize: FONT_SIZES[prefs.sizeIdx],
|
update,
|
||||||
setTheme: (theme: RdTheme) => setPrefs((p) => ({ ...p, theme })),
|
fontSize: FONT_SIZES[prefs.text.sizeIdx],
|
||||||
bump: (d: number) =>
|
lineHeight: LINE_HEIGHTS[prefs.text.lineIdx],
|
||||||
setPrefs((p) => ({ ...p, sizeIdx: Math.min(FONT_SIZES.length - 1, Math.max(0, p.sizeIdx + d)) })),
|
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<typeof useReaderPrefs>;
|
||||||
|
|||||||
@@ -8,7 +8,14 @@ import { ReaderNav, type NavTab } from "../components/reader-nav";
|
|||||||
import { btn } from "../components/ui";
|
import { btn } from "../components/ui";
|
||||||
import { chapterText, seekChapter, splitChapters } from "../lib/chapters";
|
import { chapterText, seekChapter, splitChapters } from "../lib/chapters";
|
||||||
import { decodeText } from "../lib/encoding";
|
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";
|
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
|
||||||
|
|
||||||
type Prefs = ReturnType<typeof useReaderPrefs>;
|
type Prefs = ReturnType<typeof useReaderPrefs>;
|
||||||
@@ -91,13 +98,13 @@ function ReaderSheet({
|
|||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
aria-label={`${RD_THEME_LABEL[t]}色主题`}
|
aria-label={`${RD_THEME_LABEL[t]}色主题`}
|
||||||
aria-pressed={pr.prefs.theme === t}
|
aria-pressed={resolveRdTheme(pr.prefs.themeMode, false) === t}
|
||||||
onClick={() => pr.setTheme(t)}
|
onClick={() => pr.setThemeMode(t)}
|
||||||
className="h-5 w-5 rounded-full transition-transform hover:scale-110"
|
className="h-5 w-5 rounded-full transition-transform hover:scale-110"
|
||||||
style={{
|
style={{
|
||||||
background: SWATCH[t],
|
background: SWATCH[t],
|
||||||
boxShadow:
|
boxShadow:
|
||||||
pr.prefs.theme === t
|
resolveRdTheme(pr.prefs.themeMode, false) === t
|
||||||
? "0 0 0 2px var(--rd-link)"
|
? "0 0 0 2px var(--rd-link)"
|
||||||
: "inset 0 0 0 1px color-mix(in oklab, currentColor 35%, transparent)",
|
: "inset 0 0 0 1px color-mix(in oklab, currentColor 35%, transparent)",
|
||||||
}}
|
}}
|
||||||
@@ -106,16 +113,16 @@ function ReaderSheet({
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="rd-btn"
|
className="rd-btn"
|
||||||
onClick={() => pr.bump(-1)}
|
onClick={() => pr.bumpText(-1)}
|
||||||
disabled={pr.prefs.sizeIdx === 0}
|
disabled={pr.prefs.text.sizeIdx === 0}
|
||||||
aria-label="减小字号"
|
aria-label="减小字号"
|
||||||
>
|
>
|
||||||
A−
|
A−
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="rd-btn"
|
className="rd-btn"
|
||||||
onClick={() => pr.bump(1)}
|
onClick={() => pr.bumpText(1)}
|
||||||
disabled={pr.prefs.sizeIdx === FONT_SIZES.length - 1}
|
disabled={pr.prefs.text.sizeIdx === FONT_SIZES.length - 1}
|
||||||
aria-label="加大字号"
|
aria-label="加大字号"
|
||||||
>
|
>
|
||||||
A+
|
A+
|
||||||
@@ -176,7 +183,7 @@ function MdView({ book, initialLocator, initialPercent, chrome }: ReaderProps) {
|
|||||||
if (err) return <LoadState text={err} onRetry={retry} />;
|
if (err) return <LoadState text={err} onRetry={retry} />;
|
||||||
if (text == null) return <LoadState text="正文加载中…" />;
|
if (text == null) return <LoadState text="正文加载中…" />;
|
||||||
return (
|
return (
|
||||||
<div className="rd-surface relative flex h-full flex-col" data-rd={pr.prefs.theme}>
|
<div className="rd-surface relative flex h-full flex-col" data-rd={resolveRdTheme(pr.prefs.themeMode, false)}>
|
||||||
<div
|
<div
|
||||||
ref={boxRef}
|
ref={boxRef}
|
||||||
onScroll={onScroll}
|
onScroll={onScroll}
|
||||||
@@ -299,7 +306,7 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
|
|||||||
const total = chapters.length > 1 ? Math.min(1, (ci + frac) / chapters.length) : frac;
|
const total = chapters.length > 1 ? Math.min(1, (ci + frac) / chapters.length) : frac;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rd-surface relative flex h-full flex-col" data-rd={pr.prefs.theme}>
|
<div className="rd-surface relative flex h-full flex-col" data-rd={resolveRdTheme(pr.prefs.themeMode, false)}>
|
||||||
<div
|
<div
|
||||||
ref={boxRef}
|
ref={boxRef}
|
||||||
onScroll={onScroll}
|
onScroll={onScroll}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { DEFAULT_PREFS, parsePrefs, resolveRdTheme } from "../src/lib/readerPrefs";
|
||||||
|
|
||||||
|
describe("parsePrefs", () => {
|
||||||
|
it("returns defaults for null/garbage", () => {
|
||||||
|
expect(parsePrefs(null)).toEqual(DEFAULT_PREFS);
|
||||||
|
expect(parsePrefs("{oops")).toEqual(DEFAULT_PREFS);
|
||||||
|
});
|
||||||
|
it("merges partial legacy-free structures with defaults", () => {
|
||||||
|
const p = parsePrefs(JSON.stringify({ themeMode: "night", cbz: { mode: "page" } }));
|
||||||
|
expect(p.themeMode).toBe("night");
|
||||||
|
expect(p.cbz).toEqual({ ...DEFAULT_PREFS.cbz, mode: "page" });
|
||||||
|
expect(p.text).toEqual(DEFAULT_PREFS.text);
|
||||||
|
});
|
||||||
|
it("rejects out-of-range values back to defaults", () => {
|
||||||
|
const p = parsePrefs(
|
||||||
|
JSON.stringify({
|
||||||
|
text: { sizeIdx: 99, lineIdx: -1, margin: "huge" },
|
||||||
|
cbz: { prefetch: 7 },
|
||||||
|
epub: { sizeIdx: 2, lineIdx: 0, marginIdx: 9 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(p.text).toEqual(DEFAULT_PREFS.text);
|
||||||
|
expect(p.cbz.prefetch).toBe(DEFAULT_PREFS.cbz.prefetch);
|
||||||
|
expect(p.epub.marginIdx).toBe(DEFAULT_PREFS.epub.marginIdx);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveRdTheme", () => {
|
||||||
|
it("auto follows global dark/light", () => {
|
||||||
|
expect(resolveRdTheme("auto", true)).toBe("night");
|
||||||
|
expect(resolveRdTheme("auto", false)).toBe("paper");
|
||||||
|
});
|
||||||
|
it("manual overrides global", () => {
|
||||||
|
expect(resolveRdTheme("sepia", true)).toBe("sepia");
|
||||||
|
expect(resolveRdTheme("night", false)).toBe("night");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user