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; const MAX_POSITIONS = 50; type PdfPosition = { readonly page: number; /** Pixels scrolled past the page's top, measured at `width`. */ readonly offset: number; readonly width: number; readonly zoom: number | null; /** Measured page shapes, so offsets above the saved page match the last visit. */ readonly ratios: readonly number[]; }; // Leaving a thread unmounts its preview, so reading positions outlive the viewer. // The least recently viewed entries go first, so the map stays bounded in long sessions. const positions = new Map(); function rememberPosition(key: string, position: PdfPosition) { positions.delete(key); positions.set(key, position); const oldest = positions.keys().next(); if (positions.size > MAX_POSITIONS && !oldest.done) positions.delete(oldest.value); } 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/no-unreasoned-state-sync -- 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/no-unreasoned-state-sync -- 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 ( <>
); } /** `positionKey` names the document and revision whose reading position is restored on remount. */ export default function PdfViewer(props: { src: string; title: string; positionKey?: string }) { const [pdf, setPdf] = useState(null); const [error, setError] = useState(null); const [revision, setRevision] = useState(0); const [ratios, setRatios] = useState([]); const [zoom, setZoom] = useState(() => props.positionKey === undefined ? null : (positions.get(props.positionKey)?.zoom ?? null), ); 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 savedPosition = useEffectEvent(() => props.positionKey === undefined ? undefined : positions.get(props.positionKey), ); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- 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 }); if (controller.signal.aborted) return; setPageWidth(first.width); const saved = savedPosition(); setRatios( saved?.ratios.length === document.numPages ? saved.ratios : Array.from({ length: document.numPages }, () => 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]); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- 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 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); }; const restorePosition = useEffectEvent(() => { const saved = savedPosition(); const node = scroll.current; if (!saved || !node) return; const page = Math.min(saved.page, ratios.length - 1); node.scrollTo({ top: offsets[page]! - 16 + (saved.offset * width) / saved.width }); // Sync now so the first painted page count matches the restored scroll. setScrollTop(node.scrollTop); }); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Restore the saved reading position once the loaded document has laid out its pages. useLayoutEffect(() => { if (pdf) restorePosition(); }, [pdf]); const savePosition = (node: HTMLDivElement) => { if (!pdf || props.positionKey === undefined) return; const page = currentPdfPage(offsets, ratios.length, node.scrollTop); rememberPosition(props.positionKey, { page, offset: node.scrollTop - pdfPageScrollTop(offsets, page), width, zoom, ratios, }); }; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- 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 (!node || !pdf) return; if (anchor && anchor.width !== width) node.scrollTo({ top: offsets[anchor.page]! - 16 + (anchor.offset * width) / anchor.width }); savePosition(node); }); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- 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/no-unreasoned-state-sync -- 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}
{ setScrollTop(event.currentTarget.scrollTop); savePosition(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 }) => (
))}
)}
); }