// app-pdf.jsx — PdfViewer: single-page canvas viewer for the board packet. // Exposes an imperative goToPage(n) via ref so the outline can drive it. (function () { const { useRef, useEffect, useState, forwardRef, useImperativeHandle } = React; const PdfViewer = forwardRef(function PdfViewer({ url }, ref) { const scrollRef = useRef(null); const canvasRef = useRef(null); const textLayerRef = useRef(null); const docRef = useRef(null); const renderTaskRef = useRef(null); const textLayerTaskRef = useRef(null); const [numPages, setNumPages] = useState(0); const [pageNum, setPageNum] = useState(1); const [zoom, setZoom] = useState(1); const [rotation, setRotation] = useState(0); // 0 | 90 | 180 | 270 — added on top of the page's intrinsic rotation const [cw, setCw] = useState(0); const [status, setStatus] = useState("loading"); // loading | ready | error useImperativeHandle(ref, () => ({ goToPage(n) { const total = docRef.current ? docRef.current.numPages : null; if (!n || isNaN(n)) return; let np = Math.max(1, Math.floor(n)); if (total) np = Math.min(np, total); setPageNum(np); if (scrollRef.current) scrollRef.current.scrollTo({ top: 0 }); }, }), []); // ---- load the document (once per url) ---- useEffect(() => { let cancelled = false; setStatus("loading"); setNumPages(0); const task = window.pdfjsLib.getDocument(url); task.promise.then((pdf) => { if (cancelled) { try { pdf.destroy(); } catch (e) {} return; } docRef.current = pdf; setNumPages(pdf.numPages); setPageNum(1); setStatus("ready"); }).catch(() => { if (!cancelled) setStatus("error"); }); return () => { cancelled = true; if (renderTaskRef.current) { try { renderTaskRef.current.cancel(); } catch (e) {} } if (textLayerTaskRef.current) { try { textLayerTaskRef.current.cancel(); } catch (e) {} } if (docRef.current) { try { docRef.current.destroy(); } catch (e) {} docRef.current = null; } }; }, [url]); // ---- track container width for fit-to-width ---- useEffect(() => { const el = scrollRef.current; if (!el || typeof ResizeObserver === "undefined") return; const ro = new ResizeObserver((entries) => { for (const e of entries) { const w = e.contentRect.width; if (w > 0) setCw(w); } }); ro.observe(el); return () => ro.disconnect(); }, []); // ---- stabilise text selection ---- // Toggle a .selecting class on the text layer for the duration of a drag so // the .endOfContent boundary expands; this keeps the highlight on the row // under the cursor instead of leaping to following lines when the pointer // enters the empty space after a line. useEffect(() => { const div = textLayerRef.current; if (!div) return; const start = () => div.classList.add("selecting"); const stop = () => div.classList.remove("selecting"); div.addEventListener("pointerdown", start); window.addEventListener("pointerup", stop); window.addEventListener("pointercancel", stop); return () => { div.removeEventListener("pointerdown", start); window.removeEventListener("pointerup", stop); window.removeEventListener("pointercancel", stop); }; }, []); // ---- render the current page ---- useEffect(() => { const pdf = docRef.current; if (status !== "ready" || !pdf || !cw || cw <= 0) return; let cancelled = false; (async () => { try { if (renderTaskRef.current) { try { renderTaskRef.current.cancel(); } catch (e) {} } if (textLayerTaskRef.current) { try { textLayerTaskRef.current.cancel(); } catch (e) {} } const page = await pdf.getPage(pageNum); if (cancelled) return; const base = page.getViewport({ scale: 1, rotation }); const fit = (cw - 28) / base.width; const scale = Math.max(0.2, fit * zoom); const viewport = page.getViewport({ scale, rotation }); const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext("2d"); const dpr = window.devicePixelRatio || 1; canvas.width = Math.floor(viewport.width * dpr); canvas.height = Math.floor(viewport.height * dpr); canvas.style.width = Math.floor(viewport.width) + "px"; canvas.style.height = Math.floor(viewport.height) + "px"; const transform = dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : undefined; const rt = page.render({ canvasContext: ctx, viewport, transform }); renderTaskRef.current = rt; await rt.promise; // ---- selectable text layer (overlay of real, transparent text) ---- const textLayer = textLayerRef.current; if (textLayer && window.pdfjsLib.renderTextLayer) { textLayer.innerHTML = ""; textLayer.style.width = Math.floor(viewport.width) + "px"; textLayer.style.height = Math.floor(viewport.height) + "px"; textLayer.style.setProperty("--scale-factor", String(scale)); const textContent = await page.getTextContent(); if (cancelled) return; const tlTask = window.pdfjsLib.renderTextLayer({ textContent, container: textLayer, viewport, textDivs: [], }); textLayerTaskRef.current = tlTask; await tlTask.promise; if (cancelled) return; // Append an "end of content" boundary. While a drag-select is in // progress this element (expanded to cover the whole layer via the // .selecting class) catches the cursor in the blank space past a // line's last word — without it, dragging into that gap makes the // browser jump the selection down to later lines. const eoc = document.createElement("div"); eoc.className = "endOfContent"; textLayer.appendChild(eoc); } } catch (e) { /* render cancelled — ignore */ } })(); return () => { cancelled = true; }; }, [status, pageNum, zoom, rotation, cw, numPages]); function go(delta) { setPageNum((p) => Math.min(Math.max(1, p + delta), numPages || 1)); if (scrollRef.current) scrollRef.current.scrollTo({ top: 0 }); } return (
Page e.target.select()} onChange={(e) => { const v = parseInt(e.target.value.replace(/[^0-9]/g, ""), 10); if (!isNaN(v)) setPageNum(Math.min(Math.max(1, v), numPages || 1)); }} onKeyDown={(e) => { if (e.key === "Enter") e.target.blur(); }} /> / {numPages || "–"}
{status === "loading" &&
Loading packet…
} {status === "error" &&
Couldn’t open this PDF. Try re-uploading the board packet.
}
); }); window.PdfViewer = PdfViewer; })();