lazy pdf rendering
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user