feat(web): txt/md reader with dompurified markdown and scroll-fraction progress

This commit is contained in:
2026-09-07 16:04:37 +08:00
parent e08f1dd5b9
commit b2e40a5049
3 changed files with 93 additions and 0 deletions
+17
View File
@@ -15,3 +15,20 @@ body {
::-webkit-scrollbar-thumb {
@apply rounded bg-zinc-700;
}
/* markdown 正文(仅 .md-body 作用域) */
.md-body h1 { @apply mt-8 mb-3 text-2xl font-semibold; }
.md-body h2 { @apply mt-6 mb-2 text-xl font-semibold; }
.md-body h3 { @apply mt-4 mb-2 text-base font-semibold; }
.md-body p { @apply my-3; }
.md-body ul { @apply my-3 list-disc space-y-1 pl-6; }
.md-body ol { @apply my-3 list-decimal space-y-1 pl-6; }
.md-body blockquote { @apply my-3 border-l-2 border-zinc-600 pl-4 text-zinc-400; }
.md-body code { @apply rounded bg-zinc-800 px-1 py-0.5 text-[13px]; }
.md-body pre { @apply my-3 overflow-x-auto rounded-lg bg-zinc-900 p-3; }
.md-body pre code { @apply bg-transparent p-0; }
.md-body a { @apply text-emerald-400 underline; }
.md-body img { @apply my-3 max-w-full rounded; }
.md-body hr { @apply my-6 border-zinc-800; }
.md-body table { @apply my-3 w-full border-collapse text-sm; }
.md-body th, .md-body td { @apply border border-zinc-700 px-2 py-1 text-left; }
+2
View File
@@ -11,6 +11,8 @@ import type { ReaderProps } from "../lib/useProgress";
// reader 任务逐个往里加:Task 7 cbz → Task 8 txt/md → Task 9 pdf → Task 10 epub
const READERS: Partial<Record<Format, LazyExoticComponent<ComponentType<ReaderProps>>>> = {
cbz: lazy(() => import("../readers/CbzReader")),
txt: lazy(() => import("../readers/TextReader")),
md: lazy(() => import("../readers/TextReader")),
};
export default function Reader() {
+74
View File
@@ -0,0 +1,74 @@
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 { 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);
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))
.catch((e) => !dead && setErr(e instanceof Error ? e.message : "读取失败"));
return () => {
dead = true;
};
}, [book.file_url, nonce]);
const html = useMemo(() => {
if (text == null || book.format !== "md") return null;
return DOMPurify.sanitize(marked.parse(text, { async: false }));
}, [text, book.format]);
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 frac = max > 0 ? Math.min(1, Math.max(0, el.scrollTop / max)) : 0;
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>;
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>
)}
</div>
);
}