spb/autoscholar
Public
1"use client";2import { useCallback, useEffect, useRef, useState } from "react";3import { IconChevron, IconDownload, IconExpand } from "./Icons";45type PdfDoc = { numPages: number; getPage: (n: number) => Promise<PdfPage>; destroy?: () => Promise<void> };6type PdfPage = {7 getViewport: (o: { scale: number }) => { width: number; height: number };8 render: (o: { canvasContext: CanvasRenderingContext2D; viewport: { width: number; height: number } }) => {9 promise: Promise<void>;10 cancel: () => void;11 };12};1314/**15 * PaperReader — pdf.js-based reader: page navigation, zoom-to-width, full-screen, download, keyboard support,16 * loading and error states, native/mobile fallback. The worker is loaded from this origin (CSP-safe).17 */18export function PaperReader({19 src,20 title,21 downloadHref,22 versionLabel,23}: {24 src: string;25 title: string;26 downloadHref: string;27 versionLabel: string;28}) {29 const [doc, setDoc] = useState<PdfDoc | null>(null);30 const [page, setPage] = useState(1);31 const [state, setState] = useState<"loading" | "ready" | "error">("loading");32 const [fs, setFs] = useState(false);33 const canvas = useRef<HTMLCanvasElement>(null);34 const wrap = useRef<HTMLDivElement>(null);35 const root = useRef<HTMLElement>(null);36 const renderTask = useRef<{ cancel: () => void } | null>(null);3738 useEffect(() => {39 let cancelled = false;40 let loaded: PdfDoc | null = null;41 (async () => {42 try {43 const pdfjs = await import("pdfjs-dist");44 pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs";45 const task = pdfjs.getDocument({ url: src, disableAutoFetch: true });46 const d = (await task.promise) as unknown as PdfDoc;47 if (cancelled) return;48 loaded = d;49 setDoc(d);50 setState("ready");51 } catch {52 if (!cancelled) setState("error");53 }54 })();55 return () => {56 cancelled = true;57 loaded?.destroy?.().catch(() => {});58 };59 }, [src]);6061 const render = useCallback(async () => {62 if (!doc || !canvas.current || !wrap.current) return;63 renderTask.current?.cancel();64 const p = await doc.getPage(page);65 const width = wrap.current.clientWidth;66 const base = p.getViewport({ scale: 1 });67 const scale = width / base.width;68 const dpr = Math.min(window.devicePixelRatio || 1, 2);69 const vp = p.getViewport({ scale: scale * dpr });70 const c = canvas.current;71 c.width = vp.width;72 c.height = vp.height;73 c.style.width = `${vp.width / dpr}px`;74 c.style.height = `${vp.height / dpr}px`;75 const ctx = c.getContext("2d");76 if (!ctx) return;77 const t = p.render({ canvasContext: ctx, viewport: vp });78 renderTask.current = t;79 await t.promise.catch(() => {});80 }, [doc, page]);8182 useEffect(() => {83 render();84 const ro = new ResizeObserver(() => render());85 if (wrap.current) ro.observe(wrap.current);86 return () => ro.disconnect();87 }, [render]);8889 useEffect(() => {90 const onFs = () => setFs(!!document.fullscreenElement);91 document.addEventListener("fullscreenchange", onFs);92 return () => document.removeEventListener("fullscreenchange", onFs);93 }, []);9495 const n = doc?.numPages ?? 0;96 const go = (p: number) => setPage(Math.min(Math.max(1, p), Math.max(1, n)));97 const onKey = (e: React.KeyboardEvent) => {98 if (e.key === "ArrowRight" || e.key === "PageDown") go(page + 1);99 if (e.key === "ArrowLeft" || e.key === "PageUp") go(page - 1);100 };101 const toggleFs = async () => {102 if (document.fullscreenElement) await document.exitFullscreen();103 else await root.current?.requestFullscreen?.().catch(() => {});104 };105106 return (107 <figure108 ref={root}109 className={`surface overflow-hidden ${fs ? "bg-[var(--bg)]" : ""}`}110 aria-label={`PDF reader: ${title}`}111 >112 <div className="flex flex-wrap items-center justify-between gap-2 border-b px-3 py-2 text-xs hairline">113 <div className="flex items-center gap-1">114 <button115 type="button"116 className="btn-icon h-9 w-9"117 aria-label="Previous page"118 onClick={() => go(page - 1)}119 disabled={page <= 1}120 >121 <IconChevron size={16} className="rotate-180" />122 </button>123 <span className="tabular-nums">124 <label htmlFor="pdf-page" className="sr-only">125 Page126 </label>127 <input128 id="pdf-page"129 type="number"130 min={1}131 max={Math.max(1, n)}132 value={page}133 onChange={(e) => go(Number(e.target.value) || 1)}134 className="input inline-block h-8 w-14 px-2 text-center"135 style={{ minHeight: "2rem", fontSize: "0.8rem" }}136 />{" "}137 <span className="muted">/ {n || "…"}</span>138 </span>139 <button140 type="button"141 className="btn-icon h-9 w-9"142 aria-label="Next page"143 onClick={() => go(page + 1)}144 disabled={!n || page >= n}145 >146 <IconChevron size={16} />147 </button>148 </div>149 <div className="flex items-center gap-1">150 <span className="badge hidden sm:inline-flex">{versionLabel}</span>151 <a className="btn-icon h-9 w-9" href={downloadHref} aria-label="Download PDF">152 <IconDownload size={16} />153 </a>154 <button155 type="button"156 className="btn-icon h-9 w-9"157 aria-label={fs ? "Exit full screen" : "Full screen"}158 onClick={toggleFs}159 >160 <IconExpand size={16} />161 </button>162 </div>163 </div>164 {/* biome-ignore lint/a11y/noNoninteractiveTabindex: keyboard paging on the reader surface */}165 <div166 ref={wrap}167 className={`relative overflow-auto ${fs ? "h-[calc(100dvh-3rem)]" : "max-h-[78dvh]"}`}168 style={{ background: "var(--bg-sunken)" }}169 tabIndex={0}170 onKeyDown={onKey}171 >172 {state === "loading" && (173 <div className="p-6">174 <div className="skeleton mx-auto aspect-[1/1.3] w-full max-w-2xl" aria-label="Loading PDF" />175 </div>176 )}177 {state === "error" && (178 <div className="p-6 text-sm">179 <p className="muted">The PDF could not be rendered in this browser.</p>180 <a className="btn mt-3" href={downloadHref}>181 Download the PDF182 </a>183 </div>184 )}185 <canvas186 ref={canvas}187 className={`mx-auto block ${state === "ready" ? "" : "hidden"}`}188 aria-label={`Page ${page} of ${title}`}189 />190 </div>191 <figcaption className="subtle flex items-center justify-between gap-3 px-3 py-2 text-xs">192 <span className="truncate">{title}</span>193 <span className="hidden sm:inline">← → to turn pages</span>194 </figcaption>195 </figure>196 );197}198