feat: add pdf image renderer (#2554)

## Description

Replace the PDF renderer with an custom image renderer.

This allows us to remove the "react-pdf" dependency and allows us to use
a virtual list to improve performance.
This commit is contained in:
David Nguyen
2026-03-06 12:39:03 +11:00
committed by GitHub
parent 0ce909a298
commit 6faa01d384
82 changed files with 2581 additions and 1365 deletions
@@ -98,10 +98,8 @@ export const DocumentReadOnlyFields = ({
setHiddenFieldIds((prev) => ({ ...prev, [fieldId]: true }));
};
const highestPageNumber = Math.max(...fields.map((field) => field.page));
return (
<ElementVisible target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}>
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{fields.map(
(field) =>
!hiddenFieldIds[field.secondaryId] && (
@@ -163,7 +161,7 @@ export const DocumentReadOnlyFields = ({
</span>
</p>
<p className="text-muted-foreground mt-1 text-center text-xs">
<p className="mt-1 text-center text-xs text-muted-foreground">
{getRecipientDisplayText(field.recipient)}
</p>
+27 -2
View File
@@ -5,7 +5,11 @@ import { createPortal } from 'react-dom';
import { useElementBounds } from '@documenso/lib/client-only/hooks/use-element-bounds';
import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { useIsPageInDom } from '@documenso/lib/client-only/hooks/use-is-page-in-dom';
import {
PDF_VIEWER_CONTENT_SELECTOR,
PDF_VIEWER_PAGE_SELECTOR,
} from '@documenso/lib/constants/pdf-viewer';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import type { RecipientColorStyles } from '../../lib/recipient-colors';
@@ -81,6 +85,8 @@ export function FieldRootContainer({
readonly,
}: FieldRootContainerProps) {
const [isValidating, setIsValidating] = useState(false);
const isPageInDom = useIsPageInDom(field.page);
const ref = React.useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -88,6 +94,21 @@ export function FieldRootContainer({
return;
}
// Check the validation signal on the PDF viewer container. When a field
// mounts after the virtual list scrolls to its page, the per-element
// `data-validate` attribute will not have been set yet. The signal on the
// `[data-pdf-content]` container bridges this gap so newly-rendered fields
// pick up the validation state immediately.
const pdfContent = document.querySelector(PDF_VIEWER_CONTENT_SELECTOR);
if (
pdfContent?.getAttribute('data-validate-fields') === 'true' &&
isFieldUnsignedAndRequired(field)
) {
ref.current.setAttribute('data-validate', 'true');
setIsValidating(true);
}
const observer = new MutationObserver((_mutations) => {
if (ref.current) {
setIsValidating(ref.current.getAttribute('data-validate') === 'true');
@@ -101,7 +122,11 @@ export function FieldRootContainer({
return () => {
observer.disconnect();
};
}, []);
}, [isPageInDom]);
if (!isPageInDom) {
return null;
}
return (
<FieldContainerPortal field={field}>
@@ -1,33 +0,0 @@
import React, { Suspense, lazy } from 'react';
import { Trans } from '@lingui/react/macro';
import { type PDFDocumentProxy } from 'pdfjs-dist';
import type { PdfViewerRendererMode } from './pdf-viewer-konva';
export type LoadedPDFDocument = PDFDocumentProxy;
export type PDFViewerProps = {
className?: string;
onDocumentLoad?: () => void;
renderer: PdfViewerRendererMode;
[key: string]: unknown;
} & Omit<React.HTMLAttributes<HTMLDivElement>, 'onPageClick'>;
const EnvelopePdfViewer = lazy(async () => import('./pdf-viewer-konva'));
export const PDFViewerKonvaLazy = (props: PDFViewerProps) => {
return (
<Suspense
fallback={
<div>
<Trans>Loading...</Trans>
</div>
}
>
<EnvelopePdfViewer {...props} />
</Suspense>
);
};
export default PDFViewerKonvaLazy;
@@ -1,213 +0,0 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import Konva from 'konva';
import { Loader } from 'lucide-react';
import { type PDFDocumentProxy } from 'pdfjs-dist';
import { Document as PDFDocument, Page as PDFPage, pdfjs } from 'react-pdf';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
export type LoadedPDFDocument = PDFDocumentProxy;
/**
* This imports the worker from the `pdfjs-dist` package.
*/
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/legacy/build/pdf.worker.min.mjs',
import.meta.url,
).toString();
const pdfViewerOptions = {
cMapUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/static/cmaps/`,
};
const PDFLoader = () => (
<>
<Loader className="h-12 w-12 animate-spin text-documenso" />
<p className="mt-4 text-muted-foreground">
<Trans>Loading document...</Trans>
</p>
</>
);
export type PdfViewerRendererMode = 'editor' | 'preview' | 'signing';
const RendererErrorMessages: Record<
PdfViewerRendererMode,
{ title: MessageDescriptor; description: MessageDescriptor }
> = {
editor: {
title: msg`Configuration Error`,
description: msg`There was an issue rendering some fields, please review the fields and try again.`,
},
preview: {
title: msg`Configuration Error`,
description: msg`Something went wrong while rendering the document, some fields may be missing or corrupted.`,
},
signing: {
title: msg`Configuration Error`,
description: msg`Something went wrong while rendering the document, some fields may be missing or corrupted.`,
},
};
export type PdfViewerKonvaProps = {
className?: string;
onDocumentLoad?: () => void;
customPageRenderer?: React.FunctionComponent;
renderer: PdfViewerRendererMode;
[key: string]: unknown;
} & Omit<React.HTMLAttributes<HTMLDivElement>, 'onPageClick'>;
export const PdfViewerKonva = ({
className,
onDocumentLoad,
customPageRenderer,
renderer,
...props
}: PdfViewerKonvaProps) => {
const { t } = useLingui();
const $el = useRef<HTMLDivElement>(null);
const { getPdfBuffer, currentEnvelopeItem, renderError } = useCurrentEnvelopeRender();
const [width, setWidth] = useState(0);
const [numPages, setNumPages] = useState(0);
const [pdfError, setPdfError] = useState(false);
const envelopeItemFile = useMemo(() => {
const data = getPdfBuffer(currentEnvelopeItem?.id || '');
if (!data || data.status !== 'loaded') {
return null;
}
return {
data: new Uint8Array(data.file),
};
}, [currentEnvelopeItem?.id, getPdfBuffer]);
const onDocumentLoaded = useCallback(
(doc: PDFDocumentProxy) => {
setNumPages(doc.numPages);
},
[onDocumentLoad],
);
useEffect(() => {
if ($el.current) {
const $current = $el.current;
const { width } = $current.getBoundingClientRect();
setWidth(width);
const onResize = () => {
const { width } = $current.getBoundingClientRect();
setWidth(width);
};
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
};
}
}, []);
return (
<div ref={$el} className={cn('w-full max-w-[800px]', className)} {...props}>
{renderError && (
<Alert variant="destructive" className="mb-4 max-w-[800px]">
<AlertTitle>{t(RendererErrorMessages[renderer].title)}</AlertTitle>
<AlertDescription>{t(RendererErrorMessages[renderer].description)}</AlertDescription>
</Alert>
)}
{envelopeItemFile && Konva ? (
<PDFDocument
file={envelopeItemFile}
className={cn('w-full rounded', {
'h-[80vh] max-h-[60rem]': numPages === 0,
})}
onLoadSuccess={(d) => onDocumentLoaded(d)}
// Uploading a invalid document causes an error which doesn't appear to be handled by the `error` prop.
// Therefore we add some additional custom error handling.
onSourceError={() => {
setPdfError(true);
}}
externalLinkTarget="_blank"
loading={
<div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center bg-white/50 dark:bg-background">
{pdfError ? (
<div className="text-center text-muted-foreground">
<p>
<Trans>Something went wrong while loading the document.</Trans>
</p>
<p className="mt-1 text-sm">
<Trans>Please try again or contact our support.</Trans>
</p>
</div>
) : (
<PDFLoader />
)}
</div>
}
error={
<div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center bg-white/50 dark:bg-background">
<div className="text-center text-muted-foreground">
<p>
<Trans>Something went wrong while loading the document.</Trans>
</p>
<p className="mt-1 text-sm">
<Trans>Please try again or contact our support.</Trans>
</p>
</div>
</div>
}
options={pdfViewerOptions}
>
{Array(numPages)
.fill(null)
.map((_, i) => (
<div key={i} className="last:-mb-2">
<div className="rounded border border-border will-change-transform">
<PDFPage
pageNumber={i + 1}
width={width}
renderAnnotationLayer={false}
renderTextLayer={false}
loading={() => ''}
renderMode={customPageRenderer ? 'custom' : 'canvas'}
customRenderer={customPageRenderer}
/>
</div>
<p className="my-2 text-center text-[11px] text-muted-foreground/80">
<Trans>
Page {i + 1} of {numPages}
</Trans>
</p>
</div>
))}
</PDFDocument>
) : (
<div
className={cn(
'flex h-[80vh] max-h-[60rem] w-full flex-col items-center justify-center overflow-hidden rounded',
)}
>
<PDFLoader />
</div>
)}
</div>
);
};
export default PdfViewerKonva;