lazy pdf rendering

This commit is contained in:
2025-11-17 00:22:20 +01:00
parent 16c28bcba1
commit 5ffbcc47e3
2 changed files with 265 additions and 52 deletions
+234 -48
View File
@@ -39,8 +39,6 @@ interface PageDescriptor {
scale: number; scale: number;
} }
const MIN_RENDER_WIDTH = 1200;
const MAX_RENDER_WIDTH = 2200;
const RERENDER_DELTA = 48; const RERENDER_DELTA = 48;
const getAvailableViewportSize = (viewportNode: Element, stackNode: Element) => { const getAvailableViewportSize = (viewportNode: Element, stackNode: Element) => {
@@ -82,8 +80,11 @@ const PdfViewer = ({ src, title, className, viewportRef }: PdfViewerProps): JSX.
const [pages, setPages] = useState<PageDescriptor[]>([]); const [pages, setPages] = useState<PageDescriptor[]>([]);
const [pixelRatio, setPixelRatio] = useState(1); const [pixelRatio, setPixelRatio] = useState(1);
const [viewMode, setViewMode] = useState<ViewMode>('contain'); const [viewMode, setViewMode] = useState<ViewMode>('contain');
const [visiblePages, setVisiblePages] = useState<Set<number>>(new Set());
const pdfRef = useRef<PDFDocumentProxy | null>(null); const pdfRef = useRef<PDFDocumentProxy | null>(null);
const wasmUrlRef = useRef<string | null>(null); const wasmUrlRef = useRef<string | null>(null);
const pageNodeMapRef = useRef<Map<number, HTMLElement>>(new Map());
const intersectionObserverRef = useRef<IntersectionObserver | null>(null);
const focusTargetRef = useRef<{ const focusTargetRef = useRef<{
ratioX: number; ratioX: number;
ratioY: number; ratioY: number;
@@ -152,7 +153,7 @@ const PdfViewer = ({ src, title, className, viewportRef }: PdfViewerProps): JSX.
if (viewportWidth <= 0) { if (viewportWidth <= 0) {
return; return;
} }
const desiredWidth = Math.min(MAX_RENDER_WIDTH, Math.max(viewportWidth, MIN_RENDER_WIDTH)); const desiredWidth = viewportWidth;
setRenderWidth((current) => { setRenderWidth((current) => {
if (current === 0 || desiredWidth > current + RERENDER_DELTA) { if (current === 0 || desiredWidth > current + RERENDER_DELTA) {
return desiredWidth; return desiredWidth;
@@ -214,8 +215,6 @@ const PdfViewer = ({ src, title, className, viewportRef }: PdfViewerProps): JSX.
setPages([]); setPages([]);
setStatus('loading'); setStatus('loading');
setErrorMessage(null); setErrorMessage(null);
console.log('[PdfViewer] rendering pages', { renderWidth, mode: viewMode, src });
const renderDocument = async () => { const renderDocument = async () => {
try { try {
loadingTask = getDocument({ loadingTask = getDocument({
@@ -322,6 +321,100 @@ const PdfViewer = ({ src, title, className, viewportRef }: PdfViewerProps): JSX.
toggleViewMode(); toggleViewMode();
}, [toggleViewMode, viewportRef]); }, [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(() => ( const pageElements = useMemo(() => (
pages.map((page) => ( pages.map((page) => (
<Fragment key={`${page.number}-${Math.round(page.scale * 100)}-${Math.round(pixelRatio * 100)}`}> <Fragment key={`${page.number}-${Math.round(page.scale * 100)}-${Math.round(pixelRatio * 100)}`}>
@@ -329,13 +422,17 @@ const PdfViewer = ({ src, title, className, viewportRef }: PdfViewerProps): JSX.
descriptor={page} descriptor={page}
pdfRef={pdfRef} pdfRef={pdfRef}
pixelRatio={pixelRatio} pixelRatio={pixelRatio}
rootRef={containerRef}
viewportRef={viewportRef}
onPageClick={handlePageClick} onPageClick={handlePageClick}
viewportWidth={viewportWidth}
viewportHeight={viewportHeight}
viewMode={viewMode}
shouldRender={shouldRenderPage(page.number)}
registerPageNode={registerPageNode}
/> />
</Fragment> </Fragment>
)) ))
), [handlePageClick, pages, pixelRatio, viewportRef]); ), [handlePageClick, pages, pixelRatio, registerPageNode, shouldRenderPage,
viewportHeight, viewportWidth, viewMode]);
return ( return (
<div className={viewerClasses}> <div className={viewerClasses}>
@@ -363,23 +460,34 @@ interface PdfPageCanvasProps {
descriptor: PageDescriptor; descriptor: PageDescriptor;
pdfRef: MutableRefObject<PDFDocumentProxy | null>; pdfRef: MutableRefObject<PDFDocumentProxy | null>;
pixelRatio: number; pixelRatio: number;
rootRef: RefObject<HTMLDivElement>;
viewportRef?: RefObject<HTMLElement | null>;
onPageClick: (event: React.MouseEvent<HTMLDivElement>) => void; onPageClick: (event: React.MouseEvent<HTMLDivElement>) => void;
viewportWidth: number;
viewportHeight: number;
viewMode: ViewMode;
shouldRender: boolean;
registerPageNode: (pageNumber: number, node: HTMLElement | null) => void;
} }
function PdfPageCanvas({ function PdfPageCanvas({
descriptor, descriptor,
pdfRef, pdfRef,
pixelRatio, pixelRatio,
rootRef,
viewportRef,
onPageClick, onPageClick,
viewportWidth,
viewportHeight,
viewMode,
shouldRender,
registerPageNode,
}: PdfPageCanvasProps): JSX.Element { }: PdfPageCanvasProps): JSX.Element {
const wrapperRef = useRef<HTMLDivElement>(null); const wrapperRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const renderTaskRef = useRef<RenderTask | null>(null); const renderTaskRef = useRef<RenderTask | null>(null);
const hasRenderedRef = useRef(false); 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 () => { const renderPage = useCallback(async () => {
if (renderTaskRef.current || hasRenderedRef.current) { if (renderTaskRef.current || hasRenderedRef.current) {
@@ -393,23 +501,56 @@ function PdfPageCanvas({
try { try {
const page = await pdf.getPage(descriptor.number); const page = await pdf.getPage(descriptor.number);
const viewport = page.getViewport({ scale: descriptor.scale }); const viewport = page.getViewport({ scale: descriptor.scale });
const context = canvas.getContext('2d'); const qualityScale = 1;
if (!context) { const supportsOffscreen = typeof OffscreenCanvas !== 'undefined';
return; 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; renderContext?.setTransform(pixelRatio * qualityScale, 0, 0, pixelRatio * qualityScale, 0, 0);
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
const renderTask = page.render({ canvasContext: context, viewport, canvas }); const renderTask = page.render({
canvasContext: renderContext as CanvasRenderingContext2D,
viewport,
canvas: supportsOffscreen ? undefined : canvas,
});
renderTaskRef.current = renderTask; renderTaskRef.current = renderTask;
await renderTask.promise; await renderTask.promise;
hasRenderedRef.current = true; 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; const wrapper = wrapperRef.current;
if (wrapper) { if (wrapper) {
wrapper.querySelector('.textLayer')?.remove(); wrapper.querySelector('.textLayer')?.remove();
} }
} catch (error) { } catch (error) {
if (error instanceof Error && error.name === 'RenderingCancelledException') {
return;
}
console.error('Failed to render PDF page', descriptor.number, error); console.error('Failed to render PDF page', descriptor.number, error);
} finally { } finally {
renderTaskRef.current = null; renderTaskRef.current = null;
@@ -417,6 +558,9 @@ function PdfPageCanvas({
}, [descriptor.number, descriptor.scale, pdfRef, pixelRatio]); }, [descriptor.number, descriptor.scale, pdfRef, pixelRatio]);
useEffect(() => { useEffect(() => {
if (!canvasMounted) {
return undefined;
}
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas) { if (!canvas) {
return undefined; return undefined;
@@ -429,45 +573,87 @@ function PdfPageCanvas({
renderTaskRef.current?.cancel(); renderTaskRef.current?.cancel();
wrapperNode?.querySelector('.textLayer')?.remove(); wrapperNode?.querySelector('.textLayer')?.remove();
}; };
}, [descriptor.height, descriptor.width]); }, [canvasMounted, descriptor.height, descriptor.width]);
useEffect(() => { useEffect(() => {
const target = wrapperRef.current; setCanvasMounted(false);
if (!target) { hasRenderedRef.current = false;
return undefined; renderTaskRef.current?.cancel();
}, [descriptor.number]);
useEffect(() => {
if (shouldRender) {
setCanvasMounted((prev) => (prev ? prev : true));
return;
} }
const root = viewportRef?.current setCanvasMounted((prev) => {
|| rootRef.current?.closest?.('.document-viewer__viewport') as HTMLElement | null if (!prev) {
|| undefined; return prev;
const observer = new IntersectionObserver((entries) => { }
entries.forEach((entry) => { hasRenderedRef.current = false;
if (!entry.isIntersecting || hasRenderedRef.current) { renderTaskRef.current?.cancel();
return; wrapperRef.current?.querySelector('.textLayer')?.remove();
} return false;
void renderPage();
});
}, {
root,
rootMargin: '200px 0px',
threshold: 0.1,
}); });
observer.observe(target); }, [shouldRender]);
return () => observer.disconnect();
}, [renderPage, rootRef, viewportRef]); 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 ( return (
<div <div
ref={wrapperRef} ref={setWrapperNode}
className="pdf-viewer__page-wrapper" className={wrapperClassName}
data-page-number={descriptor.number}
style={{
aspectRatio: `${descriptor.width} / ${descriptor.height}`,
'--pdf-viewer-page-width': `${descriptor.width}px`,
'--pdf-viewer-page-height': `${descriptor.height}px`,
'--pdf-viewer-page-aspect': `${descriptor.width / descriptor.height}`,
}}
onClick={(event) => { onClick={(event) => {
onPageClick(event); onPageClick(event);
}} }}
> >
<canvas {canvasMounted ? (
ref={canvasRef} <canvas
className="pdf-viewer__page" ref={canvasRef}
aria-label={`Page ${descriptor.number}`} className="pdf-viewer__page"
/> aria-label={`Page ${descriptor.number}`}
/>
) : null}
</div> </div>
); );
} }
+31 -4
View File
@@ -445,19 +445,46 @@
.pdf-viewer__page-wrapper { .pdf-viewer__page-wrapper {
position: relative; 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,\
<svg xmlns='http://www.w3.org/2000/svg' width='400' height='400' fill='white' fill-opacity='0.05'>\
<rect x='200' width='200' height='200' />\
<rect y='200' width='200' height='200' />\
</svg>");
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; 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); max-height: var(--pdf-viewer-viewport-height, 100vh);
aspect-ratio: calc(var(--pdf-viewer-page-width) / var(--pdf-viewer-page-height));
} }
.pdf-viewer__page { .pdf-viewer__page {
display: block; display: block;
width: 100%; width: 100%;
height: auto; height: 100%;
box-shadow: 0 1.25rem 2.5rem -1.25rem var(--shadow-medium);
background: var(--surface); background: var(--surface);
} }