import { useEffect, useRef, useState, type KeyboardEvent } from "react"; import * as pdfjs from "pdfjs-dist"; import { apiRaw } from "../api/client"; import { useBookmarks } from "../components/Bookmarks"; 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, initialPercent }: 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, { locator: initialLocator, percent: initialPercent }); const bm = useBookmarks({ book, capture: saver.capture, seek: (l) => { const p = Number(l.page); if (Number.isInteger(p) && p >= 0 && p < num) setPage(p); }, }); 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} {bm.btn}
{bm.panel}
); }