feat(frontend): EPUB typography themes + chrome migration (B3)
This commit is contained in:
@@ -10,6 +10,9 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab
|
|||||||
|
|
||||||
### Added / 新增
|
### Added / 新增
|
||||||
|
|
||||||
|
- EPUB reader gains typography settings (font size, line height, margins) and honors the 纸/米/夜 reading themes including follow-global-theme; its toolbar moves to the unified settings bar.
|
||||||
|
- EPUB 阅读器新增排版设置(字号/行距/边距)并支持纸/米/夜阅读主题(含跟随全局);工具条迁入统一设置栏。
|
||||||
|
|
||||||
- Text reader gains full-book search (搜索 tab in the navigation drawer: debounced input, chapter + snippet results, tap to jump) and typography settings (line height ×5, margin width ×3); the reading toolbar is now the unified settings bar with theme swatches and follow-global-theme.
|
- Text reader gains full-book search (搜索 tab in the navigation drawer: debounced input, chapter + snippet results, tap to jump) and typography settings (line height ×5, margin width ×3); the reading toolbar is now the unified settings bar with theme swatches and follow-global-theme.
|
||||||
- 文本阅读器新增全书搜索(导航抽屉「搜索」选项卡:输入防抖、章节+前后文结果、点击跳转)与排版设置(行距五档、边距三档);阅读工具条换为统一设置栏,含主题卡与跟随全局主题。
|
- 文本阅读器新增全书搜索(导航抽屉「搜索」选项卡:输入防抖、章节+前后文结果、点击跳转)与排版设置(行距五档、边距三档);阅读工具条换为统一设置栏,含主题卡与跟随全局主题。
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { useGlobalDark } from "@/components/theme";
|
|||||||
import { RD_THEMES, RD_THEME_LABEL, FONT_SIZES, resolveRdTheme, useReaderPrefs, type RdTheme } from "@/lib/readerPrefs";
|
import { RD_THEMES, RD_THEME_LABEL, FONT_SIZES, resolveRdTheme, useReaderPrefs, type RdTheme } from "@/lib/readerPrefs";
|
||||||
|
|
||||||
export interface ReaderSettingsProps {
|
export interface ReaderSettingsProps {
|
||||||
/** 进度滑条:value/max 页或千分比由调用方定,onChange 收整数 */
|
/** 进度滑条:value/max 页或千分比由调用方定,onChange 收整数;EPUB 无全局分数故可省 */
|
||||||
slider: { value: number; max: number; onChange: (v: number) => void; ariaLabel: string };
|
slider?: { value: number; max: number; onChange: (v: number) => void; ariaLabel: string };
|
||||||
/** 位置文本,如 "3/12 · 25%" */
|
/** 位置文本,如 "3/12 · 25%" */
|
||||||
position?: string;
|
position?: string;
|
||||||
/** 左半区按钮(导航/上一章/下一章等),调用方给 */
|
/** 左半区按钮(导航/上一章/下一章等),调用方给 */
|
||||||
@@ -105,6 +105,7 @@ export function ReaderSettings({ slider, position, left, showFont = false, onFon
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{slider && (
|
||||||
<Slider
|
<Slider
|
||||||
className="mx-2.5 mt-2 w-[calc(100%-1.25rem)]"
|
className="mx-2.5 mt-2 w-[calc(100%-1.25rem)]"
|
||||||
value={[slider.value]}
|
value={[slider.value]}
|
||||||
@@ -112,6 +113,7 @@ export function ReaderSettings({ slider, position, left, showFont = false, onFon
|
|||||||
onValueChange={([v]) => slider.onChange(v)}
|
onValueChange={([v]) => slider.onChange(v)}
|
||||||
aria-label={slider.ariaLabel}
|
aria-label={slider.ariaLabel}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
{extra && open && <div className="px-2.5 pt-2">{extra}</div>}
|
{extra && open && <div className="px-2.5 pt-2">{extra}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { apiRaw } from "../api/client";
|
import { apiRaw } from "../api/client";
|
||||||
import { BookmarksTab } from "../components/Bookmarks";
|
import { BookmarksTab } from "../components/Bookmarks";
|
||||||
import { ReaderNav } from "../components/reader-nav";
|
import { ReaderNav } from "../components/reader-nav";
|
||||||
import { btn } from "../components/ui";
|
import { ReaderSettings, SettingRow } from "../components/reader-settings";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { toast } from "../components/Toaster";
|
import { toast } from "../components/Toaster";
|
||||||
|
import { useGlobalDark } from "../components/theme";
|
||||||
|
import { FONT_SIZES, LINE_HEIGHTS, MARGINS, MARGIN_LABEL, resolveRdTheme, useReaderPrefs } from "../lib/readerPrefs";
|
||||||
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
|
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
|
||||||
|
|
||||||
// 仅声明用到的最小子集,避免依赖 epubjs 自带类型的完整性
|
// 仅声明用到的最小子集,避免依赖 epubjs 自带类型的完整性
|
||||||
@@ -16,6 +19,10 @@ interface EpubRendition {
|
|||||||
next(): unknown;
|
next(): unknown;
|
||||||
prev(): unknown;
|
prev(): unknown;
|
||||||
on(ev: "relocated", cb: (loc: EpubLocation) => void): void;
|
on(ev: "relocated", cb: (loc: EpubLocation) => void): void;
|
||||||
|
themes: {
|
||||||
|
register(name: string, rules: Record<string, unknown>): void;
|
||||||
|
select(name: string): void;
|
||||||
|
};
|
||||||
destroy(): void;
|
destroy(): void;
|
||||||
}
|
}
|
||||||
interface EpubBookLike {
|
interface EpubBookLike {
|
||||||
@@ -30,10 +37,20 @@ async function loadEpub(): Promise<(data: Uint8Array) => EpubBookLike> {
|
|||||||
return m.default as (data: Uint8Array) => EpubBookLike;
|
return m.default as (data: Uint8Array) => EpubBookLike;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EpubReader({ book, initialLocator, initialPercent }: ReaderProps) {
|
/** epubjs iframe 取不到宿主 CSS 变量 → 三主题 bg/fg 用 index.css 的具体色值常量。 */
|
||||||
|
const THEME_CSS = {
|
||||||
|
paper: { bg: "#faf8f4", fg: "#292524" },
|
||||||
|
sepia: { bg: "#f0e6d2", fg: "#433a29" },
|
||||||
|
night: { bg: "#171512", fg: "#c6c0b6" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export default function EpubReader({ book, initialLocator, initialPercent }: Omit<ReaderProps, "chrome">) {
|
||||||
const hostRef = useRef<HTMLDivElement>(null);
|
const hostRef = useRef<HTMLDivElement>(null);
|
||||||
const rendRef = useRef<EpubRendition | null>(null);
|
const rendRef = useRef<EpubRendition | null>(null);
|
||||||
const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent });
|
const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent });
|
||||||
|
const pr = useReaderPrefs();
|
||||||
|
const globalDark = useGlobalDark();
|
||||||
|
const rd = resolveRdTheme(pr.prefs.themeMode, globalDark);
|
||||||
const [nav, setNav] = useState(false);
|
const [nav, setNav] = useState(false);
|
||||||
const [err, setErr] = useState("");
|
const [err, setErr] = useState("");
|
||||||
const [nonce, setNonce] = useState(0);
|
const [nonce, setNonce] = useState(0);
|
||||||
@@ -89,42 +106,74 @@ export default function EpubReader({ book, initialLocator, initialPercent }: Rea
|
|||||||
};
|
};
|
||||||
}, [book.file_url, nonce]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [book.file_url, nonce]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// 排版/主题映射:rd 或 prefs.epub 变化时重新 register+select(iframe 内须用具体色值)
|
||||||
|
useEffect(() => {
|
||||||
|
const r = rendRef.current;
|
||||||
|
if (!r || !ready) return;
|
||||||
|
const { bg, fg } = THEME_CSS[rd];
|
||||||
|
r.themes.register("booklib", {
|
||||||
|
body: {
|
||||||
|
background: bg,
|
||||||
|
color: fg,
|
||||||
|
"line-height": String(LINE_HEIGHTS[pr.prefs.epub.lineIdx]),
|
||||||
|
"font-size": `${FONT_SIZES[pr.prefs.epub.sizeIdx]}px`,
|
||||||
|
padding: `${[0.5, 1, 1.75][pr.prefs.epub.marginIdx]}rem 0`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
r.themes.select("booklib");
|
||||||
|
}, [rd, pr.prefs.epub, ready]);
|
||||||
|
|
||||||
if (err)
|
if (err)
|
||||||
return (
|
return (
|
||||||
<div className="grid h-full place-items-center p-8 text-center">
|
<div className="bg-muted grid h-full place-items-center p-8 text-center">
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-3 text-red-400">{err}</p>
|
<p className="text-destructive mb-3">{err}</p>
|
||||||
<button className={btn} onClick={() => setNonce((x) => x + 1)}>
|
<Button variant="secondary" onClick={() => setNonce((x) => x + 1)}>
|
||||||
重试
|
重试
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative grid h-full grid-rows-[1fr_auto]">
|
<div className="relative grid h-full grid-rows-[1fr_auto] bg-background text-foreground" data-rd={rd}>
|
||||||
<div className="relative mx-auto min-h-0 w-full max-w-4xl bg-white shadow-2xl ring-1 ring-stone-800">
|
<div className="bg-background ring-border relative mx-auto min-h-0 w-full max-w-4xl shadow-2xl ring-1">
|
||||||
<div ref={hostRef} className="absolute inset-0" />
|
<div ref={hostRef} className="absolute inset-0" style={{ background: "var(--rd-bg)" }} />
|
||||||
{!ready && !err && (
|
{!ready && !err && (
|
||||||
<div className="grid h-full place-items-center bg-stone-950 text-stone-500">EPUB 加载中…</div>
|
<div className="bg-muted text-muted-foreground grid h-full place-items-center">EPUB 加载中…</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-center gap-3 border-t border-stone-800 px-4 py-2 text-sm">
|
<ReaderSettings
|
||||||
<button className={btn + " inline-flex items-center gap-0.5"} onClick={() => rendRef.current?.prev()}>
|
left={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" size="sm" className="gap-0.5" onClick={() => rendRef.current?.prev()}>
|
||||||
<ChevronLeft size={14} /> 上一页
|
<ChevronLeft size={14} /> 上一页
|
||||||
</button>
|
</Button>
|
||||||
<button className={btn + " inline-flex items-center gap-0.5"} onClick={() => rendRef.current?.next()}>
|
<Button variant="ghost" size="sm" className="gap-0.5" onClick={() => rendRef.current?.next()}>
|
||||||
下一页 <ChevronRight size={14} />
|
下一页 <ChevronRight size={14} />
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
className="rd-btn inline-flex items-center gap-1.5"
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="gap-1.5"
|
||||||
aria-expanded={nav}
|
aria-expanded={nav}
|
||||||
aria-controls="reader-nav"
|
aria-controls="reader-nav"
|
||||||
onClick={() => setNav((v) => !v)}
|
onClick={() => setNav((v) => !v)}
|
||||||
>
|
>
|
||||||
<PanelLeft size={14} /> 导航
|
<PanelLeft size={14} /> 导航
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</>
|
||||||
|
}
|
||||||
|
showFont
|
||||||
|
onFont={pr.bumpEpub}
|
||||||
|
extra={
|
||||||
|
<>
|
||||||
|
<FontSizeRow pr={pr} />
|
||||||
|
<LineHeightRow pr={pr} />
|
||||||
|
<MarginRow pr={pr} />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
{nav && (
|
{nav && (
|
||||||
<ReaderNav
|
<ReaderNav
|
||||||
tab="bm"
|
tab="bm"
|
||||||
@@ -146,3 +195,55 @@ export default function EpubReader({ book, initialLocator, initialPercent }: Rea
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 字号档位按钮组(EPUB 独立档位,存 prefs.epub.sizeIdx)。 */
|
||||||
|
function FontSizeRow({ pr }: { pr: ReturnType<typeof useReaderPrefs> }) {
|
||||||
|
return (
|
||||||
|
<SettingRow label="字号">
|
||||||
|
{FONT_SIZES.map((fs, i) => (
|
||||||
|
<Button
|
||||||
|
key={fs}
|
||||||
|
variant={pr.prefs.epub.sizeIdx === i ? "secondary" : "ghost"}
|
||||||
|
size="xs"
|
||||||
|
onClick={() => pr.update((p) => ({ ...p, epub: { ...p.epub, sizeIdx: i } }))}
|
||||||
|
>
|
||||||
|
{fs}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</SettingRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** EPUB 行距/边距档位(存 prefs.epub)。 */
|
||||||
|
function LineHeightRow({ pr }: { pr: ReturnType<typeof useReaderPrefs> }) {
|
||||||
|
return (
|
||||||
|
<SettingRow label="行距">
|
||||||
|
{LINE_HEIGHTS.map((lh, i) => (
|
||||||
|
<Button
|
||||||
|
key={i}
|
||||||
|
variant={pr.prefs.epub.lineIdx === i ? "secondary" : "ghost"}
|
||||||
|
size="xs"
|
||||||
|
onClick={() => pr.update((p) => ({ ...p, epub: { ...p.epub, lineIdx: i } }))}
|
||||||
|
>
|
||||||
|
{lh}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</SettingRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function MarginRow({ pr }: { pr: ReturnType<typeof useReaderPrefs> }) {
|
||||||
|
return (
|
||||||
|
<SettingRow label="边距">
|
||||||
|
{MARGINS.map((m, i) => (
|
||||||
|
<Button
|
||||||
|
key={m}
|
||||||
|
variant={pr.prefs.epub.marginIdx === i ? "secondary" : "ghost"}
|
||||||
|
size="xs"
|
||||||
|
onClick={() => pr.update((p) => ({ ...p, epub: { ...p.epub, marginIdx: i } }))}
|
||||||
|
>
|
||||||
|
{MARGIN_LABEL[m]}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</SettingRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user