SPB Git

spb/autoscholar

Public
6.8 KB · 198 lines tsx
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
Files · main
Menu

Theme