diff --git a/web/src/pages/Reader.tsx b/web/src/pages/Reader.tsx index 8e6073b..a73e3f1 100644 --- a/web/src/pages/Reader.tsx +++ b/web/src/pages/Reader.tsx @@ -13,6 +13,7 @@ const READERS: Partial import("../readers/CbzReader")), txt: lazy(() => import("../readers/TextReader")), md: lazy(() => import("../readers/TextReader")), + pdf: lazy(() => import("../readers/PdfReader")), }; export default function Reader() { diff --git a/web/src/readers/PdfReader.tsx b/web/src/readers/PdfReader.tsx new file mode 100644 index 0000000..238f065 --- /dev/null +++ b/web/src/readers/PdfReader.tsx @@ -0,0 +1,152 @@ +import { useEffect, useRef, useState, type KeyboardEvent } from "react"; +import * as pdfjs from "pdfjs-dist"; +import { apiRaw } from "../api/client"; +import { btn } from "../components/ui"; +import { useProgressSaver, type ReaderProps } from "../lib/useProgress"; + +// Vite 原生 worker 语法;pdfjs v5 要求 module worker +pdfjs.GlobalWorkerOptions.workerPort = new Worker( + new URL("pdfjs-dist/build/pdf.worker.mjs", import.meta.url), + { type: "module" }, +); + +type RenderTask = { cancel(): void; promise: Promise }; + +export default function PdfReader({ book, initialLocator }: ReaderProps) { + const wrapRef = useRef(null); + const canvasRef = useRef(null); + const docRef = useRef(null); + const docTaskRef = useRef<{ destroy(): Promise } | null>(null); + const taskRef = useRef(null); + const restored = useRef(false); + const saver = useProgressSaver(book.id); + const [num, setNum] = useState(0); + const [page, setPage] = useState(0); // 0 基 + const [err, setErr] = useState(""); + const [nonce, setNonce] = useState(0); // 仅加载重试 + const [redraw, setRedraw] = useState(0); // 仅 resize 重绘(不重新下载文档) + + useEffect(() => { + let dead = false; + setErr(""); + (async () => { + const buf = await apiRaw(book.file_url ?? "").then((r) => r.arrayBuffer()); + if (dead) return; + const task = pdfjs.getDocument({ data: new Uint8Array(buf) }); + docTaskRef.current = task; + const doc = await task.promise; + if (dead) { + await task.destroy(); + return; + } + docRef.current = doc; + setNum(doc.numPages); + if (!restored.current) { + restored.current = true; + const p = Number(initialLocator?.page); + if (Number.isInteger(p) && p > 0 && p < doc.numPages) setPage(p); + } + })().catch((e) => !dead && setErr(e instanceof Error ? e.message : "PDF 加载失败")); + return () => { + dead = true; + taskRef.current?.cancel(); + docTaskRef.current?.destroy(); + docTaskRef.current = null; + docRef.current = null; + }; + }, [book.file_url, nonce]); // initialLocator 是查询缓存值,加载一次即稳定 + + // 渲染当前页 + useEffect(() => { + const doc = docRef.current; + const canvas = canvasRef.current; + const wrap = wrapRef.current; + if (!doc || !canvas || !wrap || num === 0) return; + let cancelled = false; + (async () => { + const p = await doc.getPage(page + 1); + if (cancelled) return; + const cssW = wrap.clientWidth; + const dpr = window.devicePixelRatio || 1; + const base = p.getViewport({ scale: 1 }); + const vp = p.getViewport({ scale: (cssW * dpr) / base.width }); + canvas.width = Math.floor(vp.width); + canvas.height = Math.floor(vp.height); + canvas.style.width = "100%"; + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("canvas 2d 上下文不可用"); + const t = p.render({ canvas, viewport: vp }) as RenderTask; + taskRef.current = t; + await t.promise; + })().catch((e) => { + if (!cancelled && (e as { name?: string }).name !== "AbortException") + setErr(e instanceof Error ? e.message : "渲染失败"); + }); + saver.report({ page }, (page + 1) / num); + return () => { + cancelled = true; + taskRef.current?.cancel(); + }; + }, [page, num, redraw, saver]); + + // 容器尺寸变化 → 重绘 + useEffect(() => { + const wrap = wrapRef.current; + if (!wrap) return; + let t: ReturnType; + const ro = new ResizeObserver(() => { + clearTimeout(t); + t = setTimeout(() => setRedraw((x) => x + 1), 200); + }); + ro.observe(wrap); + return () => { + clearTimeout(t); + ro.disconnect(); + }; + }, []); + + function onKey(e: KeyboardEvent) { + if (e.key === "ArrowRight" || e.key === "PageDown") setPage((p) => Math.min(num - 1, p + 1)); + else if (e.key === "ArrowLeft" || e.key === "PageUp") setPage((p) => Math.max(0, p - 1)); + else return; + e.preventDefault(); + } + + if (err) + return ( +
+
+

{err}

+ +
+
+ ); + if (num === 0) return
PDF 加载中…
; + + return ( +
+
+ +
+
+ + + + {page + 1}/{num} + + + +
+
+ ); +}