fix(reader): split large txt into chapters (章 as boundaries, 卷 as optgroup labels, pseudo-sections when unmarked) rendered one at a time so the browser stops freezing; decode via strict-UTF-8-then-GB18030 with UTF-16 BOM sniffing to kill mojibake; progress locator {ch,scrollFraction} with old global-fraction fallback

This commit is contained in:
2026-09-07 22:21:29 +08:00
parent 991de0ef26
commit f47f2a6199
6 changed files with 318 additions and 31 deletions
+153 -31
View File
@@ -2,34 +2,57 @@ import DOMPurify from "dompurify";
import { marked } from "marked";
import { useEffect, useMemo, useRef, useState } from "react";
import { apiRaw } from "../api/client";
import { btn } from "../components/ui";
import { btn, input } from "../components/ui";
import { chapterText, splitChapters } from "../lib/chapters";
import { decodeText } from "../lib/encoding";
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
export default function TextReader({ book, initialLocator }: ReaderProps) {
const boxRef = useRef<HTMLDivElement>(null);
const saver = useProgressSaver(book.id);
const restored = useRef(false);
function useFileText(url: string) {
const [text, setText] = useState<string | null>(null);
const [err, setErr] = useState("");
const [nonce, setNonce] = useState(0);
useEffect(() => {
let dead = false;
setText(null);
setErr("");
apiRaw(book.file_url ?? "")
.then((r) => r.text())
.then((t) => !dead && setText(t))
apiRaw(url)
.then((r) => r.arrayBuffer())
.then((b) => !dead && setText(decodeText(b)))
.catch((e) => !dead && setErr(e instanceof Error ? e.message : "读取失败"));
return () => {
dead = true;
};
}, [book.file_url, nonce]);
}, [url, nonce]);
return { text, err, retry: () => setNonce((n) => n + 1) };
}
const html = useMemo(() => {
if (text == null || book.format !== "md") return null;
return DOMPurify.sanitize(marked.parse(text, { async: false }));
}, [text, book.format]);
function LoadState({ text, onRetry }: { text: string; onRetry?: () => void }) {
return (
<div className="grid h-full place-items-center p-8 text-center">
<div>
{onRetry && <p className="mb-3 text-red-400">{text}</p>}
{!onRetry && <p className="text-zinc-500">{text}</p>}
{onRetry && (
<button className={btn} onClick={onRetry}>
重试
</button>
)}
</div>
</div>
);
}
export default function TextReader(props: ReaderProps) {
return props.book.format === "md" ? <MdView {...props} /> : <TxtView {...props} />;
}
function MdView({ book, initialLocator }: ReaderProps) {
const boxRef = useRef<HTMLDivElement>(null);
const saver = useProgressSaver(book.id);
const restored = useRef(false);
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;
@@ -49,25 +72,124 @@ export default function TextReader({ book, initialLocator }: ReaderProps) {
saver.report({ scrollFraction: frac }, frac);
}
if (err)
return (
<div className="grid h-full place-items-center p-8 text-center">
<div>
<p className="mb-3 text-red-400">{err}</p>
<button className={btn} onClick={() => setNonce((n) => n + 1)}>
重试
</button>
</div>
</div>
);
if (text == null) return <div className="grid h-full place-items-center text-zinc-500">正文加载中…</div>;
if (err) return <LoadState text={err} onRetry={retry} />;
if (text == null) return <LoadState text="正文加载中…" />;
return (
<div ref={boxRef} onScroll={onScroll} className="h-full overflow-y-auto p-6">
{html !== null ? (
<article className="md-body mx-auto max-w-2xl text-[15px] leading-7" dangerouslySetInnerHTML={{ __html: html }} />
) : (
<pre className="mx-auto max-w-2xl font-sans text-[15px] leading-7 break-words whitespace-pre-wrap">{text}</pre>
<article className="md-body mx-auto max-w-2xl text-[15px] leading-7" dangerouslySetInnerHTML={{ __html: html ?? "" }} />
</div>
);
}
// 整本塞一个 <pre> 会冻死浏览器:按章渲染,一章一个滚动块。
function TxtView({ book, initialLocator }: ReaderProps) {
const boxRef = useRef<HTMLDivElement>(null);
const saver = useProgressSaver(book.id);
const inited = useRef(false);
const restoreFrac = useRef<number | null>(null);
const targetCi = useRef(0);
const [ci, setCi] = useState(0);
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);
let idx = 0;
if (Number.isFinite(rawCh) && rawCh >= 0 && rawCh < chapters.length) {
idx = Math.floor(rawCh);
if (hasF) restoreFrac.current = f;
} else if (hasF) {
const p = f * chapters.length; // 旧版全局分数 → 章节近似定位
idx = Math.min(chapters.length - 1, Math.floor(p));
restoreFrac.current = p - idx;
}
if (idx > 0 || restoreFrac.current != null) {
targetCi.current = idx;
setCi(idx);
}
}, [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]);
function goChapter(i: number) {
if (!chapters) return;
restoreFrac.current = null;
setCi(i);
saver.report({ ch: i, scrollFraction: 0 }, i / chapters.length);
}
function onScroll() {
const el = boxRef.current;
if (!el || !chapters) return;
const max = el.scrollHeight - el.clientHeight;
const frac = max > 0 ? Math.min(1, Math.max(0, el.scrollTop / max)) : 0;
saver.report({ ch: ci, scrollFraction: frac }, (ci + frac) / chapters.length);
}
if (err) return <LoadState text={err} onRetry={retry} />;
if (text == null || !chapters) return <LoadState text="正文加载中…" />;
return (
<div className="flex h-full flex-col">
{chapters.length > 1 && (
<div className="flex shrink-0 items-center gap-2 border-b border-zinc-800 px-4 py-2 text-sm">
<select className={input + " max-w-64"} value={ci} onChange={(e) => goChapter(Number(e.target.value))}>
{(() => {
const groups: { vol: string | undefined; items: number[] }[] = [];
chapters.forEach((c, i) => {
const last = groups[groups.length - 1];
if (!last || last.vol !== c.vol) groups.push({ vol: c.vol, items: [i] });
else last.items.push(i);
});
const opt = (i: number) => (
<option key={i} value={i}>
{i + 1}. {chapters[i].title}
</option>
);
return groups.length === 1 && !groups[0].vol
? chapters.map((_, i) => opt(i))
: groups.map((g, gi) =>
g.vol ? (
<optgroup key={gi} label={g.vol}>
{g.items.map(opt)}
</optgroup>
) : (
g.items.map(opt)
),
);
})()}
</select>
<span className="ml-auto text-zinc-500">
{ci + 1}/{chapters.length}
</span>
</div>
)}
<div ref={boxRef} onScroll={onScroll} className="min-h-0 flex-1 overflow-y-auto p-6">
<pre className="mx-auto max-w-2xl font-sans text-[15px] leading-7 break-words whitespace-pre-wrap">
{chapterText(text, chapters, ci)}
</pre>
</div>
{chapters.length > 1 && (
<div className="flex shrink-0 items-center justify-center gap-3 border-t border-zinc-800 px-4 py-2 text-sm">
<button className={btn} disabled={ci === 0} onClick={() => goChapter(ci - 1)}>
上一章
</button>
<button className={btn} disabled={ci === chapters.length - 1} onClick={() => goChapter(ci + 1)}>
下一章
</button>
</div>
)}
</div>
);