import { useEffect, useMemo, useRef, useState } from "react"; import { Document, Page, pdfjs } from "react-pdf"; import "./PdfViewer.css"; // Use new URL(..., import.meta.url) — understood by Rolldown/Rollup on all platforms, // including Windows where ?raw query strings are invalid in file paths. pdfjs.GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url).toString(); type PdfViewerProps = { url?: string; file?: string | Blob | ArrayBuffer }; export function PdfViewer({ url, file }: PdfViewerProps) { let [numPages, setNumPages] = useState(0); let [error, setError] = useState(null); let containerRef = useRef(null); let [containerWidth, setContainerWidth] = useState(0); let [containerHeight, setContainerHeight] = useState(0); let [scale, setScale] = useState(1); let [naturalPageSize, setNaturalPageSize] = useState<{ w: number; h: number; } | null>(null); const pages = useMemo(() => Array.from({ length: numPages }, (_, i) => i + 1), [numPages]); // Observe container size to support fit-to-page calculations useEffect(() => { let el = containerRef.current; if (!el) return; let ro = new ResizeObserver((entries) => { let cr = entries[0].contentRect; setContainerWidth(Math.max(1, Math.floor(cr.width))); setContainerHeight(Math.max(1, Math.floor(cr.height))); }); ro.observe(el); setContainerWidth(Math.max(1, Math.floor(el.clientWidth))); setContainerHeight(Math.max(1, Math.floor(el.clientHeight))); return () => ro.disconnect(); }, []); // Intercept browser zoom gestures and zoom the PDF instead useEffect(() => { let el = containerRef.current; if (!el) return; const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v)); const zoomBy = (delta: number) => { // Smooth exponential zoom for trackpads/mice const factor = Math.exp(delta * 0.001); setScale((s) => clamp(s * factor, 0.25, 5)); }; const onWheel = (e: WheelEvent) => { if (e.ctrlKey) { // ctrl+wheel is usually browser zoom; override for PDF zoom e.preventDefault(); zoomBy(-e.deltaY); } }; const onKeyDown = (e: KeyboardEvent) => { if (!e.ctrlKey) return; const key = e.key; if (key === "+" || key === "=" || key === "Add") { e.preventDefault(); setScale((s) => clamp(s * 1.1, 0.25, 5)); } else if (key === "-" || key === "_" || key === "Subtract") { e.preventDefault(); setScale((s) => clamp(s / 1.1, 0.25, 5)); } else if (key === "0") { e.preventDefault(); setScale(1); } }; el.addEventListener("wheel", onWheel, { passive: false }); window.addEventListener("keydown", onKeyDown, { passive: false }); return () => { el.removeEventListener("wheel", onWheel as EventListener); window.removeEventListener("keydown", onKeyDown as EventListener); }; }, []); // When source changes, reset auto-fit baseline useEffect(() => { setNaturalPageSize(null); setScale(1); }, [url, file]); // Compute base width to fit full page into container, then apply zoom scale. // If we don't yet know page's natural size, default to fit-to-width. const fitBaseWidth = useMemo(() => { if (!naturalPageSize) return containerWidth || 1000; const { w, h } = naturalPageSize; if (w <= 0 || h <= 0) return containerWidth || 1000; // width to fit by height: containerHeight * (w/h) const widthByHeight = (containerHeight || 1000) * (w / h); // Fit to page means respecting both constraints return Math.min(Math.max(1, containerWidth || 1000), Math.max(1, widthByHeight)); }, [naturalPageSize, containerWidth, containerHeight]); const pageWidth = Math.max(1, Math.round(fitBaseWidth * scale)); return (
setNumPages(numPages)} onLoadError={(e) => setError((e as Error)?.message ?? "Failed to load PDF")} > {pages.map((pageNumber) => ( { // Capture natural page size once for fit-to-page calculation try { // pdf.js viewport at scale 1 const vp = p.getViewport({ scale: 1 }); const w = Number(vp?.width) || Number((p as any).width) || 0; const h = Number(vp?.height) || Number((p as any).height) || 0; if (w > 0 && h > 0 && !naturalPageSize) { setNaturalPageSize({ w, h }); } } catch { // Ignore, fallback to fit-to-width when unknown } }} /> ))}
{error &&
Error loading PDF: {error}
}
); }