From 5ffbcc47e3751f1aab8df6de51186fb765b69387 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Mon, 17 Nov 2025 00:22:20 +0100 Subject: [PATCH] lazy pdf rendering --- frontend/src/preview/PdfViewer.tsx | 282 +++++++++++++++++++---- frontend/src/styles/documents/viewer.css | 35 ++- 2 files changed, 265 insertions(+), 52 deletions(-) diff --git a/frontend/src/preview/PdfViewer.tsx b/frontend/src/preview/PdfViewer.tsx index 83fc1d8..1df58c8 100644 --- a/frontend/src/preview/PdfViewer.tsx +++ b/frontend/src/preview/PdfViewer.tsx @@ -39,8 +39,6 @@ interface PageDescriptor { scale: number; } -const MIN_RENDER_WIDTH = 1200; -const MAX_RENDER_WIDTH = 2200; const RERENDER_DELTA = 48; const getAvailableViewportSize = (viewportNode: Element, stackNode: Element) => { @@ -82,8 +80,11 @@ const PdfViewer = ({ src, title, className, viewportRef }: PdfViewerProps): JSX. const [pages, setPages] = useState([]); const [pixelRatio, setPixelRatio] = useState(1); const [viewMode, setViewMode] = useState('contain'); + const [visiblePages, setVisiblePages] = useState>(new Set()); const pdfRef = useRef(null); const wasmUrlRef = useRef(null); + const pageNodeMapRef = useRef>(new Map()); + const intersectionObserverRef = useRef(null); const focusTargetRef = useRef<{ ratioX: number; ratioY: number; @@ -152,7 +153,7 @@ const PdfViewer = ({ src, title, className, viewportRef }: PdfViewerProps): JSX. if (viewportWidth <= 0) { return; } - const desiredWidth = Math.min(MAX_RENDER_WIDTH, Math.max(viewportWidth, MIN_RENDER_WIDTH)); + const desiredWidth = viewportWidth; setRenderWidth((current) => { if (current === 0 || desiredWidth > current + RERENDER_DELTA) { return desiredWidth; @@ -214,8 +215,6 @@ const PdfViewer = ({ src, title, className, viewportRef }: PdfViewerProps): JSX. setPages([]); setStatus('loading'); setErrorMessage(null); - console.log('[PdfViewer] rendering pages', { renderWidth, mode: viewMode, src }); - const renderDocument = async () => { try { loadingTask = getDocument({ @@ -322,6 +321,100 @@ const PdfViewer = ({ src, title, className, viewportRef }: PdfViewerProps): JSX. toggleViewMode(); }, [toggleViewMode, viewportRef]); + const handlePageVisibilityChange = useCallback((pageNumber: number, isVisible: boolean) => { + setVisiblePages((prev) => { + const alreadyVisible = prev.has(pageNumber); + if (alreadyVisible === isVisible) { + return prev; + } + const next = new Set(prev); + if (isVisible) { + next.add(pageNumber); + } else { + next.delete(pageNumber); + } + return next; + }); + }, []); + + const shouldRenderPage = useCallback((pageNumber: number) => { + if (visiblePages.size === 0) { + return pageNumber === 1; + } + for (const visiblePage of visiblePages) { + if (Math.abs(visiblePage - pageNumber) <= 2) { + return true; + } + } + return false; + }, [visiblePages]); + + const intersectionRoot = viewportRef?.current || null; + + const registerPageNode = useCallback((pageNumber: number, node: HTMLElement | null) => { + const map = pageNodeMapRef.current; + const existing = map.get(pageNumber); + if (existing === node) { + return; + } + if (existing) { + intersectionObserverRef.current?.unobserve(existing); + map.delete(pageNumber); + } + if (node) { + node.dataset.pageNumber = String(pageNumber); + map.set(pageNumber, node); + intersectionObserverRef.current?.observe(node); + } + }, []); + + useEffect(() => { + let frame: number | null = null; + let observer: IntersectionObserver | null = null; + + const attachObserver = () => { + const stackElement = containerRef.current; + if (!stackElement) { + frame = requestAnimationFrame(attachObserver); + return; + } + const root = intersectionRoot + || stackElement.closest('.document-viewer__viewport') + || undefined; + observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + const attr = entry.target.getAttribute('data-page-number'); + if (!attr) { + return; + } + const pageNumber = Number(attr); + if (!Number.isFinite(pageNumber) || pageNumber <= 0) { + return; + } + handlePageVisibilityChange(pageNumber, entry.isIntersecting); + }); + }, { + root: root as Element | undefined, + rootMargin: '200px 0px', + threshold: 0.1, + }); + intersectionObserverRef.current = observer; + pageNodeMapRef.current.forEach((node) => observer?.observe(node)); + }; + + attachObserver(); + + return () => { + if (frame !== null) { + cancelAnimationFrame(frame); + } + observer?.disconnect(); + if (intersectionObserverRef.current === observer) { + intersectionObserverRef.current = null; + } + }; + }, [handlePageVisibilityChange, intersectionRoot]); + const pageElements = useMemo(() => ( pages.map((page) => ( @@ -329,13 +422,17 @@ const PdfViewer = ({ src, title, className, viewportRef }: PdfViewerProps): JSX. descriptor={page} pdfRef={pdfRef} pixelRatio={pixelRatio} - rootRef={containerRef} - viewportRef={viewportRef} onPageClick={handlePageClick} + viewportWidth={viewportWidth} + viewportHeight={viewportHeight} + viewMode={viewMode} + shouldRender={shouldRenderPage(page.number)} + registerPageNode={registerPageNode} /> )) - ), [handlePageClick, pages, pixelRatio, viewportRef]); + ), [handlePageClick, pages, pixelRatio, registerPageNode, shouldRenderPage, + viewportHeight, viewportWidth, viewMode]); return (
@@ -363,23 +460,34 @@ interface PdfPageCanvasProps { descriptor: PageDescriptor; pdfRef: MutableRefObject; pixelRatio: number; - rootRef: RefObject; - viewportRef?: RefObject; onPageClick: (event: React.MouseEvent) => void; + viewportWidth: number; + viewportHeight: number; + viewMode: ViewMode; + shouldRender: boolean; + registerPageNode: (pageNumber: number, node: HTMLElement | null) => void; } function PdfPageCanvas({ descriptor, pdfRef, pixelRatio, - rootRef, - viewportRef, onPageClick, + viewportWidth, + viewportHeight, + viewMode, + shouldRender, + registerPageNode, }: PdfPageCanvasProps): JSX.Element { - const wrapperRef = useRef(null); + const wrapperRef = useRef(null); const canvasRef = useRef(null); const renderTaskRef = useRef(null); const hasRenderedRef = useRef(false); + const setWrapperNode = useCallback((node: HTMLDivElement | null) => { + wrapperRef.current = node; + registerPageNode(descriptor.number, node); + }, [descriptor.number, registerPageNode]); + const [canvasMounted, setCanvasMounted] = useState(false); const renderPage = useCallback(async () => { if (renderTaskRef.current || hasRenderedRef.current) { @@ -393,23 +501,56 @@ function PdfPageCanvas({ try { const page = await pdf.getPage(descriptor.number); const viewport = page.getViewport({ scale: descriptor.scale }); - const context = canvas.getContext('2d'); - if (!context) { - return; + const qualityScale = 1; + const supportsOffscreen = typeof OffscreenCanvas !== 'undefined'; + let renderContext: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null = null; + let offscreenCanvas: OffscreenCanvas | null = null; + + if (supportsOffscreen) { + offscreenCanvas = new OffscreenCanvas( + viewport.width * pixelRatio * qualityScale, + viewport.height * pixelRatio * qualityScale, + ); + renderContext = offscreenCanvas.getContext('2d'); + } else { + renderContext = canvas.getContext('2d'); + if (!renderContext) { + return; + } + canvas.width = viewport.width * pixelRatio; + canvas.height = viewport.height * pixelRatio; } - canvas.width = viewport.width * pixelRatio; - canvas.height = viewport.height * pixelRatio; - context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); - const renderTask = page.render({ canvasContext: context, viewport, canvas }); + + renderContext?.setTransform(pixelRatio * qualityScale, 0, 0, pixelRatio * qualityScale, 0, 0); + + const renderTask = page.render({ + canvasContext: renderContext as CanvasRenderingContext2D, + viewport, + canvas: supportsOffscreen ? undefined : canvas, + }); renderTaskRef.current = renderTask; await renderTask.promise; hasRenderedRef.current = true; + if (offscreenCanvas) { + const bitmap = offscreenCanvas.transferToImageBitmap(); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + const destContext = canvas.getContext('2d'); + destContext?.drawImage(bitmap, 0, 0); + bitmap.close(); + } + + page.cleanup(); + const wrapper = wrapperRef.current; if (wrapper) { wrapper.querySelector('.textLayer')?.remove(); } } catch (error) { + if (error instanceof Error && error.name === 'RenderingCancelledException') { + return; + } console.error('Failed to render PDF page', descriptor.number, error); } finally { renderTaskRef.current = null; @@ -417,6 +558,9 @@ function PdfPageCanvas({ }, [descriptor.number, descriptor.scale, pdfRef, pixelRatio]); useEffect(() => { + if (!canvasMounted) { + return undefined; + } const canvas = canvasRef.current; if (!canvas) { return undefined; @@ -429,45 +573,87 @@ function PdfPageCanvas({ renderTaskRef.current?.cancel(); wrapperNode?.querySelector('.textLayer')?.remove(); }; - }, [descriptor.height, descriptor.width]); + }, [canvasMounted, descriptor.height, descriptor.width]); useEffect(() => { - const target = wrapperRef.current; - if (!target) { - return undefined; + setCanvasMounted(false); + hasRenderedRef.current = false; + renderTaskRef.current?.cancel(); + }, [descriptor.number]); + + useEffect(() => { + if (shouldRender) { + setCanvasMounted((prev) => (prev ? prev : true)); + return; } - const root = viewportRef?.current - || rootRef.current?.closest?.('.document-viewer__viewport') as HTMLElement | null - || undefined; - const observer = new IntersectionObserver((entries) => { - entries.forEach((entry) => { - if (!entry.isIntersecting || hasRenderedRef.current) { - return; - } - void renderPage(); - }); - }, { - root, - rootMargin: '200px 0px', - threshold: 0.1, + setCanvasMounted((prev) => { + if (!prev) { + return prev; + } + hasRenderedRef.current = false; + renderTaskRef.current?.cancel(); + wrapperRef.current?.querySelector('.textLayer')?.remove(); + return false; }); - observer.observe(target); - return () => observer.disconnect(); - }, [renderPage, rootRef, viewportRef]); + }, [shouldRender]); + + useEffect(() => { + if (!canvasMounted) { + return; + } + void renderPage(); + }, [canvasMounted, renderPage]); + + const limitAxis: 'width' | 'height' = useMemo(() => { + if (viewMode === 'fit-width') { + return 'width'; + } + const safeViewportWidth = Math.max(1, viewportWidth); + const safeViewportHeight = Math.max(1, viewportHeight); + const safePageWidth = Math.max(1, descriptor.width); + const safePageHeight = Math.max(1, descriptor.height); + const widthScale = safeViewportWidth / safePageWidth; + const heightScale = safeViewportHeight / safePageHeight; + if (!Number.isFinite(widthScale) && Number.isFinite(heightScale)) { + return 'height'; + } + if (!Number.isFinite(heightScale) && Number.isFinite(widthScale)) { + return 'width'; + } + if (!Number.isFinite(widthScale) && !Number.isFinite(heightScale)) { + return 'width'; + } + return widthScale <= heightScale ? 'width' : 'height'; + }, [descriptor.height, descriptor.width, viewMode, viewportHeight, viewportWidth]); + + const wrapperClassName = useMemo(() => ( + ['pdf-viewer__page-wrapper', + limitAxis === 'width' ? 'pdf-viewer__page-wrapper--limit-width' : 'pdf-viewer__page-wrapper--limit-height', + ].filter(Boolean).join(' ') + ), [limitAxis]); return (
{ onPageClick(event); }} > - + {canvasMounted ? ( + + ) : null}
); } diff --git a/frontend/src/styles/documents/viewer.css b/frontend/src/styles/documents/viewer.css index 375db66..3430754 100644 --- a/frontend/src/styles/documents/viewer.css +++ b/frontend/src/styles/documents/viewer.css @@ -445,19 +445,46 @@ .pdf-viewer__page-wrapper { position: relative; + display: block; } -.pdf-viewer__canvas-stack--contain .pdf-viewer__page { +.pdf-viewer__page-wrapper:not(:has(> canvas)) { + background-color: transparent; + + background-image: url("data:image/svg+xml,\ +\ + \ + \ +"); + + background-size: 30px 30px; +} + +.pdf-viewer__canvas-stack--fit-width .pdf-viewer__page-wrapper--limit-width { + width: var(--pdf-viewer-page-width, 100%); + height: auto; + aspect-ratio: calc(var(--pdf-viewer-page-width) / var(--pdf-viewer-page-height)); +} + +.pdf-viewer__canvas-stack--contain .pdf-viewer__page-wrapper--limit-height { width: auto; - max-width: min(100%, var(--pdf-viewer-viewport-width, 100vw)); + height: min(var(--pdf-viewer-page-height, 100%), var(--pdf-viewer-viewport-height, 100vh)); + max-width: min(var(--pdf-viewer-page-width, 100%), var(--pdf-viewer-viewport-width, 100vw)); + aspect-ratio: calc(var(--pdf-viewer-page-width) / var(--pdf-viewer-page-height)); +} + +.pdf-viewer__canvas-stack--contain .pdf-viewer__page-wrapper--limit-width { + width: var(--pdf-viewer-page-width, 100%); + height: auto; + max-width: min(var(--pdf-viewer-page-width, 100%), var(--pdf-viewer-viewport-width, 100vw)); max-height: var(--pdf-viewer-viewport-height, 100vh); + aspect-ratio: calc(var(--pdf-viewer-page-width) / var(--pdf-viewer-page-height)); } .pdf-viewer__page { display: block; width: 100%; - height: auto; - box-shadow: 0 1.25rem 2.5rem -1.25rem var(--shadow-medium); + height: 100%; background: var(--surface); }