feat(frontend): text reader search + typography, chrome migration (B2)

This commit is contained in:
2026-09-16 16:58:39 +08:00
parent 74e5e9d5fe
commit 1004daf821
5 changed files with 260 additions and 157 deletions
+229 -135
View File
@@ -1,27 +1,20 @@
import DOMPurify from "dompurify";
import { marked } from "marked";
import { ChevronLeft, ChevronRight, PanelLeft } from "lucide-react";
import { useEffect, useMemo, useRef, useState, type MouseEvent, type ReactNode } from "react";
import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
import { apiRaw } from "../api/client";
import { BookmarksTab } from "../components/Bookmarks";
import { ReaderNav, type NavTab } from "../components/reader-nav";
import { btn } from "../components/ui";
import { ReaderSettings, SettingRow } from "../components/reader-settings";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useGlobalDark } from "../components/theme";
import { chapterText, seekChapter, splitChapters } from "../lib/chapters";
import { decodeText } from "../lib/encoding";
import {
FONT_SIZES,
RD_THEMES,
RD_THEME_LABEL,
resolveRdTheme,
useReaderPrefs,
type RdTheme,
} from "../lib/readerPrefs";
import { LINE_HEIGHTS, MARGINS, MARGIN_LABEL, resolveRdTheme, useReaderPrefs } from "../lib/readerPrefs";
import { searchChapters, splitHighlight, type SearchHit } from "../lib/search";
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
type Prefs = ReturnType<typeof useReaderPrefs>;
const SWATCH: Record<RdTheme, string> = { paper: "#faf8f4", sepia: "#f0e6d2", night: "#171512" };
function useFileText(url: string) {
const [text, setText] = useState<string | null>(null);
const [err, setErr] = useState("");
@@ -38,9 +31,22 @@ function useFileText(url: string) {
useEffect(() => {
let dead = false;
apiRaw(url)
.then((r) => r.arrayBuffer())
.then((b) => !dead && setText(decodeText(b)))
.catch((e) => !dead && setErr(e instanceof Error ? e.message : "读取失败"));
.then((r) => {
if (dead) return;
if (!r.ok) {
r.text().then((t) => {
if (!dead) setErr(t || `HTTP ${r.status}`);
});
return;
}
r.arrayBuffer().then((buf) => {
if (dead) return;
setText(decodeText(buf));
});
})
.catch((e) => {
if (!dead) setErr(e instanceof Error ? e.message : "加载失败");
});
return () => {
dead = true;
};
@@ -50,89 +56,19 @@ function useFileText(url: string) {
function LoadState({ text, onRetry }: { text: string; onRetry?: () => void }) {
return (
<div className="grid h-full place-items-center p-8 text-center">
<div className="grid h-full place-items-center p-8 text-center text-muted-foreground">
<div>
{onRetry && <p className="mb-3 text-red-400">{text}</p>}
{!onRetry && <p className="text-stone-500">{text}</p>}
<p className="mb-3">{text}</p>
{onRetry && (
<button className={btn} onClick={onRetry}>
<Button variant="secondary" onClick={onRetry}>
重试
</button>
</Button>
)}
</div>
</div>
);
}
/** 沉浸态底栏:进度滑条 + 主题色卡 + 字号,主题内配色。children 放左半区按钮(目录/翻页)。 */
function ReaderSheet({
pr,
frac,
onSeek,
children,
}: {
pr: Prefs;
frac: number;
onSeek: (f: number) => void;
children?: ReactNode;
}) {
return (
<div className="rd-sheet rd-divider relative z-20 shrink-0 border-t px-4 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-2">
<div className="flex items-center gap-3">
<input
type="range"
min={0}
max={1000}
value={Math.round(frac * 1000)}
onChange={(e) => onSeek(Number(e.target.value) / 1000)}
className="rd-range"
aria-label="阅读进度"
/>
<span className="w-10 shrink-0 text-right text-xs tabular-nums opacity-70">{Math.round(frac * 100)}%</span>
</div>
<div className="mt-1 flex items-center gap-2">
{children}
<div className="ml-auto flex items-center gap-1.5">
<div className="flex items-center gap-1.5" role="group" aria-label="阅读主题">
{RD_THEMES.map((t) => (
<button
key={t}
aria-label={`${RD_THEME_LABEL[t]}色主题`}
aria-pressed={resolveRdTheme(pr.prefs.themeMode, false) === t}
onClick={() => pr.setThemeMode(t)}
className="h-5 w-5 rounded-full transition-transform hover:scale-110"
style={{
background: SWATCH[t],
boxShadow:
resolveRdTheme(pr.prefs.themeMode, false) === t
? "0 0 0 2px var(--rd-link)"
: "inset 0 0 0 1px color-mix(in oklab, currentColor 35%, transparent)",
}}
/>
))}
</div>
<button
className="rd-btn"
onClick={() => pr.bumpText(-1)}
disabled={pr.prefs.text.sizeIdx === 0}
aria-label="减小字号"
>
A−
</button>
<button
className="rd-btn"
onClick={() => pr.bumpText(1)}
disabled={pr.prefs.text.sizeIdx === FONT_SIZES.length - 1}
aria-label="加大字号"
>
A+
</button>
</div>
</div>
</div>
);
}
/** 点正文唤出/收起工具栏;拖选文本与控件点击不触发。 */
function bodyTogglesChrome(e: MouseEvent<HTMLElement>, toggle: () => void) {
if ((e.target as HTMLElement).closest("a,button,input,nav")) return;
@@ -150,6 +86,8 @@ function MdView({ book, initialLocator, initialPercent, chrome }: ReaderProps) {
const [nav, setNav] = useState(false);
const restored = useRef(false);
const pr = useReaderPrefs();
const globalDark = useGlobalDark();
const rd = resolveRdTheme(pr.prefs.themeMode, globalDark);
const [frac, setFrac] = useState(0);
const { text, err, retry } = useFileText(book.file_url ?? "");
@@ -183,7 +121,7 @@ function MdView({ book, initialLocator, initialPercent, chrome }: ReaderProps) {
if (err) return <LoadState text={err} onRetry={retry} />;
if (text == null) return <LoadState text="正文加载中…" />;
return (
<div className="rd-surface relative flex h-full flex-col" data-rd={resolveRdTheme(pr.prefs.themeMode, false)}>
<div className="rd-surface relative flex h-full flex-col" data-rd={rd}>
<div
ref={boxRef}
onScroll={onScroll}
@@ -191,22 +129,41 @@ function MdView({ book, initialLocator, initialPercent, chrome }: ReaderProps) {
className="min-h-0 flex-1 overflow-y-auto px-6 py-6"
>
<article
className="md-body mx-auto max-w-[46rem] leading-[1.85]"
style={{ fontSize: pr.fontSize }}
className="md-body mx-auto max-w-[46rem]"
style={{ fontSize: pr.fontSize, lineHeight: pr.lineHeight }}
dangerouslySetInnerHTML={{ __html: html ?? "" }}
/>
</div>
{chrome.on && (
<ReaderSheet pr={pr} frac={frac} onSeek={seek}>
<button
className="rd-btn inline-flex items-center gap-1.5"
aria-expanded={nav}
aria-controls="reader-nav"
onClick={() => setNav((v) => !v)}
>
<PanelLeft size={14} /> 导航
</button>
</ReaderSheet>
<ReaderSettings
slider={{
value: Math.round(frac * 1000),
max: 1000,
onChange: (v) => seek(v / 1000),
ariaLabel: "阅读进度",
}}
position={`${Math.round(frac * 100)}%`}
left={
<Button
variant="ghost"
size="sm"
className="gap-1.5"
aria-expanded={nav}
aria-controls="reader-nav"
onClick={() => setNav((v) => !v)}
>
<PanelLeft size={14} /> 导航
</Button>
}
showFont
onFont={pr.bumpText}
extra={
<>
<LineHeightRow pr={pr} />
<MarginRow pr={pr} />
</>
}
/>
)}
{nav && (
<ReaderNav
@@ -228,6 +185,40 @@ function MdView({ book, initialLocator, initialPercent, chrome }: ReaderProps) {
);
}
/** 行距五档按钮组(text/epub 各自消费 lineIdx)。 */
function LineHeightRow({ pr, epub = false }: { pr: ReturnType<typeof useReaderPrefs>; epub?: boolean }) {
const cur = epub ? pr.prefs.epub.lineIdx : pr.prefs.text.lineIdx;
const patch = (i: number) =>
pr.update((p) => (epub ? { ...p, epub: { ...p.epub, lineIdx: i } } : { ...p, text: { ...p.text, lineIdx: i } }));
return (
<SettingRow label="行距">
{LINE_HEIGHTS.map((lh, i) => (
<Button key={i} variant={cur === i ? "secondary" : "ghost"} size="xs" onClick={() => patch(i)}>
{lh}
</Button>
))}
</SettingRow>
);
}
/** 边距三档按钮组(text/epub 各自消费 margin/marginIdx)。 */
function MarginRow({ pr, epub = false }: { pr: ReturnType<typeof useReaderPrefs>; epub?: boolean }) {
const cur = epub ? pr.prefs.epub.marginIdx : MARGINS.indexOf(pr.prefs.text.margin);
const patch = (i: number) =>
pr.update((p) =>
epub ? { ...p, epub: { ...p.epub, marginIdx: i } } : { ...p, text: { ...p.text, margin: MARGINS[i] } },
);
return (
<SettingRow label="边距">
{MARGINS.map((m, i) => (
<Button key={m} variant={cur === i ? "secondary" : "ghost"} size="xs" onClick={() => patch(i)}>
{MARGIN_LABEL[m]}
</Button>
))}
</SettingRow>
);
}
// 整本塞一个 <pre> 会冻死浏览器:按章渲染,一章一个滚动块。
function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps) {
const boxRef = useRef<HTMLDivElement>(null);
@@ -239,7 +230,11 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
const [ci, setCi] = useState(0);
const [frac, setFrac] = useState(0);
const [nav, setNav] = useState<null | NavTab>(null);
const [query, setQuery] = useState("");
const [hits, setHits] = useState<SearchHit[] | null>(null);
const pr = useReaderPrefs();
const globalDark = useGlobalDark();
const rd = resolveRdTheme(pr.prefs.themeMode, globalDark);
const { text, err, retry } = useFileText(book.file_url ?? "");
const chapters = useMemo(() => (text == null ? null : splitChapters(text)), [text]);
@@ -276,6 +271,15 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
if (nav === "toc") activeRow.current?.scrollIntoView({ block: "center" });
}, [nav]);
// 全书搜索:250ms 防抖,空 query 在同一 timer 内清空结果(避免 effect 内同步 setState)
useEffect(() => {
if (!chapters || !text) return;
const t = setTimeout(() => {
setHits(query.trim() ? searchChapters(text, chapters, query) : null);
}, 250);
return () => clearTimeout(t);
}, [query, chapters, text]);
function goChapter(i: number, intra = 0) {
if (!chapters) return;
restoreFrac.current = intra > 0 ? intra : null;
@@ -306,7 +310,7 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
const total = chapters.length > 1 ? Math.min(1, (ci + frac) / chapters.length) : frac;
return (
<div className="rd-surface relative flex h-full flex-col" data-rd={resolveRdTheme(pr.prefs.themeMode, false)}>
<div className="rd-surface relative flex h-full flex-col" data-rd={rd}>
<div
ref={boxRef}
onScroll={onScroll}
@@ -314,44 +318,62 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
className="min-h-0 flex-1 overflow-y-auto px-5 py-6 sm:px-6"
>
<pre
className="mx-auto max-w-[34em] break-words whitespace-pre-wrap"
style={{ fontSize: pr.fontSize, lineHeight: 1.9, textAlign: "justify" }}
className="mx-auto break-words whitespace-pre-wrap"
style={{ fontSize: pr.fontSize, lineHeight: pr.lineHeight, textAlign: "justify", maxWidth: pr.marginMaxW }}
>
{chapterText(text, chapters, ci)}
</pre>
</div>
{chrome.on && (
<ReaderSheet pr={pr} frac={total} onSeek={seek}>
<button
className="rd-btn inline-flex items-center gap-1.5"
aria-expanded={!!nav}
aria-controls="reader-nav"
onClick={() => setNav(nav ? null : chapters.length > 1 ? "toc" : "bm")}
>
<PanelLeft size={14} /> 导航
</button>
{chapters.length > 1 && (
<ReaderSettings
slider={{
value: Math.round(total * 1000),
max: 1000,
onChange: (v) => seek(v / 1000),
ariaLabel: "阅读进度",
}}
position={`${Math.round(total * 100)}%`}
left={
<>
<span className="text-xs opacity-60 tabular-nums">
{ci + 1}/{chapters.length}
</span>
<button
className="rd-btn inline-flex items-center gap-0.5"
disabled={ci === 0}
onClick={() => goChapter(ci - 1)}
<Button
variant="ghost"
size="sm"
className="gap-1.5"
aria-expanded={!!nav}
aria-controls="reader-nav"
onClick={() => setNav(nav ? null : chapters.length > 1 ? "toc" : "bm")}
>
<ChevronLeft size={14} /> 上一章
</button>
<button
className="rd-btn inline-flex items-center gap-0.5"
disabled={ci === chapters.length - 1}
onClick={() => goChapter(ci + 1)}
>
下一章 <ChevronRight size={14} />
</button>
<PanelLeft size={14} /> 导航
</Button>
{chapters.length > 1 && (
<>
<span className="text-xs tabular-nums text-muted-foreground">
{ci + 1}/{chapters.length}
</span>
<Button variant="ghost" size="sm" disabled={ci === 0} onClick={() => goChapter(ci - 1)}>
<ChevronLeft size={14} /> 上一章
</Button>
<Button
variant="ghost"
size="sm"
disabled={ci === chapters.length - 1}
onClick={() => goChapter(ci + 1)}
>
下一章 <ChevronRight size={14} />
</Button>
</>
)}
</>
)}
</ReaderSheet>
}
showFont
onFont={pr.bumpText}
extra={
<>
<LineHeightRow pr={pr} />
<MarginRow pr={pr} />
</>
}
/>
)}
{nav && (
<ReaderNav
@@ -368,6 +390,19 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
}}
/>
}
search={
<SearchPanel
query={query}
hits={hits}
chapters={chapters}
onQuery={setQuery}
onJump={(h) => {
const len = chapterText(text, chapters, h.ch).length;
goChapter(h.ch, Math.min(1, Math.max(0, h.pos / Math.max(1, len))));
setNav(null);
}}
/>
}
toc={
/* ponytail: 目录一次渲染全部章节,数千章级书若卡顿再上虚拟列表 */
chapters.length > 1 && (
@@ -394,3 +429,62 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
</div>
);
}
/** 搜索面板(导航抽屉「搜索」选项卡):防抖输入 + 章节/片段结果,点击跳到命中章的大致位置。 */
function SearchPanel({
query,
hits,
chapters,
onQuery,
onJump,
}: {
query: string;
hits: SearchHit[] | null;
chapters: { title: string; start: number }[];
onQuery: (q: string) => void;
onJump: (h: SearchHit) => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []); // autofocus 以 effect 实现,规避 jsx-a11y/no-autofocus 与打断首帧渲染
return (
<div className="flex h-full flex-col">
<Input
ref={inputRef}
placeholder="搜索全书"
value={query}
onChange={(e) => onQuery(e.target.value)}
aria-label="搜索全书"
/>
<div className="mt-2 min-h-0 flex-1 overflow-y-auto">
{hits == null && <p className="p-2 text-xs text-muted-foreground">输入关键词检索全书</p>}
{hits != null && hits.length === 0 && <p className="p-2 text-xs text-muted-foreground">无结果</p>}
{hits != null && hits.length > 0 && (
<ul className="space-y-0.5">
{hits.map((h, i) => {
const { before, match, after } = splitHighlight(h.snippet, h.matchStart, h.matchLen);
return (
<li key={`${h.ch}-${h.pos}-${i}`}>
<button
className="w-full rounded-md px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground"
onClick={() => onJump(h)}
>
<span className="block truncate text-xs text-muted-foreground">
{i + 1}. {chapters[h.ch].title}
</span>
<span className="block truncate">
{before}
<mark className="rounded px-0.5 bg-accent text-accent-foreground">{match}</mark>
{after}
</span>
</button>
</li>
);
})}
</ul>
)}
</div>
</div>
);
}