226 lines
7.9 KiB
TypeScript
226 lines
7.9 KiB
TypeScript
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, PanelLeft } from "lucide-react";
|
|
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
|
|
import * as pdfjs from "pdfjs-dist";
|
|
import { apiRaw } from "../api/client";
|
|
import { BookmarksTab } from "../components/Bookmarks";
|
|
import { ReaderNav } from "../components/reader-nav";
|
|
import { ReaderSettings } from "../components/reader-settings";
|
|
import { Button } from "@/components/ui/button";
|
|
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<void> };
|
|
|
|
export default function PdfReader({ book, initialLocator, initialPercent }: ReaderProps) {
|
|
const wrapRef = useRef<HTMLDivElement>(null);
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const docRef = useRef<pdfjs.PDFDocumentProxy | null>(null);
|
|
const docTaskRef = useRef<{ destroy(): Promise<void> } | null>(null);
|
|
const taskRef = useRef<RenderTask | null>(null);
|
|
const restored = useRef(false);
|
|
const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent });
|
|
const [nav, setNav] = useState(false);
|
|
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 重绘(不重新下载文档)
|
|
// 重新加载(换书/重试)时先清错误:React 官方推荐的「渲染期按下发 key 重置本地 state」,
|
|
// 替代原先 effect 内的同步 setState(级联渲染);首挂载 key 相等,重置为空操作,行为等价
|
|
const loadKey = (book.file_url ?? "") + "#" + nonce;
|
|
const [prevLoadKey, setPrevLoadKey] = useState(loadKey);
|
|
if (prevLoadKey !== loadKey) {
|
|
setPrevLoadKey(loadKey);
|
|
setErr("");
|
|
}
|
|
|
|
useEffect(() => {
|
|
let dead = false;
|
|
(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<typeof setTimeout>;
|
|
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 (
|
|
<div className="bg-muted grid h-full place-items-center p-8 text-center">
|
|
<div>
|
|
<p className="text-destructive mb-3">{err}</p>
|
|
<Button variant="secondary" onClick={() => setNonce((x) => x + 1)}>
|
|
重试
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
if (num === 0) return <div className="text-muted-foreground grid h-full place-items-center">PDF 加载中…</div>;
|
|
|
|
return (
|
|
<div
|
|
tabIndex={0}
|
|
onKeyDown={onKey}
|
|
aria-label="PDF 阅读器,可用左右方向键翻页"
|
|
className="relative grid h-full grid-rows-[1fr_auto] bg-background text-foreground focus:outline-none focus-visible:outline-hidden"
|
|
>
|
|
<div ref={wrapRef} className="bg-muted/50 flex min-h-0 items-start justify-center overflow-y-auto p-4">
|
|
<canvas ref={canvasRef} className="ring-border max-w-3xl rounded-md shadow-2xl ring-1" />
|
|
</div>
|
|
<ReaderSettings
|
|
left={
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="gap-0.5"
|
|
disabled={page === 0}
|
|
onClick={() => setPage(0)}
|
|
aria-label="第一页"
|
|
>
|
|
<ChevronsLeft size={14} /> 首页
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="gap-0.5"
|
|
disabled={page === 0}
|
|
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
|
>
|
|
<ChevronLeft size={14} /> 上一页
|
|
</Button>
|
|
<span className="min-w-16 text-center text-muted-foreground tabular-nums">
|
|
{page + 1}/{num}
|
|
</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="gap-0.5"
|
|
disabled={page >= num - 1}
|
|
onClick={() => setPage((p) => Math.min(num - 1, p + 1))}
|
|
>
|
|
下一页 <ChevronRight size={14} />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="gap-0.5"
|
|
disabled={page >= num - 1}
|
|
onClick={() => setPage(num - 1)}
|
|
aria-label="最后一页"
|
|
>
|
|
<ChevronsRight size={14} /> 末页
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="gap-1.5"
|
|
aria-expanded={nav}
|
|
aria-controls="reader-nav"
|
|
onClick={() => setNav((v) => !v)}
|
|
>
|
|
<PanelLeft size={14} /> 导航
|
|
</Button>
|
|
</>
|
|
}
|
|
/>
|
|
{nav && (
|
|
<ReaderNav
|
|
tab="bm"
|
|
onClose={() => setNav(false)}
|
|
bookmarks={
|
|
<BookmarksTab
|
|
book={book}
|
|
capture={saver.capture}
|
|
seek={(l) => {
|
|
const p = Number(l.page);
|
|
setNav(false);
|
|
if (Number.isInteger(p) && p >= 0 && p < num) setPage(p);
|
|
}}
|
|
/>
|
|
}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|