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;
}
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<PageDescriptor[]>([]);
const [pixelRatio, setPixelRatio] = useState(1);
const [viewMode, setViewMode] = useState<ViewMode>('contain');
const [visiblePages, setVisiblePages] = useState<Set<number>>(new Set());
const pdfRef = useRef<PDFDocumentProxy | 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<{
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) => (
<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}
pdfRef={pdfRef}
pixelRatio={pixelRatio}
rootRef={containerRef}
viewportRef={viewportRef}
onPageClick={handlePageClick}
viewportWidth={viewportWidth}
viewportHeight={viewportHeight}
viewMode={viewMode}
shouldRender={shouldRenderPage(page.number)}
registerPageNode={registerPageNode}
/>
</Fragment>
))
), [handlePageClick, pages, pixelRatio, viewportRef]);
), [handlePageClick, pages, pixelRatio, registerPageNode, shouldRenderPage,
viewportHeight, viewportWidth, viewMode]);
return (
<div className={viewerClasses}>
@@ -363,23 +460,34 @@ interface PdfPageCanvasProps {
descriptor: PageDescriptor;
pdfRef: MutableRefObject<PDFDocumentProxy | null>;
pixelRatio: number;
rootRef: RefObject<HTMLDivElement>;
viewportRef?: RefObject<HTMLElement | null>;
onPageClick: (event: React.MouseEvent<HTMLDivElement>) => 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<HTMLDivElement>(null);
const wrapperRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const renderTaskRef = useRef<RenderTask | null>(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 (
<div
ref={wrapperRef}
className="pdf-viewer__page-wrapper"
ref={setWrapperNode}
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) => {
onPageClick(event);
}}
>
<canvas
ref={canvasRef}
className="pdf-viewer__page"
aria-label={`Page ${descriptor.number}`}
/>
{canvasMounted ? (
<canvas
ref={canvasRef}
className="pdf-viewer__page"
aria-label={`Page ${descriptor.number}`}
/>
) : null}
</div>
);
}
+31 -4
View File
@@ -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,\
<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;
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);
}