"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { IconChevron, IconDownload, IconExpand } from "./Icons"; type PdfDoc = { numPages: number; getPage: (n: number) => Promise; destroy?: () => Promise }; type PdfPage = { getViewport: (o: { scale: number }) => { width: number; height: number }; render: (o: { canvasContext: CanvasRenderingContext2D; viewport: { width: number; height: number } }) => { promise: Promise; cancel: () => void; }; }; /** * PaperReader — pdf.js-based reader: page navigation, zoom-to-width, full-screen, download, keyboard support, * loading and error states, native/mobile fallback. The worker is loaded from this origin (CSP-safe). */ export function PaperReader({ src, title, downloadHref, versionLabel, }: { src: string; title: string; downloadHref: string; versionLabel: string; }) { const [doc, setDoc] = useState(null); const [page, setPage] = useState(1); const [state, setState] = useState<"loading" | "ready" | "error">("loading"); const [fs, setFs] = useState(false); const canvas = useRef(null); const wrap = useRef(null); const root = useRef(null); const renderTask = useRef<{ cancel: () => void } | null>(null); useEffect(() => { let cancelled = false; let loaded: PdfDoc | null = null; (async () => { try { const pdfjs = await import("pdfjs-dist"); pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs"; const task = pdfjs.getDocument({ url: src, disableAutoFetch: true }); const d = (await task.promise) as unknown as PdfDoc; if (cancelled) return; loaded = d; setDoc(d); setState("ready"); } catch { if (!cancelled) setState("error"); } })(); return () => { cancelled = true; loaded?.destroy?.().catch(() => {}); }; }, [src]); const render = useCallback(async () => { if (!doc || !canvas.current || !wrap.current) return; renderTask.current?.cancel(); const p = await doc.getPage(page); const width = wrap.current.clientWidth; const base = p.getViewport({ scale: 1 }); const scale = width / base.width; const dpr = Math.min(window.devicePixelRatio || 1, 2); const vp = p.getViewport({ scale: scale * dpr }); const c = canvas.current; c.width = vp.width; c.height = vp.height; c.style.width = `${vp.width / dpr}px`; c.style.height = `${vp.height / dpr}px`; const ctx = c.getContext("2d"); if (!ctx) return; const t = p.render({ canvasContext: ctx, viewport: vp }); renderTask.current = t; await t.promise.catch(() => {}); }, [doc, page]); useEffect(() => { render(); const ro = new ResizeObserver(() => render()); if (wrap.current) ro.observe(wrap.current); return () => ro.disconnect(); }, [render]); useEffect(() => { const onFs = () => setFs(!!document.fullscreenElement); document.addEventListener("fullscreenchange", onFs); return () => document.removeEventListener("fullscreenchange", onFs); }, []); const n = doc?.numPages ?? 0; const go = (p: number) => setPage(Math.min(Math.max(1, p), Math.max(1, n))); const onKey = (e: React.KeyboardEvent) => { if (e.key === "ArrowRight" || e.key === "PageDown") go(page + 1); if (e.key === "ArrowLeft" || e.key === "PageUp") go(page - 1); }; const toggleFs = async () => { if (document.fullscreenElement) await document.exitFullscreen(); else await root.current?.requestFullscreen?.().catch(() => {}); }; return (
go(Number(e.target.value) || 1)} className="input inline-block h-8 w-14 px-2 text-center" style={{ minHeight: "2rem", fontSize: "0.8rem" }} />{" "} / {n || "…"}
{versionLabel}
{/* biome-ignore lint/a11y/noNoninteractiveTabindex: keyboard paging on the reader surface */}
{state === "loading" && (
)} {state === "error" && (

The PDF could not be rendered in this browser.

Download the PDF
)}
{title} ← → to turn pages
); }