502 lines
18 KiB
TypeScript
502 lines
18 KiB
TypeScript
import DOMPurify from "dompurify";
|
|
import { marked } from "marked";
|
|
import { ChevronLeft, ChevronRight, PanelLeft } from "lucide-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 { 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 { 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";
|
|
|
|
function useFileText(url: string) {
|
|
const [text, setText] = useState<string | null>(null);
|
|
const [err, setErr] = useState("");
|
|
const [nonce, setNonce] = useState(0);
|
|
// url/nonce 变化时先落回加载态:React 官方推荐的「渲染期按下发 key 重置本地 state」,
|
|
// 替代原先 effect 内的同步 setState(级联渲染);重试时的表现与原先一致
|
|
const key = url + "#" + nonce;
|
|
const [prevKey, setPrevKey] = useState(key);
|
|
if (prevKey !== key) {
|
|
setPrevKey(key);
|
|
setText(null);
|
|
setErr("");
|
|
}
|
|
useEffect(() => {
|
|
let dead = false;
|
|
apiRaw(url)
|
|
.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;
|
|
};
|
|
}, [url, nonce]);
|
|
return { text, err, retry: () => setNonce((n) => n + 1) };
|
|
}
|
|
|
|
function LoadState({ text, onRetry }: { text: string; onRetry?: () => void }) {
|
|
return (
|
|
<div className="grid h-full place-items-center p-8 text-center text-muted-foreground">
|
|
<div>
|
|
<p className="mb-3">{text}</p>
|
|
{onRetry && (
|
|
<Button variant="secondary" onClick={onRetry}>
|
|
重试
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** 点正文唤出/收起工具栏;拖选文本与控件点击不触发。 */
|
|
function bodyTogglesChrome(e: MouseEvent<HTMLElement>, toggle: () => void) {
|
|
if ((e.target as HTMLElement).closest("a,button,input,nav")) return;
|
|
if (window.getSelection()?.toString()) return;
|
|
toggle();
|
|
}
|
|
|
|
export default function TextReader(props: ReaderProps) {
|
|
return props.book.format === "md" ? <MdView {...props} /> : <TxtView {...props} />;
|
|
}
|
|
|
|
function MdView({ book, initialLocator, initialPercent, chrome }: ReaderProps) {
|
|
const boxRef = useRef<HTMLDivElement>(null);
|
|
const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent });
|
|
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 ?? "");
|
|
|
|
const html = useMemo(() => (text == null ? null : DOMPurify.sanitize(marked.parse(text, { async: false }))), [text]);
|
|
|
|
useEffect(() => {
|
|
if (restored.current || text == null || !boxRef.current) return;
|
|
restored.current = true;
|
|
const f = Number(initialLocator?.scrollFraction);
|
|
if (Number.isFinite(f) && f > 0 && f <= 1) {
|
|
const el = boxRef.current;
|
|
el.scrollTop = f * (el.scrollHeight - el.clientHeight);
|
|
}
|
|
}, [text, initialLocator]);
|
|
|
|
function onScroll() {
|
|
const el = boxRef.current;
|
|
if (!el) return;
|
|
const max = el.scrollHeight - el.clientHeight;
|
|
const f = max > 0 ? Math.min(1, Math.max(0, el.scrollTop / max)) : 0;
|
|
setFrac(f);
|
|
saver.report({ scrollFraction: f }, f);
|
|
}
|
|
|
|
function seek(f: number) {
|
|
const el = boxRef.current;
|
|
if (!el) return;
|
|
el.scrollTop = f * (el.scrollHeight - el.clientHeight);
|
|
}
|
|
|
|
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={rd}>
|
|
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- 点正文唤出/收起工具栏的手势区;正文滚轮/拖选不涉及键盘操作 */}
|
|
<div
|
|
ref={boxRef}
|
|
onScroll={onScroll}
|
|
onClick={(e) => bodyTogglesChrome(e, chrome.toggle)}
|
|
className="min-h-0 flex-1 overflow-y-auto px-6 py-6"
|
|
>
|
|
<article
|
|
className="md-body mx-auto max-w-[46rem]"
|
|
style={{ fontSize: pr.fontSize, lineHeight: pr.lineHeight }}
|
|
dangerouslySetInnerHTML={{ __html: html ?? "" }}
|
|
/>
|
|
</div>
|
|
{chrome.on && (
|
|
<ReaderSettings
|
|
themeMode={pr.prefs.themeMode}
|
|
onThemeMode={pr.setThemeMode}
|
|
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}
|
|
fontIdx={pr.prefs.text.sizeIdx}
|
|
extra={
|
|
<>
|
|
<LineHeightRow pr={pr} />
|
|
<MarginRow pr={pr} />
|
|
</>
|
|
}
|
|
/>
|
|
)}
|
|
{nav && (
|
|
<ReaderNav
|
|
tab="bm"
|
|
onClose={() => setNav(false)}
|
|
bookmarks={
|
|
<BookmarksTab
|
|
book={book}
|
|
capture={saver.capture}
|
|
seek={(l) => {
|
|
seek(Number(l.scrollFraction) || 0);
|
|
setNav(false);
|
|
}}
|
|
/>
|
|
}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** 行距五档按钮组(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);
|
|
const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent });
|
|
const inited = useRef(false);
|
|
const restoreFrac = useRef<number | null>(null);
|
|
const targetCi = useRef(0);
|
|
const activeRow = useRef<HTMLButtonElement>(null);
|
|
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]);
|
|
|
|
useEffect(() => {
|
|
if (inited.current || !chapters) return;
|
|
inited.current = true;
|
|
const f = Number(initialLocator?.scrollFraction);
|
|
const hasF = Number.isFinite(f) && f > 0 && f <= 1;
|
|
const rawCh = Number(initialLocator?.ch);
|
|
if (Number.isFinite(rawCh) && rawCh >= 0 && rawCh < chapters.length) {
|
|
targetCi.current = Math.floor(rawCh);
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- 首章渲染后按缓存 locator 落位阅读进度:ci 落位与下方滚动定位 effect/refs 协作,生命周期与恢复进度语义同源,保留带理由
|
|
setCi(Math.floor(rawCh));
|
|
if (hasF) restoreFrac.current = f;
|
|
} else if (hasF) {
|
|
const { idx, intra } = seekChapter(chapters.length, f); // 旧版全局分数 → 章节近似定位
|
|
targetCi.current = idx;
|
|
setCi(idx);
|
|
if (intra > 0) restoreFrac.current = intra;
|
|
}
|
|
}, [chapters, initialLocator]);
|
|
|
|
useEffect(() => {
|
|
const el = boxRef.current;
|
|
if (!el || !chapters || ci !== targetCi.current) return;
|
|
if (restoreFrac.current != null) {
|
|
el.scrollTop = restoreFrac.current * (el.scrollHeight - el.clientHeight);
|
|
restoreFrac.current = null;
|
|
} else el.scrollTop = 0;
|
|
}, [ci, chapters]);
|
|
|
|
useEffect(() => {
|
|
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;
|
|
targetCi.current = i;
|
|
setCi(i);
|
|
setFrac(intra);
|
|
saver.report({ ch: i, scrollFraction: intra }, (i + intra) / chapters.length);
|
|
}
|
|
|
|
function onScroll() {
|
|
const el = boxRef.current;
|
|
if (!el || !chapters) return;
|
|
const max = el.scrollHeight - el.clientHeight;
|
|
const f = max > 0 ? Math.min(1, Math.max(0, el.scrollTop / max)) : 0;
|
|
setFrac(f);
|
|
saver.report({ ch: ci, scrollFraction: f }, (ci + f) / chapters.length);
|
|
}
|
|
|
|
function seek(f: number) {
|
|
if (!chapters) return;
|
|
const { idx, intra } = seekChapter(chapters.length, f);
|
|
goChapter(idx, intra);
|
|
}
|
|
|
|
if (err) return <LoadState text={err} onRetry={retry} />;
|
|
if (text == null || !chapters) return <LoadState text="正文加载中…" />;
|
|
|
|
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={rd}>
|
|
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- 点正文唤出/收起工具栏的手势区;点按是唯一唤出途径,键盘用户由常驻工具条提供全部控制 */}
|
|
<div
|
|
ref={boxRef}
|
|
onScroll={onScroll}
|
|
onClick={(e) => bodyTogglesChrome(e, chrome.toggle)}
|
|
className="min-h-0 flex-1 overflow-y-auto px-5 py-6 sm:px-6"
|
|
>
|
|
<pre
|
|
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 && (
|
|
<ReaderSettings
|
|
themeMode={pr.prefs.themeMode}
|
|
onThemeMode={pr.setThemeMode}
|
|
slider={{
|
|
value: Math.round(total * 1000),
|
|
max: 1000,
|
|
onChange: (v) => seek(v / 1000),
|
|
ariaLabel: "阅读进度",
|
|
}}
|
|
position={`${Math.round(total * 100)}%`}
|
|
left={
|
|
<>
|
|
<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")}
|
|
>
|
|
<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>
|
|
</>
|
|
)}
|
|
</>
|
|
}
|
|
showFont
|
|
onFont={pr.bumpText}
|
|
fontIdx={pr.prefs.text.sizeIdx}
|
|
extra={
|
|
<>
|
|
<LineHeightRow pr={pr} />
|
|
<MarginRow pr={pr} />
|
|
</>
|
|
}
|
|
/>
|
|
)}
|
|
{nav && (
|
|
<ReaderNav
|
|
tab={chapters.length > 1 ? nav : "bm"}
|
|
onTab={setNav}
|
|
onClose={() => setNav(null)}
|
|
bookmarks={
|
|
<BookmarksTab
|
|
book={book}
|
|
capture={saver.capture}
|
|
seek={(l) => {
|
|
goChapter(Number(l.ch) || 0, Number(l.scrollFraction) || 0);
|
|
setNav(null);
|
|
}}
|
|
/>
|
|
}
|
|
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 && (
|
|
<ul className="space-y-0.5">
|
|
{chapters.map((c, i) => (
|
|
<li key={i}>
|
|
<button
|
|
ref={i === ci ? activeRow : undefined}
|
|
className={
|
|
"min-w-0 flex-1 truncate rounded-md px-2.5 py-1.5 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground" +
|
|
(i === ci ? " bg-accent text-accent-foreground" : "")
|
|
}
|
|
onClick={() => {
|
|
goChapter(i);
|
|
setNav(null);
|
|
}}
|
|
>
|
|
{i + 1}. {c.title}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)
|
|
}
|
|
/>
|
|
)}
|
|
</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>
|
|
);
|
|
}
|