fix(deploy): pin distinct image tags per compose (booklib/{api,web}:{dev,prod}); same project name shared default tags so dev up without --build ran the prod image
This commit is contained in:
@@ -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<void> };
|
||||
|
||||
export default function PdfReader({ book, initialLocator }: 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);
|
||||
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<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="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((x) => x + 1)}>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
if (num === 0) return <div className="grid h-full place-items-center text-zinc-500">PDF 加载中…</div>;
|
||||
|
||||
return (
|
||||
<div tabIndex={0} onKeyDown={onKey} className="grid h-full grid-rows-[1fr_auto] focus:outline-none">
|
||||
<div ref={wrapRef} className="flex min-h-0 items-start justify-center overflow-y-auto bg-zinc-900 p-4">
|
||||
<canvas ref={canvasRef} className="rounded shadow-lg" />
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-3 border-t border-zinc-800 px-4 py-2 text-sm">
|
||||
<button className={btn} disabled={page === 0} onClick={() => setPage(0)}>
|
||||
⏮
|
||||
</button>
|
||||
<button className={btn} disabled={page === 0} onClick={() => setPage((p) => Math.max(0, p - 1))}>
|
||||
上一页
|
||||
</button>
|
||||
<span className="text-zinc-400">
|
||||
{page + 1}/{num}
|
||||
</span>
|
||||
<button className={btn} disabled={page >= num - 1} onClick={() => setPage((p) => Math.min(num - 1, p + 1))}>
|
||||
下一页
|
||||
</button>
|
||||
<button className={btn} disabled={page >= num - 1} onClick={() => setPage(num - 1)}>
|
||||
⏭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user