diff --git a/web/src/index.css b/web/src/index.css index dcfb2b5..5b28d2c 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -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; } diff --git a/web/src/pages/Reader.tsx b/web/src/pages/Reader.tsx index 9291437..8e6073b 100644 --- a/web/src/pages/Reader.tsx +++ b/web/src/pages/Reader.tsx @@ -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>>> = { cbz: lazy(() => import("../readers/CbzReader")), + txt: lazy(() => import("../readers/TextReader")), + md: lazy(() => import("../readers/TextReader")), }; export default function Reader() { diff --git a/web/src/readers/TextReader.tsx b/web/src/readers/TextReader.tsx new file mode 100644 index 0000000..aa90433 --- /dev/null +++ b/web/src/readers/TextReader.tsx @@ -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(null); + const saver = useProgressSaver(book.id); + const restored = useRef(false); + const [text, setText] = useState(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 ( +
+
+

{err}

+ +
+
+ ); + if (text == null) return
正文加载中…
; + + return ( +
+ {html !== null ? ( +
+ ) : ( +
{text}
+ )} +
+ ); +}