import { useEffect, useEffectEvent, useLayoutEffect, useRef, useState, type CSSProperties, } from "react"; import { TextLayer, type PDFDocumentProxy } from "pdfjs-dist"; import { ChevronDownIcon, ChevronUpIcon, MinusIcon, PlusIcon, ScanIcon } from "lucide-react"; import { Input } from "~/components/ui/input"; import { observeResize } from "~/lib/observeResize"; import { FileSurfaceAction, FileSurfaceFailure, FileSurfaceLoading } from "./fileSurfaceChrome"; import { readBoundedDocument } from "./readBoundedDocument"; import { openPdf } from "./pdfDocument"; import { currentPdfPage, pdfFindIncludes, pdfFindMatchRuns, pdfPageOffsets, pdfPageScrollTop, pdfPageText, pdfPageWidth, PDF_MAX_ZOOM, PDF_MIN_ZOOM, stepPdfZoom, visiblePdfPages, } from "./pdfLayout"; import "./pdfViewer.css"; const MAX_PAGES = 2000; const MAX_FIND_TEXT = 4 * 1024 * 1024; export type PdfViewState = { page: number; offset: number; zoom: number | null; left: number; }; function PdfPage(props: { document: PDFDocumentProxy; index: number; width: number; query: string; onSize: (index: number, ratio: number) => void; onError: (error: string) => void; }) { const canvas = useRef(null); const text = useRef(null); const { document: pdf, index, width, onSize, onError } = props; // oxlint-disable-next-line t3code/react-effect-budget -- Render the PDF page canvas and text layer, cancelling tasks and releasing pdf.js page resources on cleanup. useEffect(() => { let cancelled = false; let paint: ReturnType>["render"]> | undefined; let layer: TextLayer | undefined; let loadedPage: Awaited> | undefined; void (async () => { const page = await pdf.getPage(index + 1); loadedPage = page; if (cancelled || !canvas.current || !text.current) { page.cleanup(); return; } const natural = page.getViewport({ scale: 1 }); onSize(index, natural.height / natural.width); const viewport = page.getViewport({ scale: width / natural.width }); const ratio = Math.min( 2, window.devicePixelRatio || 1, Math.sqrt((8 * 1024 * 1024) / (viewport.width * viewport.height)), ); canvas.current.width = Math.ceil(viewport.width * ratio); canvas.current.height = Math.ceil(viewport.height * ratio); paint = page.render({ canvas: canvas.current, viewport, transform: [ratio, 0, 0, ratio, 0, 0], background: "white", }); text.current.replaceChildren(); text.current.style.setProperty("--total-scale-factor", String(viewport.scale)); layer = new TextLayer({ container: text.current, viewport, textContentSource: page.streamTextContent(), }); await Promise.all([paint.promise, layer.render()]); })().catch((cause: unknown) => { if (!cancelled) onError(cause instanceof Error ? cause.message : "This PDF page could not render."); }); return () => { cancelled = true; paint?.cancel(); layer?.cancel(); // Release operator lists when virtual pages leave the viewport. void Promise.allSettled([paint?.promise]).then(() => loadedPage?.cleanup()); }; }, [pdf, index, width, onSize, onError]); // oxlint-disable-next-line t3code/react-effect-budget -- Highlight pdf.js-owned text nodes and observe asynchronous text layer mutations for the current search query. useEffect(() => { const node = text.current; if (!node) return; const mark = () => { const elements = Array.from(node.querySelectorAll('span[role="presentation"], br')); const hits = pdfFindMatchRuns( elements.map((element) => (element.tagName === "BR" ? "\n" : (element.textContent ?? ""))), props.query, ); elements.forEach((element, index) => element.classList.toggle("pdf-find-match", hits[index])); }; mark(); const observer = new MutationObserver(mark); observer.observe(node, { childList: true, subtree: true }); return () => observer.disconnect(); }, [props.query]); return ( <>
); } export default function PdfViewer(props: { src: string; title: string; initialView?: PdfViewState; onViewChange?: (view: PdfViewState) => void; }) { const [initialView] = useState( () => props.initialView ?? { page: 0, offset: 0, zoom: null, left: 0 }, ); const [pdf, setPdf] = useState(null); const [error, setError] = useState(null); const [revision, setRevision] = useState(0); const [ratios, setRatios] = useState([]); const [zoom, setZoom] = useState(initialView.zoom); const [pageWidth, setPageWidth] = useState(612); const [viewport, setViewport] = useState({ width: 600, height: 800 }); const [scrollTop, setScrollTop] = useState(0); const [query, setQuery] = useState(""); const [matches, setMatches] = useState([]); const [finding, setFinding] = useState(false); const [findError, setFindError] = useState(null); const [match, setMatch] = useState(0); const scroll = useRef(null); const find = useRef(null); const zoomAnchor = useRef<{ page: number; offset: number; width: number } | null>(null); const initialPage = initialView.page; // oxlint-disable-next-line t3code/react-effect-budget -- Own the PDF fetch, load timeout, and pdf.js document task across source changes, retries, and unmounts. useEffect(() => { const controller = new AbortController(); let disposed = false; let task: ReturnType | undefined; const timeout = setTimeout(() => { setError("This PDF took too long to load. Try again."); controller.abort(); void task?.destroy(); }, 30_000); void readBoundedDocument( props.src, controller.signal, 50 * 1024 * 1024, revision > 0 ? "reload" : "default", ) .then(async (bytes) => { controller.signal.throwIfAborted(); task = openPdf(bytes); const document = await task.promise; if (document.numPages > MAX_PAGES) throw new Error("Preview limited to 2,000 pages. Save this PDF to read it in full."); const first = (await document.getPage(1)).getViewport({ scale: 1 }); const sizes = await Promise.all( Array.from({ length: Math.min(initialPage + 1, document.numPages) }, async (_, index) => { const size = (await document.getPage(index + 1)).getViewport({ scale: 1 }); return size.height / size.width; }), ); if (controller.signal.aborted) return; setPageWidth(first.width); setRatios( Array.from( { length: document.numPages }, (_, index) => sizes[index] ?? first.height / first.width, ), ); setPdf(document); }) .catch((cause: unknown) => { if (!disposed && !controller.signal.aborted) setError(cause instanceof Error ? cause.message : "Unable to open PDF."); }) .finally(() => clearTimeout(timeout)); return () => { disposed = true; controller.abort(); clearTimeout(timeout); void task?.destroy(); }; }, [props.src, revision, initialPage]); // oxlint-disable-next-line t3code/react-effect-budget -- Measure the PDF viewport before paint and observe resizes for fit-width sizing and visible-page selection. useLayoutEffect(() => { const node = scroll.current; if (!node) return; const update = () => setViewport({ width: node.clientWidth, height: node.clientHeight }); update(); return observeResize(node, update); }, []); const { scale, width } = pdfPageWidth(viewport.width, pageWidth, zoom); const offsets = pdfPageOffsets(ratios, width); const current = currentPdfPage(offsets, ratios.length, scrollTop); const visible = visiblePdfPages(ratios, offsets, scrollTop, viewport.height); // Stable callbacks let page canvases survive scroll and find updates. const [onSize] = useState( () => (index: number, ratio: number) => setRatios((old) => old[index] === ratio ? old : old.map((value, i) => (i === index ? ratio : value)), ), ); const jumpToPage = useEffectEvent((index: number) => { scroll.current?.scrollTo({ top: pdfPageScrollTop(offsets, index) }); }); const saveView = (node: HTMLDivElement) => { const page = currentPdfPage(offsets, ratios.length, node.scrollTop); props.onViewChange?.({ page, offset: Math.max(0, node.scrollTop - (offsets[page]! - 16)), zoom, left: node.scrollLeft, }); }; const restoreView = useEffectEvent(() => { const node = scroll.current; if (!pdf || !node) return; const page = Math.min(initialPage, pdf.numPages - 1); node.scrollTo({ top: offsets[page]! - 16 + Math.min(initialView.offset, Math.max(0, width * ratios[page]! - 1)), left: initialView.left, }); saveView(node); }); // oxlint-disable-next-line t3code/react-effect-budget -- Restore the saved PDF scroll position after the loaded document's page layout commits. useLayoutEffect(() => { if (pdf) restoreView(); }, [pdf]); const showPage = (index: number) => { scroll.current?.scrollTo({ top: pdfPageScrollTop(offsets, index) }); }; const changeZoom = (value: number | null) => { zoomAnchor.current = { page: current, offset: (scroll.current?.scrollTop ?? 0) - (offsets[current]! - 16), width, }; setZoom(value); }; // oxlint-disable-next-line t3code/react-effect-budget -- Restore the DOM scroll anchor after the new zoom width has committed to page layout. useLayoutEffect(() => { const anchor = zoomAnchor.current; zoomAnchor.current = null; const node = scroll.current; if (!anchor || !node) return; if (anchor.width !== width) { node.scrollTo({ top: offsets[anchor.page]! - 16 + (anchor.offset * width) / anchor.width, }); } saveView(node); }); // oxlint-disable-next-line t3code/react-effect-budget -- Scroll to the first asynchronous search result after its measured page offsets have committed. useLayoutEffect(() => { if (matches.length) jumpToPage(matches[0]!); }, [matches]); // oxlint-disable-next-line t3code/react-effect-budget -- Debounce pdf.js text extraction and cancel obsolete searches when the query or document changes. useEffect(() => { if (!pdf || !query.trim()) { // oxlint-disable-next-line react/set-state-in-effect -- A blank query cancels the previous search. setFinding(false); return; } let cancelled = false; const timer = setTimeout(() => { setFinding(true); setFindError(null); setMatches([]); setMatch(0); void (async () => { const found: number[] = []; const sizes: number[] = []; let size = 0; for (let index = 0; index < pdf.numPages; index++) { if (cancelled) return; const page = await pdf.getPage(index + 1); const pageSize = page.getViewport({ scale: 1 }); sizes.push(pageSize.height / pageSize.width); const content = await page.getTextContent(); const text = pdfPageText(content.items); size += text.length; if (size > MAX_FIND_TEXT) throw new Error( "This PDF has too much text to search. Save it to search in another app.", ); if (pdfFindIncludes(text, query)) found.push(index); } if (cancelled) return; setRatios(sizes); setMatches(found); })() .catch((cause: unknown) => { if (!cancelled) setFindError(cause instanceof Error ? cause.message : "Could not search this PDF."); }) .finally(() => { if (!cancelled) setFinding(false); }); }, 250); return () => { cancelled = true; clearTimeout(timer); }; }, [pdf, query]); return (
{pdf ? `${current + 1} / ${pdf.numPages} pages` : "PDF"} showPage(current - 1)} > = pdf.numPages} onPress={() => showPage(current + 1)} > changeZoom(stepPdfZoom(scale, -1))} > {zoom === null ? "Fit width" : `${Math.round(scale * 100)}%`} = PDF_MAX_ZOOM} onPress={() => changeZoom(stepPdfZoom(scale, 1))} > { changeZoom(null); scroll.current?.scrollTo({ left: 0 }); }} >
{ setQuery(event.target.value); setMatches([]); setFindError(null); }} />
{query ? ( <> {finding ? "Searching…" : (findError ?? `${matches.length ? match + 1 : 0}/${matches.length}`)} { const next = (match + 1) % matches.length; setMatch(next); showPage(matches[next]!); }} > ) : null}
{ const top = event.currentTarget.scrollTop; setScrollTop(top); saveView(event.currentTarget); }} onKeyDown={(event) => { if ((event.ctrlKey || event.metaKey) && event.key === "f") { event.preventDefault(); event.stopPropagation(); find.current?.focus(); } }} > {error ? ( { setError(null); setPdf(null); setRevision((value) => value + 1); }} /> ) : !pdf ? ( ) : (
{visible.map(({ index, ratio }) => (
))}
)}
); }