diff --git a/surfsense_web/components/report-panel/pdf-viewer.tsx b/surfsense_web/components/report-panel/pdf-viewer.tsx index 48fab89cb..828cadc4f 100644 --- a/surfsense_web/components/report-panel/pdf-viewer.tsx +++ b/surfsense_web/components/report-panel/pdf-viewer.tsx @@ -18,24 +18,73 @@ interface PdfViewerProps { isPublic?: boolean; } +interface PageDimensions { + width: number; + height: number; +} + const ZOOM_STEP = 0.15; const MIN_ZOOM = 0.5; const MAX_ZOOM = 3; const PAGE_GAP = 12; +const SCROLL_DEBOUNCE_MS = 30; +const BUFFER_PAGES = 1; export function PdfViewer({ pdfUrl, isPublic = false }: PdfViewerProps) { const [numPages, setNumPages] = useState(0); const [scale, setScale] = useState(1); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(null); - const [currentPage, setCurrentPage] = useState(1); const scrollContainerRef = useRef(null); - const pagesContainerRef = useRef(null); const pdfDocRef = useRef(null); const canvasRefs = useRef>(new Map()); const renderTasksRef = useRef>(new Map()); const renderedScalesRef = useRef>(new Map()); + const pageDimsRef = useRef([]); + const visiblePagesRef = useRef>(new Set()); + const scrollTimerRef = useRef | null>(null); + + const getScaledHeight = useCallback( + (pageIndex: number) => { + const dims = pageDimsRef.current[pageIndex]; + return dims ? Math.floor(dims.height * scale) : 0; + }, + [scale], + ); + + const getVisibleRange = useCallback(() => { + const container = scrollContainerRef.current; + if (!container || pageDimsRef.current.length === 0) return { first: 1, last: 1 }; + + const scrollTop = container.scrollTop; + const viewportHeight = container.clientHeight; + const scrollBottom = scrollTop + viewportHeight; + + let cumTop = 16; + let first = 1; + let last = pageDimsRef.current.length; + + for (let i = 0; i < pageDimsRef.current.length; i++) { + const pageHeight = getScaledHeight(i); + const pageBottom = cumTop + pageHeight; + + if (pageBottom >= scrollTop && first === 1) { + first = i + 1; + } + if (cumTop > scrollBottom) { + last = i; + break; + } + + cumTop = pageBottom + PAGE_GAP; + } + + first = Math.max(1, first - BUFFER_PAGES); + last = Math.min(pageDimsRef.current.length, last + BUFFER_PAGES); + + return { first, last }; + }, [getScaledHeight]); const renderPage = useCallback(async (pageNum: number, currentScale: number) => { const pdf = pdfDocRef.current; @@ -71,12 +120,51 @@ export function PdfViewer({ pdfUrl, isPublic = false }: PdfViewerProps) { await renderTask.promise; renderTasksRef.current.delete(pageNum); renderedScalesRef.current.set(pageNum, currentScale); + page.cleanup(); } catch (err: unknown) { if (err instanceof Error && err.message?.includes("cancelled")) return; console.error(`Failed to render page ${pageNum}:`, err); } }, []); + const cleanupPage = useCallback((pageNum: number) => { + const existing = renderTasksRef.current.get(pageNum); + if (existing) { + existing.cancel(); + renderTasksRef.current.delete(pageNum); + } + + const canvas = canvasRefs.current.get(pageNum); + if (canvas) { + const ctx = canvas.getContext("2d"); + if (ctx) ctx.clearRect(0, 0, canvas.width, canvas.height); + canvas.width = 0; + canvas.height = 0; + } + + renderedScalesRef.current.delete(pageNum); + }, []); + + const renderVisiblePages = useCallback(() => { + if (!pdfDocRef.current || pageDimsRef.current.length === 0) return; + + const { first, last } = getVisibleRange(); + const newVisible = new Set(); + + for (let i = first; i <= last; i++) { + newVisible.add(i); + renderPage(i, scale); + } + + for (const pageNum of visiblePagesRef.current) { + if (!newVisible.has(pageNum)) { + cleanupPage(pageNum); + } + } + + visiblePagesRef.current = newVisible; + }, [getVisibleRange, renderPage, cleanupPage, scale]); + useEffect(() => { let cancelled = false; @@ -84,7 +172,7 @@ export function PdfViewer({ pdfUrl, isPublic = false }: PdfViewerProps) { setLoading(true); setLoadError(null); setNumPages(0); - setCurrentPage(1); + pageDimsRef.current = []; try { const loadingTask = pdfjsLib.getDocument({ @@ -98,7 +186,21 @@ export function PdfViewer({ pdfUrl, isPublic = false }: PdfViewerProps) { return; } + const dims: PageDimensions[] = []; + for (let i = 1; i <= pdf.numPages; i++) { + const page = await pdf.getPage(i); + const viewport = page.getViewport({ scale: 1 }); + dims.push({ width: viewport.width, height: viewport.height }); + page.cleanup(); + } + + if (cancelled) { + pdf.destroy(); + return; + } + pdfDocRef.current = pdf; + pageDimsRef.current = dims; setNumPages(pdf.numPages); setLoading(false); } catch (err: unknown) { @@ -118,52 +220,42 @@ export function PdfViewer({ pdfUrl, isPublic = false }: PdfViewerProps) { } renderTasksRef.current.clear(); renderedScalesRef.current.clear(); + visiblePagesRef.current.clear(); pdfDocRef.current?.destroy(); pdfDocRef.current = null; }; }, [pdfUrl]); useEffect(() => { - if (!pdfDocRef.current || numPages === 0) return; + if (numPages === 0) return; renderedScalesRef.current.clear(); + visiblePagesRef.current.clear(); - for (let i = 1; i <= numPages; i++) { - renderPage(i, scale); - } - }, [scale, numPages, renderPage]); + const frame = requestAnimationFrame(() => { + renderVisiblePages(); + }); + + return () => cancelAnimationFrame(frame); + }, [numPages, renderVisiblePages]); useEffect(() => { const container = scrollContainerRef.current; - if (!container || numPages <= 1) return; + if (!container || numPages === 0) return; const handleScroll = () => { - const canvases = canvasRefs.current; - const containerTop = container.scrollTop; - const containerMid = containerTop + container.clientHeight / 2; - - let closest = 1; - let closestDist = Number.POSITIVE_INFINITY; - - for (let i = 1; i <= numPages; i++) { - const canvas = canvases.get(i); - if (!canvas) continue; - const rect = canvas.getBoundingClientRect(); - const containerRect = container.getBoundingClientRect(); - const canvasMid = rect.top - containerRect.top + containerTop + rect.height / 2; - const dist = Math.abs(canvasMid - containerMid); - if (dist < closestDist) { - closestDist = dist; - closest = i; - } - } - - setCurrentPage(closest); + if (scrollTimerRef.current) clearTimeout(scrollTimerRef.current); + scrollTimerRef.current = setTimeout(() => { + renderVisiblePages(); + }, SCROLL_DEBOUNCE_MS); }; container.addEventListener("scroll", handleScroll, { passive: true }); - return () => container.removeEventListener("scroll", handleScroll); - }, [numPages]); + return () => { + container.removeEventListener("scroll", handleScroll); + if (scrollTimerRef.current) clearTimeout(scrollTimerRef.current); + }; + }, [numPages, renderVisiblePages]); const setCanvasRef = useCallback((pageNum: number, el: HTMLCanvasElement | null) => { if (el) { @@ -184,7 +276,7 @@ export function PdfViewer({ pdfUrl, isPublic = false }: PdfViewerProps) { if (loadError) { return (
-

Failed to load resume preview

+

Failed to load PDF

{loadError}

); @@ -194,14 +286,6 @@ export function PdfViewer({ pdfUrl, isPublic = false }: PdfViewerProps) {
{numPages > 0 && (
- {numPages > 1 && ( - <> - - {currentPage} / {numPages} - -
- - )} @@ -223,18 +307,29 @@ export function PdfViewer({ pdfUrl, isPublic = false }: PdfViewerProps) {
) : ( -
- {Array.from({ length: numPages }, (_, i) => i + 1).map((pageNum) => ( - setCanvasRef(pageNum, el)} - className="shadow-lg" - /> - ))} +
+ {pageDimsRef.current.map((dims, i) => { + const pageNum = i + 1; + const scaledWidth = Math.floor(dims.width * scale); + const scaledHeight = Math.floor(dims.height * scale); + return ( +
+ setCanvasRef(pageNum, el)} + className="shadow-lg absolute inset-0" + /> + {numPages > 1 && ( + + Page {pageNum}/{numPages} + + )} +
+ ); + })}
)}