import type { ImageLoadingState, PageRenderData } from '@documenso/lib/client-only/providers/envelope-render-provider'; import { PDF_VIEWER_PAGE_CLASSNAME } from '@documenso/lib/constants/pdf-viewer'; import { cn } from '@documenso/ui/lib/utils'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { Trans, useLingui } from '@lingui/react/macro'; import pMap from 'p-map'; import * as pdfjsLib from 'pdfjs-dist'; import pdfjsWorker from 'pdfjs-dist/build/pdf.worker?url'; import type React from 'react'; import { useEffect, useMemo, useRef, useState } from 'react'; import type { ScrollTarget } from '../virtual-list/use-virtual-list'; import { useVirtualList } from '../virtual-list/use-virtual-list'; import { PdfViewerPageImage } from './pdf-viewer-page-image'; import { PdfViewerErrorState, PdfViewerLoadingState } from './pdf-viewer-states'; import { useScrollToPage } from './use-scroll-to-page'; pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker; type PageMeta = { width: number; height: number; }; type LoadingState = 'loading' | 'loaded' | 'error'; const LOW_RENDER_RESOLUTION = 1; const HIGH_RENDER_RESOLUTION = 2; const IDLE_RENDER_DELAY = 200; export type PDFViewerProps = { className?: string; /** * The PDF data to render. * * If it's a URL, it will be fetched and rendered. * * If null will render an empty state. */ data: Uint8Array | string | null; /** * Ref to the scrollable parent container that handles scrolling. * * This must point to an element with `overflow-y: auto` or `overflow-y: scroll` * that is an ancestor of this component, or `'window'` to use the browser * window as the scroll container. */ scrollParentRef: ScrollTarget; onDocumentLoad?: () => void; /** * Additional component to render next to the image, such as a Konva canvas * for rendering fields. */ customPageRenderer?: React.FunctionComponent<{ pageData: PageRenderData }>; } & React.HTMLAttributes; export default function PDFViewer({ className, data, scrollParentRef, onDocumentLoad, customPageRenderer, ...props }: PDFViewerProps) { const { t } = useLingui(); const { toast } = useToast(); const $el = useRef(null); const [loadingState, setLoadingState] = useState('loading'); const pdfRef = useRef(null); const [pages, setPages] = useState([]); useEffect(() => { if (!data) { return; } let isCancelled = false; const fetchMetadata = async () => { try { setLoadingState('loading'); setPages([]); if (isCancelled) { return; } let result: Uint8Array | null = typeof data === 'string' ? null : new Uint8Array(data); if (typeof data === 'string') { const response = await fetch(data); if (!response.ok) { throw new Error(`Failed to fetch PDF data: ${response.status}`); } result = new Uint8Array(await response.arrayBuffer()); } if (isCancelled) { return; } const loadedPdf = await pdfjsLib.getDocument({ data: result!, cMapUrl: '/static/cmaps/' }).promise; if (isCancelled) { await loadedPdf.destroy(); return; } // Destroy previous PDF if it exists if (pdfRef.current) { await pdfRef.current.destroy(); } // eslint-disable-next-line require-atomic-updates pdfRef.current = loadedPdf; // Fetch the pages const pages = await pMap(Array.from({ length: loadedPdf.numPages }), async (_, pageIndex) => { const page = await loadedPdf.getPage(pageIndex + 1); const viewport = page.getViewport({ scale: 1 }); return { width: viewport.width, height: viewport.height, }; }); if (isCancelled) { return; } setPages(pages); setLoadingState('loaded'); } catch (err) { if (isCancelled) { return; } console.error(err); setLoadingState('error'); toast({ title: t`Error`, description: t`An error occurred while loading the document.`, variant: 'destructive', }); } }; void fetchMetadata(); return () => { isCancelled = true; if (pdfRef.current) { void pdfRef.current.destroy(); pdfRef.current = null; } }; }, [data]); // Notify when document is loaded useEffect(() => { if (loadingState === 'loaded' && onDocumentLoad) { onDocumentLoad(); } }, [loadingState, onDocumentLoad]); const isLoading = loadingState === 'loading'; const hasError = loadingState === 'error'; if (!data) { return (

No document found

); } return (
{/* Loading State */} {isLoading && } {/* Error State */} {hasError && } {/* Loaded State */} {loadingState === 'loaded' && pages.length > 0 && pdfRef.current && ( )}
); } type VirtualizedPageListProps = { scrollParentRef: ScrollTarget; constraintRef: React.RefObject; pages: PageMeta[]; numPages: number; pdf: pdfjsLib.PDFDocumentProxy; customPageRenderer?: React.FunctionComponent<{ pageData: PageRenderData }>; }; const VirtualizedPageList = ({ scrollParentRef, constraintRef, pages, numPages, pdf, customPageRenderer, }: VirtualizedPageListProps) => { const contentRef = useRef(null); const { virtualItems, totalSize, constraintWidth, scrollToItem } = useVirtualList({ scrollRef: scrollParentRef, constraintRef, contentRef, itemCount: numPages, itemSize: (index, width) => { const pageMeta = pages[index]; // Calculate height based on aspect ratio and available width const aspectRatio = pageMeta.height / pageMeta.width; const scaledHeight = width * aspectRatio; // Add 32px for the page number text and margins (my-2 = 8px * 2 + text height ~16px) // Add additional 2px for the top and bottom borders. return scaledHeight + 32 + 2; }, overscan: 5, }); useScrollToPage(contentRef, scrollToItem); return (
{virtualItems.map((virtualItem) => { const index = virtualItem.index; const pageMeta = pages[index]; const pageNumber = index + 1; // Calculate scale based on constraint width const scale = constraintWidth / pageMeta.width; const scaledWidth = Math.floor(pageMeta.width * scale); const scaledHeight = Math.floor(pageMeta.height * scale); return (

Page {pageNumber} of {numPages}

); })}
); }; type PdfViewerPageProps = { pageNumber: number; pdf: pdfjsLib.PDFDocumentProxy; unscaledWidth: number; unscaledHeight: number; scaledWidth: number; scaledHeight: number; scale: number; customPageRenderer?: React.FunctionComponent<{ pageData: PageRenderData }>; }; const PdfViewerPage = ({ pageNumber, pdf, unscaledWidth, unscaledHeight, scaledWidth, scaledHeight, scale, customPageRenderer: CustomPageRenderer, }: PdfViewerPageProps) => { const { imageProps, imageLoadingState } = usePdfPageImage({ pageNumber, pdf, unscaledWidth, unscaledHeight, scaledWidth, scaledHeight, scale, }); return (
{CustomPageRenderer && imageLoadingState === 'loaded' && ( )}
); }; /** * Manages rendering a page from a pdf. */ const usePdfPageImage = ({ pageNumber, pdf, scale, scaledWidth, scaledHeight }: PdfViewerPageProps) => { const [imageLoadingState, setImageLoadingState] = useState('loading'); const [imageUrl, setImageUrl] = useState(''); const renderTaskRef = useRef(null); const idleTimerRef = useRef | null>(null); const renderedResolutionRef = useRef(null); const renderedPageNumberRef = useRef(null); const renderedPdfRef = useRef(null); useEffect(() => { let isCancelled = false; const cancelRenderTask = () => { if (!renderTaskRef.current) { return; } renderTaskRef.current.cancel(); renderTaskRef.current = null; }; const hasMatchingRenderedImage = (resolution: number) => { return ( renderedPdfRef.current === pdf && renderedPageNumberRef.current === pageNumber && renderedResolutionRef.current === resolution ); }; const setRenderedImageMeta = (resolution: number) => { renderedPdfRef.current = pdf; renderedPageNumberRef.current = pageNumber; renderedResolutionRef.current = resolution; }; const renderAtResolution = async (resolution: number) => { let currentTask: pdfjsLib.RenderTask | null = null; try { if (isCancelled) { return; } if (hasMatchingRenderedImage(resolution)) { return; } cancelRenderTask(); const page = await pdf.getPage(pageNumber); if (isCancelled) { return; } const renderScale = scale * resolution; const viewport = page.getViewport({ scale: renderScale }); const canvas = document.createElement('canvas'); canvas.width = Math.floor(viewport.width); canvas.height = Math.floor(viewport.height); const context = canvas.getContext('2d'); if (!context) { throw new Error('Failed to get canvas context'); } currentTask = page.render({ canvasContext: context, viewport, canvas, }); renderTaskRef.current = currentTask; await currentTask.promise; if (isCancelled || renderTaskRef.current !== currentTask) { return; } setRenderedImageMeta(resolution); setImageUrl(canvas.toDataURL('image/jpeg')); } catch (err) { if (err instanceof Error && err.name === 'RenderingCancelledException') { return; } if (!isCancelled) { console.error(err); setImageLoadingState('error'); } } finally { if (renderTaskRef.current === currentTask) { renderTaskRef.current = null; } } }; void renderAtResolution(LOW_RENDER_RESOLUTION); idleTimerRef.current = setTimeout(() => { void renderAtResolution(HIGH_RENDER_RESOLUTION); }, IDLE_RENDER_DELAY); return () => { isCancelled = true; if (idleTimerRef.current) { clearTimeout(idleTimerRef.current); idleTimerRef.current = null; } cancelRenderTask(); }; }, [pdf, pageNumber, scale]); const imageProps = useMemo( (): React.ImgHTMLAttributes & Record & { alt: '' } => ({ className: PDF_VIEWER_PAGE_CLASSNAME, width: Math.floor(scaledWidth), height: Math.floor(scaledHeight), alt: '', onLoad: () => setImageLoadingState('loaded'), onError: () => setImageLoadingState('error'), src: imageUrl, 'data-page-number': pageNumber, draggable: false, }), [scaledWidth, scaledHeight, imageUrl, pageNumber], ); return { imageProps, imageLoadingState, }; };