>
@@ -175,7 +178,7 @@ const DocumentCertificateQrV2 = ({
formattedDate,
token,
}: DocumentCertificateQrV2Props) => {
- const { currentEnvelopeItem, envelopeItems } = useCurrentEnvelopeRender();
+ const { envelopeItems } = useCurrentEnvelopeRender();
return (
@@ -210,7 +213,10 @@ const DocumentCertificateQrV2 = ({
);
diff --git a/apps/remix/app/components/general/document/document-edit-form.tsx b/apps/remix/app/components/general/document/document-edit-form.tsx
index 835e0c292..faacbcf39 100644
--- a/apps/remix/app/components/general/document/document-edit-form.tsx
+++ b/apps/remix/app/components/general/document/document-edit-form.tsx
@@ -15,6 +15,7 @@ import {
import type { TDocument } from '@documenso/lib/types/document';
import { ZDocumentAccessAuthTypesSchema } from '@documenso/lib/types/document-auth';
import { trpc } from '@documenso/trpc/react';
+import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
@@ -27,7 +28,6 @@ import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/ad
import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
-import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { Stepper } from '@documenso/ui/primitives/stepper';
import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -440,11 +440,11 @@ export const DocumentEditForm = ({
gradient
>
- setIsDocumentPdfLoaded(true)}
/>
diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-drag-drop.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-drag-drop.tsx
index bca1db219..2de39668b 100644
--- a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-drag-drop.tsx
+++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-drag-drop.tsx
@@ -175,15 +175,6 @@ export const EnvelopeEditorFieldDragDrop = ({
const { top, left, height, width } = getBoundingClientRect($page);
- console.log({
- top,
- left,
- height,
- width,
- rawPageX: event.pageX,
- rawPageY: event.pageY,
- });
-
const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10);
// Calculate x and y as a percentage of the page width and height
@@ -278,13 +269,13 @@ export const EnvelopeEditorFieldDragDrop = ({
onMouseDown={() => setSelectedField(field.type)}
data-selected={selectedField === field.type ? true : undefined}
className={cn(
- 'border-border group flex h-12 cursor-pointer items-center justify-center rounded-lg border px-4 transition-colors',
+ 'group flex h-12 cursor-pointer items-center justify-center rounded-lg border border-border px-4 transition-colors',
RECIPIENT_COLOR_STYLES[selectedRecipientColor].fieldButton,
)}
>
createPageCanvas(stage, pageLayer));
+ renderStatus,
+ imageProps,
+ } = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer), pageData);
- const { _className, scale } = pageContext;
+ const { scale, pageNumber } = pageData;
const localPageFields = useMemo(
() =>
editorFields.localFields.filter(
- (field) =>
- field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
+ (field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
),
- [editorFields.localFields, pageContext.pageNumber],
+ [editorFields.localFields, pageNumber],
);
const handleResizeOrMove = (event: KonvaEventObject) => {
- const { current: container } = canvasElement;
-
- if (!container) {
- return;
- }
-
const isDragEvent = event.type === 'dragend';
const fieldGroup = event.target as Konva.Group;
@@ -344,7 +345,6 @@ export default function EnvelopeEditorFieldsPageRenderer() {
// Create a field if no items are selected or the size is too small.
if (
selectedFieldGroups.length === 0 &&
- canvasElement.current &&
unscaledBoxWidth > MIN_FIELD_WIDTH_PX &&
unscaledBoxHeight > MIN_FIELD_HEIGHT_PX &&
editorFields.selectedRecipient &&
@@ -515,7 +515,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
removePendingField();
- if (!canvasElement.current || !currentEnvelopeItem || !editorFields.selectedRecipient) {
+ if (!currentEnvelopeItem || !editorFields.selectedRecipient) {
return;
}
@@ -530,7 +530,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
editorFields.addField({
envelopeItemId: currentEnvelopeItem.id,
- page: pageContext.pageNumber,
+ page: pageNumber,
type,
positionX: fieldX,
positionY: fieldY,
@@ -559,10 +559,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
}
return (
-
+
{selectedKonvaFieldGroups.length > 0 &&
interactiveTransformer.current &&
!isFieldChanging && (
@@ -625,13 +622,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
{/* The element Konva will inject it's canvas into. */}
- {/* Canvas the PDF will be rendered on. */}
-
+
);
}
diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
index 548ec7420..70172a1dd 100644
--- a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
+++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
@@ -1,4 +1,4 @@
-import { lazy, useEffect, useMemo, useState } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
@@ -12,6 +12,7 @@ import { match } from 'ts-pattern';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
+import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
import type { NormalizedFieldWithContext } from '@documenso/lib/server-only/ai/envelope/detect-fields/types';
import {
FIELD_META_DEFAULT_VALUES,
@@ -29,7 +30,7 @@ import {
} from '@documenso/lib/types/field-meta';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
-import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
+import { EnvelopePdfViewer } from '@documenso/ui/components/pdf-viewer/envelope-pdf-viewer';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator';
@@ -49,13 +50,10 @@ import { EditorFieldTextForm } from '~/components/forms/editor/editor-field-text
import { useCurrentTeam } from '~/providers/team';
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
+import EnvelopeEditorFieldsPageRenderer from './envelope-editor-fields-page-renderer';
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
import { EnvelopeRecipientSelector } from './envelope-recipient-selector';
-const EnvelopeEditorFieldsPageRenderer = lazy(
- async () => import('~/components/general/envelope-editor/envelope-editor-fields-page-renderer'),
-);
-
const FieldSettingsTypeTranslations: Record
= {
[FieldType.SIGNATURE]: msg`Signature Settings`,
[FieldType.FREE_SIGNATURE]: msg`Free Signature Settings`,
@@ -75,6 +73,8 @@ export const EnvelopeEditorFieldsPage = () => {
const team = useCurrentTeam();
+ const scrollableContainerRef = useRef(null);
+
const { envelope, editorFields, relativePath } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
@@ -156,12 +156,12 @@ export const EnvelopeEditorFieldsPage = () => {
return (
-
+
{/* Horizontal envelope item selector */}
{/* Document View */}
-
+
{envelope.recipients.length === 0 && (
{
)}
{currentEnvelopeItem !== null ? (
-
) : (
diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx
index 3fc331d72..89ee0c785 100644
--- a/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx
+++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx
@@ -11,12 +11,13 @@ import {
EnvelopeRenderProvider,
useCurrentEnvelopeRender,
} from '@documenso/lib/client-only/providers/envelope-render-provider';
+import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing';
import { toCheckboxCustomText } from '@documenso/lib/utils/fields';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
-import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
+import { EnvelopePdfViewer } from '@documenso/ui/components/pdf-viewer/envelope-pdf-viewer';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
import { Separator } from '@documenso/ui/primitives/separator';
@@ -200,6 +201,7 @@ export const EnvelopeEditorPreviewPage = () => {
// Override the parent renderer provider so we can inject custom fields.
return (
{
{/* Document View */}
-
+
Preview Mode
@@ -228,9 +230,9 @@ export const EnvelopeEditorPreviewPage = () => {
{currentEnvelopeItem !== null ? (
-
) : (
diff --git a/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx b/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx
index 370d35240..1aecef120 100644
--- a/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx
+++ b/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx
@@ -5,17 +5,22 @@ import { DocumentStatus, type Recipient, SigningStatus } from '@prisma/client';
import type Konva from 'konva';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
-import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
+import {
+ type PageRenderData,
+ useCurrentEnvelopeRender,
+} from '@documenso/lib/client-only/providers/envelope-render-provider';
import type { TEnvelope } from '@documenso/lib/types/envelope';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
import { EnvelopeRecipientFieldTooltip } from '@documenso/ui/components/document/envelope-recipient-field-tooltip';
+import { EnvelopePageImage } from '../envelope/envelope-page-image';
+
type GenericLocalField = TEnvelope['fields'][number] & {
recipient: Pick
;
};
-export default function EnvelopeGenericPageRenderer() {
+export default function EnvelopeGenericPageRenderer({ pageData }: { pageData: PageRenderData }) {
const { i18n } = useLingui();
const {
@@ -28,19 +33,12 @@ export default function EnvelopeGenericPageRenderer() {
overrideSettings,
} = useCurrentEnvelopeRender();
- const {
- stage,
- pageLayer,
- canvasElement,
- konvaContainer,
- pageContext,
- scaledViewport,
- unscaledViewport,
- } = usePageRenderer(({ stage, pageLayer }) => {
- createPageCanvas(stage, pageLayer);
- });
+ const { stage, pageLayer, imageProps, konvaContainer, unscaledViewport, renderStatus } =
+ usePageRenderer(({ stage, pageLayer }) => {
+ createPageCanvas(stage, pageLayer);
+ }, pageData);
- const { _className, scale } = pageContext;
+ const { scale, pageNumber } = pageData;
const localPageFields = useMemo((): GenericLocalField[] => {
if (envelopeStatus === DocumentStatus.COMPLETED) {
@@ -49,8 +47,7 @@ export default function EnvelopeGenericPageRenderer() {
return fields
.filter(
- (field) =>
- field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
+ (field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
)
.map((field) => {
const recipient = recipients.find((recipient) => recipient.id === field.recipientId);
@@ -73,7 +70,7 @@ export default function EnvelopeGenericPageRenderer() {
(recipient.signingStatus === SigningStatus.SIGNED ? inserted : true) ||
fieldMeta?.readOnly,
);
- }, [fields, pageContext.pageNumber, currentEnvelopeItem?.id, recipients]);
+ }, [fields, pageNumber, currentEnvelopeItem?.id, recipients]);
const unsafeRenderFieldOnLayer = (field: GenericLocalField) => {
if (!pageLayer.current) {
@@ -160,10 +157,7 @@ export default function EnvelopeGenericPageRenderer() {
}
return (
-
+
{overrideSettings?.showRecipientTooltip &&
localPageFields.map((field) => (
- {/* Canvas the PDF will be rendered on. */}
-
+
);
}
diff --git a/apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx b/apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx
index b47e22cea..5dfc4b462 100644
--- a/apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx
+++ b/apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx
@@ -14,7 +14,10 @@ import type { KonvaEventObject } from 'konva/lib/Node';
import { match } from 'ts-pattern';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
-import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
+import {
+ type PageRenderData,
+ useCurrentEnvelopeRender,
+} from '@documenso/lib/client-only/providers/envelope-render-provider';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates';
import { isBase64Image } from '@documenso/lib/constants/signatures';
@@ -44,12 +47,13 @@ import { handleTextFieldClick } from '~/utils/field-signing/text-field';
import { useRequiredDocumentSigningAuthContext } from '../document-signing/document-signing-auth-provider';
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
+import { EnvelopePageImage } from '../envelope/envelope-page-image';
type GenericLocalField = TEnvelope['fields'][number] & {
recipient: Pick;
};
-export default function EnvelopeSignerPageRenderer() {
+export default function EnvelopeSignerPageRenderer({ pageData }: { pageData: PageRenderData }) {
const { t, i18n } = useLingui();
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
const { sessionData } = useOptionalSession();
@@ -77,17 +81,10 @@ export default function EnvelopeSignerPageRenderer() {
const { onFieldSigned, onFieldUnsigned } = useEmbedSigningContext() || {};
- const {
- stage,
- pageLayer,
- canvasElement,
- konvaContainer,
- pageContext,
- scaledViewport,
- unscaledViewport,
- } = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer));
+ const { stage, pageLayer, imageProps, konvaContainer, unscaledViewport, renderStatus } =
+ usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer), pageData);
- const { _className, scale } = pageContext;
+ const { scale, pageNumber } = pageData;
const { envelope } = envelopeData;
@@ -99,10 +96,9 @@ export default function EnvelopeSignerPageRenderer() {
}
return fieldsToRender.filter(
- (field) =>
- field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
+ (field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
);
- }, [recipientFields, selectedAssistantRecipientFields, pageContext.pageNumber]);
+ }, [recipientFields, selectedAssistantRecipientFields, pageNumber]);
/**
* Returns fields that have been fully signed by other recipients for this specific
@@ -117,7 +113,7 @@ export default function EnvelopeSignerPageRenderer() {
return recipient.fields
.filter(
(field) =>
- field.page === pageContext.pageNumber &&
+ field.page === pageNumber &&
field.envelopeItemId === currentEnvelopeItem?.id &&
(field.inserted || field.fieldMeta?.readOnly),
)
@@ -132,7 +128,7 @@ export default function EnvelopeSignerPageRenderer() {
},
}));
});
- }, [envelope.recipients, pageContext.pageNumber]);
+ }, [envelope.recipients, pageNumber]);
const unsafeRenderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
if (!pageLayer.current) {
@@ -534,14 +530,11 @@ export default function EnvelopeSignerPageRenderer() {
}
return (
-
+
{showPendingFieldTooltip &&
recipientFieldsRemaining.length > 0 &&
recipientFieldsRemaining[0]?.envelopeItemId === currentEnvelopeItem?.id &&
- recipientFieldsRemaining[0]?.page === pageContext.pageNumber && (
+ recipientFieldsRemaining[0]?.page === pageNumber && (
- {/* Canvas the PDF will be rendered on. */}
-
+
);
}
diff --git a/apps/remix/app/components/general/envelope/envelope-page-image.tsx b/apps/remix/app/components/general/envelope/envelope-page-image.tsx
new file mode 100644
index 000000000..707d3a552
--- /dev/null
+++ b/apps/remix/app/components/general/envelope/envelope-page-image.tsx
@@ -0,0 +1,32 @@
+import { Trans } from '@lingui/react/macro';
+
+import { Spinner } from '@documenso/ui/primitives/spinner';
+
+type EnvelopePageImageProps = {
+ renderStatus: 'loading' | 'loaded' | 'error';
+ imageProps: React.ImgHTMLAttributes & Record & { alt: '' };
+};
+
+export const EnvelopePageImage = ({ renderStatus, imageProps }: EnvelopePageImageProps) => {
+ return (
+ <>
+ {/* Loading State */}
+ {renderStatus === 'loading' && (
+
+
+
+ )}
+
+ {renderStatus === 'error' && (
+
+
+ Error loading page
+
+
+ )}
+
+ {/* The PDF image. */}
+
+ >
+ );
+};
diff --git a/apps/remix/app/components/general/template/template-edit-form.tsx b/apps/remix/app/components/general/template/template-edit-form.tsx
index 254694117..18d9551b2 100644
--- a/apps/remix/app/components/general/template/template-edit-form.tsx
+++ b/apps/remix/app/components/general/template/template-edit-form.tsx
@@ -14,11 +14,11 @@ import {
import { ZDocumentAccessAuthTypesSchema } from '@documenso/lib/types/document-auth';
import type { TTemplate } from '@documenso/lib/types/template';
import { trpc } from '@documenso/trpc/react';
+import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
-import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { Stepper } from '@documenso/ui/primitives/stepper';
import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-fields';
import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types';
@@ -312,11 +312,11 @@ export const TemplateEditForm = ({
gradient
>
- setIsDocumentPdfLoaded(true)}
/>
diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx
index fc9646811..75956777d 100644
--- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx
+++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx
@@ -9,6 +9,7 @@ import { match } from 'ts-pattern';
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { useSession } from '@documenso/lib/client-only/providers/session';
+import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
@@ -16,12 +17,12 @@ import {
DocumentReadOnlyFields,
mapFieldsWithRecipients,
} from '@documenso/ui/components/document/document-read-only-fields';
-import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
+import { EnvelopePdfViewer } from '@documenso/ui/components/pdf-viewer/envelope-pdf-viewer';
+import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
import { cn } from '@documenso/ui/lib/utils';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
-import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { Spinner } from '@documenso/ui/primitives/spinner';
import { DocumentPageViewButton } from '~/components/general/document/document-page-view-button';
@@ -154,6 +155,7 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
{envelope.internalVersion === 2 ? (
-
@@ -193,11 +195,11 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
/>
)}
-
diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.edit.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.edit.tsx
index c85cfb796..bb675a7c9 100644
--- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.edit.tsx
+++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.edit.tsx
@@ -58,7 +58,7 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
if (envelope && (envelope.teamId !== team.id || envelope.internalVersion !== 2)) {
return (
-
+
Redirecting
@@ -67,7 +67,7 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
if (isLoadingEnvelope) {
return (
-
+
Loading
@@ -99,6 +99,7 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
return (
-
@@ -210,10 +212,10 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
documentMeta={mockedDocumentMeta}
/>
-
diff --git a/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx b/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx
index 1484e70e7..d73d8357e 100644
--- a/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx
+++ b/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx
@@ -198,7 +198,7 @@ const DirectSigningPageV1 = ({ data }: { data: Awaited
-
+
@@ -246,7 +246,7 @@ const DirectSigningPageV2 = ({ data }: { data: Awaited
-
+
diff --git a/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx b/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
index ede85cafd..aaba7092f 100644
--- a/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
+++ b/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
@@ -494,7 +494,7 @@ const SigningPageV2 = ({ data }: { data: Awaited
-
+
diff --git a/apps/remix/app/routes/embed+/_v0+/direct.$token.tsx b/apps/remix/app/routes/embed+/_v0+/direct.$token.tsx
index e5791264e..85592464d 100644
--- a/apps/remix/app/routes/embed+/_v0+/direct.$token.tsx
+++ b/apps/remix/app/routes/embed+/_v0+/direct.$token.tsx
@@ -320,7 +320,7 @@ const EmbedDirectTemplatePageV2 = ({
user={user}
isDirectTemplate={true}
>
-
+
-
+
()
/**
@@ -319,3 +323,11 @@ export const filesRoute = new Hono()
});
},
);
+
+// Envelope item meta routes for both tokens and auth based
+filesRoute.route('/', getEnvelopeItemMetaRoute);
+filesRoute.route('/', getEnvelopeItemMetaByTokenRoute);
+
+// Image routes for both tokens and auth based
+filesRoute.route('/', getEnvelopeItemImageRoute);
+filesRoute.route('/', getEnvelopeItemImageByTokenRoute);
diff --git a/apps/remix/server/api/files/files.types.ts b/apps/remix/server/api/files/files.types.ts
index 0e7c2f8cf..ec126f1c4 100644
--- a/apps/remix/server/api/files/files.types.ts
+++ b/apps/remix/server/api/files/files.types.ts
@@ -72,3 +72,23 @@ export const ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema = z.object({
export type TGetEnvelopeItemFileTokenDownloadRequestParams = z.infer<
typeof ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema
>;
+
+export const ZGetEnvelopeItemMetaSchema = z.object({
+ envelopeItemId: z.string(),
+ documentDataId: z.string(),
+ pages: z
+ .object({
+ originalWidth: z.number(),
+ originalHeight: z.number(),
+ scale: z.number(),
+ scaledWidth: z.number(),
+ scaledHeight: z.number(),
+ })
+ .array(),
+});
+
+export const ZGetEnvelopeItemsMetaResponseSchema = z.object({
+ envelopeItems: z.array(ZGetEnvelopeItemMetaSchema),
+});
+
+export type TGetEnvelopeItemsMetaResponse = z.infer;
diff --git a/apps/remix/server/api/files/routes/get-envelope-item-image-by-token.ts b/apps/remix/server/api/files/routes/get-envelope-item-image-by-token.ts
new file mode 100644
index 000000000..e70f5bb42
--- /dev/null
+++ b/apps/remix/server/api/files/routes/get-envelope-item-image-by-token.ts
@@ -0,0 +1,67 @@
+import { sValidator } from '@hono/standard-validator';
+import { Hono } from 'hono';
+import { z } from 'zod';
+
+import { prisma } from '@documenso/prisma';
+
+import type { HonoEnv } from '../../../router';
+import { handleEnvelopeItemPageRequest } from './get-envelope-item-image';
+
+const route = new Hono();
+
+const ZGetEnvelopeItemPageTokenParamsSchema = z.object({
+ token: z.string().min(1),
+ envelopeId: z.string().min(1),
+ envelopeItemId: z.string().min(1),
+ documentDataId: z.string().min(1),
+ version: z.enum(['initial', 'current']),
+ pageIndex: z.coerce.number().int().min(0),
+});
+
+/**
+ * Returns a single PDF page as a JPEG image using a token.
+ */
+route.get(
+ '/token/:token/envelope/:envelopeId/envelopeItem/:envelopeItemId/dataId/:documentDataId/:version/:pageIndex/image.jpeg',
+ sValidator('param', ZGetEnvelopeItemPageTokenParamsSchema),
+ async (c) => {
+ const { token, envelopeId, envelopeItemId, documentDataId, version, pageIndex } =
+ c.req.valid('param');
+
+ // Validate envelope access.
+ const envelopeItem = await prisma.envelopeItem.findFirst({
+ where: {
+ id: envelopeItemId,
+ documentDataId,
+ envelope: {
+ id: envelopeId,
+ recipients: {
+ some: {
+ token,
+ },
+ },
+ },
+ },
+ include: {
+ documentData: true,
+ },
+ });
+
+ if (!envelopeItem) {
+ return c.json({ error: 'Not found' }, 404);
+ }
+
+ // We can hard cache this since since it's a unique URL for a given recipient.
+ // Might be dicey if the handler returns a cacheable error code.
+ c.header('Cache-Control', 'public, max-age=31536000, immutable');
+
+ return await handleEnvelopeItemPageRequest({
+ c,
+ envelopeItem,
+ version,
+ pageIndex,
+ });
+ },
+);
+
+export default route;
diff --git a/apps/remix/server/api/files/routes/get-envelope-item-image.ts b/apps/remix/server/api/files/routes/get-envelope-item-image.ts
new file mode 100644
index 000000000..2888337c0
--- /dev/null
+++ b/apps/remix/server/api/files/routes/get-envelope-item-image.ts
@@ -0,0 +1,175 @@
+import { sValidator } from '@hono/standard-validator';
+import type { DocumentData, EnvelopeItem } from '@prisma/client';
+import { type Context, Hono } from 'hono';
+import { z } from 'zod';
+
+import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
+import { PDF_IMAGE_RENDER_SCALE } from '@documenso/lib/constants/pdf-viewer';
+import { pdfToImage } from '@documenso/lib/server-only/ai/pdf-to-images';
+import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
+import { getTeamById } from '@documenso/lib/server-only/team/get-team';
+import type { DocumentDataVersion } from '@documenso/lib/types/document-data';
+import { sha256 } from '@documenso/lib/universal/crypto';
+import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
+import { UNSAFE_getS3File } from '@documenso/lib/universal/upload/server-actions';
+import { getEnvelopeItemPageImageS3Key } from '@documenso/lib/utils/envelope-images';
+import { prisma } from '@documenso/prisma';
+
+import type { HonoEnv } from '../../../router';
+
+const route = new Hono();
+
+const ZGetEnvelopeItemPageRequestParamsSchema = z.object({
+ envelopeId: z.string().min(1),
+ envelopeItemId: z.string().min(1),
+ documentDataId: z.string().min(1),
+ version: z.enum(['initial', 'current']),
+ pageIndex: z.coerce.number().int().min(0),
+});
+
+const ZGetEnvelopeItemPageRequestQuerySchema = z.object({
+ presignToken: z.string().optional(),
+});
+
+/**
+ * Returns a single PDF page as a JPEG image.
+ */
+route.get(
+ '/envelope/:envelopeId/envelopeItem/:envelopeItemId/dataId/:documentDataId/:version/:pageIndex/image.jpeg',
+ sValidator('param', ZGetEnvelopeItemPageRequestParamsSchema),
+ sValidator('query', ZGetEnvelopeItemPageRequestQuerySchema),
+ async (c) => {
+ const {
+ envelopeId,
+ envelopeItemId,
+ documentDataId: _documentDataId,
+ version,
+ pageIndex,
+ } = c.req.valid('param');
+
+ const { presignToken } = c.req.valid('query');
+
+ const session = await getOptionalSession(c);
+
+ let userId = session.user?.id;
+
+ // Check presignToken if provided
+ if (presignToken) {
+ const verifiedToken = await verifyEmbeddingPresignToken({
+ token: presignToken,
+ }).catch(() => undefined);
+
+ userId = verifiedToken?.userId;
+ }
+
+ if (!userId) {
+ return c.json({ error: 'Not found' }, 404);
+ }
+
+ const envelope = await prisma.envelope.findFirst({
+ where: { id: envelopeId },
+ include: {
+ envelopeItems: {
+ where: { id: envelopeItemId },
+ include: { documentData: true },
+ },
+ },
+ });
+
+ if (!envelope) {
+ return c.json({ error: 'Not found' }, 404);
+ }
+
+ const [envelopeItem] = envelope.envelopeItems;
+
+ if (!envelopeItem?.documentData) {
+ return c.json({ error: 'Not found' }, 404);
+ }
+
+ // Check team access
+ const team = await getTeamById({
+ userId,
+ teamId: envelope.teamId,
+ }).catch(() => null);
+
+ if (!team) {
+ return c.json({ error: 'Not found' }, 404);
+ }
+
+ return await handleEnvelopeItemPageRequest({
+ c,
+ envelopeItem,
+ version,
+ pageIndex,
+ });
+ },
+);
+
+type HandleEnvelopeItemPageRequestOptions = {
+ c: Context;
+ envelopeItem: EnvelopeItem & {
+ documentData: DocumentData;
+ };
+ pageIndex: number;
+ version: DocumentDataVersion;
+};
+
+export const handleEnvelopeItemPageRequest = async ({
+ c,
+ envelopeItem,
+ pageIndex,
+ version,
+}: HandleEnvelopeItemPageRequestOptions) => {
+ // Determine which PDF data to use based on version requested.
+ const documentDataToUse =
+ version === 'current' ? envelopeItem.documentData.data : envelopeItem.documentData.initialData;
+
+ // Generate ETag from document data hash + page index.
+ // Note: This can also be an S3 string.
+ const etag = Buffer.from(sha256(`${documentDataToUse}:${pageIndex}`)).toString('hex');
+
+ c.header('ETag', etag);
+
+ if (c.req.header('If-None-Match') === etag) {
+ return c.body(null, 304);
+ }
+
+ c.header('Content-Type', 'image/jpeg');
+
+ // Return the image if it already exists in S3.
+ if (envelopeItem.documentData.type === 'S3_PATH') {
+ const s3Key = getEnvelopeItemPageImageS3Key(documentDataToUse, pageIndex);
+
+ const image = await UNSAFE_getS3File(s3Key);
+
+ if (image) {
+ return c.body(image);
+ }
+ }
+
+ // Fetch PDF to render the page on the spot if it doesn't exist in S3.
+ const pdfBytes = await getFileServerSide({
+ type: envelopeItem.documentData.type,
+ data: documentDataToUse,
+ });
+
+ // Render page to image.
+ const { image } = await pdfToImage(pdfBytes, {
+ scale: PDF_IMAGE_RENDER_SCALE,
+ pageIndex,
+ }).catch((err) => {
+ console.error(err);
+
+ return {
+ image: null,
+ };
+ });
+
+ if (!image) {
+ return c.json({ error: 'Failed to render page to image' }, 500);
+ }
+
+ return c.body(image);
+};
+
+export default route;
diff --git a/apps/remix/server/api/files/routes/get-envelope-item-meta-by-token.ts b/apps/remix/server/api/files/routes/get-envelope-item-meta-by-token.ts
new file mode 100644
index 000000000..a0bbd4ee3
--- /dev/null
+++ b/apps/remix/server/api/files/routes/get-envelope-item-meta-by-token.ts
@@ -0,0 +1,54 @@
+import { sValidator } from '@hono/standard-validator';
+import { Hono } from 'hono';
+import { z } from 'zod';
+
+import { prisma } from '@documenso/prisma';
+
+import type { HonoEnv } from '../../../router';
+import { handleEnvelopeItemsMetaRequest } from './get-envelope-item-meta';
+
+const route = new Hono();
+
+const ZGetEnvelopeMetaByTokenParamSchema = z.object({
+ token: z.string().min(1),
+ envelopeId: z.string().min(1),
+});
+
+/**
+ * Returns metadata for all envelope items including page counts and dimensions using a token.
+ */
+route.get(
+ '/token/:token/envelope/:envelopeId/meta',
+ sValidator('param', ZGetEnvelopeMetaByTokenParamSchema),
+ async (c) => {
+ const { token, envelopeId } = c.req.valid('param');
+
+ // Validate token belongs to envelope
+ const recipient = await prisma.recipient.findFirst({
+ where: {
+ token,
+ envelopeId,
+ },
+ select: {
+ envelope: {
+ include: {
+ envelopeItems: {
+ include: { documentData: true },
+ },
+ },
+ },
+ },
+ });
+
+ if (!recipient) {
+ return c.json({ error: 'Not found' }, 404);
+ }
+
+ return await handleEnvelopeItemsMetaRequest({
+ c,
+ envelopeItems: recipient.envelope.envelopeItems,
+ });
+ },
+);
+
+export default route;
diff --git a/apps/remix/server/api/files/routes/get-envelope-item-meta.ts b/apps/remix/server/api/files/routes/get-envelope-item-meta.ts
new file mode 100644
index 000000000..e70f564df
--- /dev/null
+++ b/apps/remix/server/api/files/routes/get-envelope-item-meta.ts
@@ -0,0 +1,138 @@
+import { sValidator } from '@hono/standard-validator';
+import type { DocumentData, EnvelopeItem } from '@prisma/client';
+import { type Context, Hono } from 'hono';
+import { z } from 'zod';
+
+import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
+import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
+import { getTeamById } from '@documenso/lib/server-only/team/get-team';
+import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
+import { extractAndStorePdfImages } from '@documenso/lib/universal/upload/put-file.server';
+import { prisma } from '@documenso/prisma';
+
+import type { HonoEnv } from '../../../router';
+import type { TGetEnvelopeItemsMetaResponse } from '../files.types';
+
+const route = new Hono();
+
+const ZGetEnvelopeMetaParamsSchema = z.object({
+ envelopeId: z.string().min(1),
+});
+
+const ZGetEnvelopeMetaQuerySchema = z.object({
+ presignToken: z.string().optional(),
+});
+
+/**
+ * Returns metadata for all envelope items including page counts and dimensions.
+ */
+route.get(
+ '/envelope/:envelopeId/meta',
+ sValidator('param', ZGetEnvelopeMetaParamsSchema),
+ sValidator('query', ZGetEnvelopeMetaQuerySchema),
+ async (c) => {
+ const { envelopeId } = c.req.valid('param');
+ const { presignToken } = c.req.valid('query');
+
+ const session = await getOptionalSession(c);
+
+ let userId = session.user?.id;
+
+ // Check presignToken if provided
+ if (presignToken) {
+ const verifiedToken = await verifyEmbeddingPresignToken({
+ token: presignToken,
+ }).catch(() => undefined);
+
+ userId = verifiedToken?.userId;
+ }
+
+ if (!userId) {
+ return c.json({ error: 'Not found' }, 404);
+ }
+
+ // Note: Access is verified in the getTeamById call after this.
+ const envelope = await prisma.envelope.findFirst({
+ where: {
+ id: envelopeId,
+ },
+ include: {
+ envelopeItems: {
+ include: { documentData: true },
+ },
+ },
+ });
+
+ if (!envelope) {
+ return c.json({ error: 'Not found' }, 404);
+ }
+
+ // Check access to envelope.
+ const team = await getTeamById({
+ userId,
+ teamId: envelope.teamId,
+ }).catch(() => null);
+
+ if (!team) {
+ return c.json({ error: 'Not found' }, 404);
+ }
+
+ return await handleEnvelopeItemsMetaRequest({
+ c,
+ envelopeItems: envelope.envelopeItems,
+ });
+ },
+);
+
+type HandleEnvelopeItemsMetaRequestOptions = {
+ c: Context;
+ envelopeItems: (EnvelopeItem & {
+ documentData: DocumentData;
+ })[];
+};
+
+export const handleEnvelopeItemsMetaRequest = async ({
+ c,
+ envelopeItems,
+}: HandleEnvelopeItemsMetaRequestOptions) => {
+ const response = await Promise.all(
+ envelopeItems.map(async (item) => {
+ let pageMetadata = item.documentData.metadata;
+
+ // Runtime backfill if pageMetadata is missing.
+ if (!pageMetadata) {
+ const pdfBytes = await getFileServerSide({
+ type: item.documentData.type,
+ data: item.documentData.data,
+ });
+
+ const pdfPageMetadata = await extractAndStorePdfImages(
+ new Uint8Array(pdfBytes).buffer,
+ item.documentData.id,
+ );
+
+ pageMetadata = {
+ pages: pdfPageMetadata,
+ };
+ }
+
+ const pages = pageMetadata.pages ?? [];
+
+ return {
+ envelopeItemId: item.id,
+ documentDataId: item.documentData.id,
+ pages: pages.map((page) => ({
+ originalWidth: page.originalWidth,
+ originalHeight: page.originalHeight,
+ scale: page.scale,
+ scaledWidth: page.scaledWidth,
+ scaledHeight: page.scaledHeight,
+ })),
+ };
+ }),
+ );
+
+ return c.json({ envelopeItems: response } satisfies TGetEnvelopeItemsMetaResponse);
+};
+
+export default route;
diff --git a/package-lock.json b/package-lock.json
index 5ad9d9bd6..516ee4b01 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -27380,24 +27380,6 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
- "node_modules/make-cancellable-promise": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/make-cancellable-promise/-/make-cancellable-promise-2.0.0.tgz",
- "integrity": "sha512-3SEQqTpV9oqVsIWqAcmDuaNeo7yBO3tqPtqGRcKkEo0lrzD3wqbKG9mkxO65KoOgXqj+zH2phJ2LiAsdzlogSw==",
- "license": "MIT",
- "funding": {
- "url": "https://github.com/wojtekmaj/make-cancellable-promise?sponsor=1"
- }
- },
- "node_modules/make-event-props": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/make-event-props/-/make-event-props-2.0.0.tgz",
- "integrity": "sha512-G/hncXrl4Qt7mauJEXSg3AcdYzmpkIITTNl5I+rH9sog5Yw0kK6vseJjCaPfOXqOqQuPUP89Rkhfz5kPS8ijtw==",
- "license": "MIT",
- "funding": {
- "url": "https://github.com/wojtekmaj/make-event-props?sponsor=1"
- }
- },
"node_modules/map-stream": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz",
@@ -27844,23 +27826,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/merge-refs": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/merge-refs/-/merge-refs-2.0.0.tgz",
- "integrity": "sha512-3+B21mYK2IqUWnd2EivABLT7ueDhb0b8/dGK8LoFQPrU61YITeCMn14F7y7qZafWNZhUEKb24cJdiT5Wxs3prg==",
- "license": "MIT",
- "funding": {
- "url": "https://github.com/wojtekmaj/merge-refs?sponsor=1"
- },
- "peerDependencies": {
- "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@@ -31949,44 +31914,6 @@
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
- "node_modules/react-pdf": {
- "version": "10.3.0",
- "resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-10.3.0.tgz",
- "integrity": "sha512-2LQzC9IgNVAX8gM+6F+1t/70a9/5RWThYxc+CWAmT2LW/BRmnj+35x1os5j/nR2oldyf8L+hCAMBmVKU8wrYFA==",
- "license": "MIT",
- "dependencies": {
- "clsx": "^2.0.0",
- "dequal": "^2.0.3",
- "make-cancellable-promise": "^2.0.0",
- "make-event-props": "^2.0.0",
- "merge-refs": "^2.0.0",
- "pdfjs-dist": "5.4.296",
- "tiny-invariant": "^1.0.0",
- "warning": "^4.0.0"
- },
- "funding": {
- "url": "https://github.com/wojtekmaj/react-pdf?sponsor=1"
- },
- "peerDependencies": {
- "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
- "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
- "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/react-pdf/node_modules/clsx": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
- "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/react-promise-suspense": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/react-promise-suspense/-/react-promise-suspense-0.3.4.tgz",
@@ -36561,15 +36488,6 @@
"node": ">=20.0.0"
}
},
- "node_modules/warning": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
- "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
- "license": "MIT",
- "dependencies": {
- "loose-envify": "^1.0.0"
- }
- },
"node_modules/wcwidth": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
@@ -37513,7 +37431,6 @@
"posthog-js": "^1.297.2",
"posthog-node": "4.18.0",
"react": "^18",
- "react-pdf": "^10.3.0",
"remeda": "^2.32.0",
"sharp": "0.34.5",
"skia-canvas": "^3.0.8",
@@ -37671,7 +37588,6 @@
"react-day-picker": "^8.10.1",
"react-dom": "^18",
"react-hook-form": "^7.66.1",
- "react-pdf": "^10.3.0",
"react-rnd": "^10.5.2",
"remeda": "^2.32.0",
"tailwind-merge": "^1.14.0",
diff --git a/packages/app-tests/e2e/document-flow/autosave-fields-step.spec.ts b/packages/app-tests/e2e/document-flow/autosave-fields-step.spec.ts
index db33897b3..96aa46142 100644
--- a/packages/app-tests/e2e/document-flow/autosave-fields-step.spec.ts
+++ b/packages/app-tests/e2e/document-flow/autosave-fields-step.spec.ts
@@ -1,6 +1,7 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
+import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { prisma } from '@documenso/prisma';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
@@ -46,7 +47,7 @@ test.describe('AutoSave Fields Step', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
- await page.locator('canvas').click({
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 100,
@@ -54,7 +55,7 @@ test.describe('AutoSave Fields Step', () => {
});
await page.getByRole('button', { name: 'Text' }).click();
- await page.locator('canvas').click({
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 200,
@@ -74,7 +75,7 @@ test.describe('AutoSave Fields Step', () => {
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
- await page.locator('canvas').click({
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 500,
@@ -100,7 +101,7 @@ test.describe('AutoSave Fields Step', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
- await page.locator('canvas').click({
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 100,
@@ -108,7 +109,7 @@ test.describe('AutoSave Fields Step', () => {
});
await page.getByRole('button', { name: 'Text' }).click();
- await page.locator('canvas').click({
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 200,
@@ -128,7 +129,7 @@ test.describe('AutoSave Fields Step', () => {
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
- await page.locator('canvas').click({
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 500,
@@ -162,7 +163,7 @@ test.describe('AutoSave Fields Step', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
- await page.locator('canvas').click({
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 100,
@@ -170,7 +171,7 @@ test.describe('AutoSave Fields Step', () => {
});
await page.getByRole('button', { name: 'Text' }).click();
- await page.locator('canvas').click({
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 200,
@@ -190,7 +191,7 @@ test.describe('AutoSave Fields Step', () => {
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
- await page.locator('canvas').click({
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 500,
@@ -224,7 +225,7 @@ test.describe('AutoSave Fields Step', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
- await page.locator('canvas').click({
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 100,
@@ -232,7 +233,7 @@ test.describe('AutoSave Fields Step', () => {
});
await page.getByRole('button', { name: 'Text' }).click();
- await page.locator('canvas').click({
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 200,
diff --git a/packages/app-tests/e2e/document-flow/autosave-subject-step.spec.ts b/packages/app-tests/e2e/document-flow/autosave-subject-step.spec.ts
index 64e9a41bf..560111b3f 100644
--- a/packages/app-tests/e2e/document-flow/autosave-subject-step.spec.ts
+++ b/packages/app-tests/e2e/document-flow/autosave-subject-step.spec.ts
@@ -2,6 +2,7 @@ import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { EnvelopeType } from '@prisma/client';
+import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
@@ -28,7 +29,7 @@ export const setupDocumentAndNavigateToSubjectStep = async (page: Page) => {
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
- await page.locator('canvas').click({
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 100,
diff --git a/packages/app-tests/e2e/document-flow/duplicate-recipients-simple.spec.ts b/packages/app-tests/e2e/document-flow/duplicate-recipients-simple.spec.ts
index ea0bfd43e..135a7aee8 100644
--- a/packages/app-tests/e2e/document-flow/duplicate-recipients-simple.spec.ts
+++ b/packages/app-tests/e2e/document-flow/duplicate-recipients-simple.spec.ts
@@ -1,5 +1,6 @@
import { expect, test } from '@playwright/test';
+import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
@@ -33,14 +34,14 @@ test('[DOCUMENT_FLOW]: Simple duplicate recipients test', async ({ page }) => {
// Step 3: Add fields
await page.getByRole('button', { name: 'Signature' }).click();
- await page.locator('canvas').click({ position: { x: 100, y: 100 } });
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
await page.getByRole('combobox').first().click();
// Switch to second duplicate and add field
await page.getByText('Duplicate 2 (duplicate@example.com)').first().click();
await page.getByRole('button', { name: 'Signature' }).click();
- await page.locator('canvas').click({ position: { x: 200, y: 100 } });
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
// Continue to send
await page.getByRole('button', { name: 'Continue' }).click();
diff --git a/packages/app-tests/e2e/document-flow/duplicate-recipients.spec.ts b/packages/app-tests/e2e/document-flow/duplicate-recipients.spec.ts
index 6eb2bb370..bb0e3b2d8 100644
--- a/packages/app-tests/e2e/document-flow/duplicate-recipients.spec.ts
+++ b/packages/app-tests/e2e/document-flow/duplicate-recipients.spec.ts
@@ -44,21 +44,21 @@ const completeDocumentFlowWithDuplicateRecipients = async (options: {
// Step 3: Add fields for each recipient
// Add signature field for first duplicate recipient
await page.getByRole('button', { name: 'Signature' }).click();
- await page.locator('canvas').click({ position: { x: 100, y: 100 } });
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
await page.getByText('Duplicate Recipient 1 (duplicate@example.com)').click();
// Switch to second duplicate recipient and add their field
await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click();
await page.getByRole('button', { name: 'Signature' }).click();
- await page.locator('canvas').click({ position: { x: 200, y: 100 } });
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click();
// Switch to unique recipient and add their field
await page.getByText('Unique Recipient (unique@example.com)').click();
await page.getByRole('button', { name: 'Signature' }).click();
- await page.locator('canvas').click({ position: { x: 300, y: 100 } });
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 300, y: 100 } });
// Continue to subject
await page.getByRole('button', { name: 'Continue' }).click();
@@ -122,7 +122,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
- await page.locator('canvas').click({ position: { x: 100, y: 100 } });
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
// Save the document by going to subject
await page.getByRole('button', { name: 'Continue' }).click();
@@ -149,7 +149,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
await page.getByText('Test Recipient Duplicate (test@example.com)').first().click();
await page.getByRole('button', { name: 'Signature' }).click();
- await page.locator('canvas').click({ position: { x: 200, y: 100 } });
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
// Complete the flow
await page.getByRole('button', { name: 'Continue' }).click();
@@ -270,24 +270,24 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
// Add signature for first recipient
await page.getByRole('button', { name: 'Signature' }).click();
- await page.locator('canvas').click({ position: { x: 100, y: 100 } });
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
// Add name field for second recipient
await page.getByRole('combobox').first().click();
await page.getByText('Approver Role (signer@example.com)').first().click();
await page.getByRole('button', { name: 'Name' }).click();
- await page.locator('canvas').click({ position: { x: 200, y: 100 } });
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
// Add date field for second recipient
await page.getByRole('button', { name: 'Date' }).click();
- await page.locator('canvas').click({ position: { x: 200, y: 150 } });
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 150 } });
// If second recipient is still a SIGNER (role change wasn't available),
// add a signature field for them to pass validation
if (!secondRecipientIsApprover) {
await page.getByRole('button', { name: 'Signature' }).click();
- await page.locator('canvas').click({ position: { x: 200, y: 200 } });
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 200 } });
}
// Complete the document
@@ -349,7 +349,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
// Add another field to the second duplicate
await page.getByRole('button', { name: 'Name' }).click();
- await page.locator('canvas').click({ position: { x: 250, y: 150 } });
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 250, y: 150 } });
// Save changes
await page.getByRole('button', { name: 'Continue' }).click();
diff --git a/packages/app-tests/e2e/document-flow/stepper-component.spec.ts b/packages/app-tests/e2e/document-flow/stepper-component.spec.ts
index 4220731ad..3f43718dc 100644
--- a/packages/app-tests/e2e/document-flow/stepper-component.spec.ts
+++ b/packages/app-tests/e2e/document-flow/stepper-component.spec.ts
@@ -9,6 +9,7 @@ import {
import { DateTime } from 'luxon';
import path from 'node:path';
+import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { prisma } from '@documenso/prisma';
import {
seedBlankDocument,
@@ -92,7 +93,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) =>
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
- await page.locator('canvas').click({
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 100,
@@ -100,7 +101,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) =>
});
await page.getByRole('button', { name: 'Email' }).click();
- await page.locator('canvas').click({
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 200,
@@ -158,7 +159,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
- await page.locator('canvas').click({
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 100,
@@ -166,7 +167,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
});
await page.getByRole('button', { name: 'Email' }).click();
- await page.locator('canvas').click({
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 200,
@@ -177,7 +178,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
await page.getByText('User 2 (user2@example.com)').click();
await page.getByRole('button', { name: 'Signature' }).click();
- await page.locator('canvas').click({
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 500,
y: 100,
@@ -185,7 +186,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
});
await page.getByRole('button', { name: 'Email' }).click();
- await page.locator('canvas').click({
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 500,
y: 200,
@@ -256,7 +257,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
await page.getByRole('option', { name: 'User 1 (user1@example.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
- await page.locator('canvas').click({
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 100,
@@ -264,7 +265,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
});
await page.getByRole('button', { name: 'Email' }).click();
- await page.locator('canvas').click({
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 200,
@@ -275,7 +276,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
await page.getByRole('option', { name: 'User 3 (user3@example.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
- await page.locator('canvas').click({
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 500,
y: 100,
@@ -283,7 +284,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
});
await page.getByRole('button', { name: 'Email' }).click();
- await page.locator('canvas').click({
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 500,
y: 200,
@@ -576,7 +577,7 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
}
await page.getByRole('button', { name: 'Signature' }).click();
- await page.locator('canvas').click({
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 100 * i,
diff --git a/packages/app-tests/e2e/envelopes/envelope-alignment.spec.ts b/packages/app-tests/e2e/envelopes/envelope-alignment.spec.ts
index c48306106..50d5b12c0 100644
--- a/packages/app-tests/e2e/envelopes/envelope-alignment.spec.ts
+++ b/packages/app-tests/e2e/envelopes/envelope-alignment.spec.ts
@@ -1,13 +1,12 @@
-import { createCanvas } from '@napi-rs/canvas';
import type { TestInfo } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import fs from 'node:fs';
import path from 'node:path';
-import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
import pixelMatch from 'pixelmatch';
import { PNG } from 'pngjs';
+import { pdfToImages } from '@documenso/lib/server-only/ai/pdf-to-images';
import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download';
import { prisma } from '@documenso/prisma';
import { seedAlignmentTestDocument } from '@documenso/prisma/seed/initial-seed';
@@ -378,39 +377,14 @@ test.skip('download envelope images', async ({ page }) => {
});
async function renderPdfToImage(pdfBytes: Uint8Array) {
- const loadingTask = pdfjsLib.getDocument({ data: pdfBytes });
- const pdf = await loadingTask.promise;
-
// Increase for higher resolution
const scale = 4;
- return await Promise.all(
- Array.from({ length: pdf.numPages }, async (_, index) => {
- const page = await pdf.getPage(index + 1);
-
- const viewport = page.getViewport({ scale });
-
- const canvas = createCanvas(viewport.width, viewport.height);
- const canvasContext = canvas.getContext('2d');
- canvasContext.imageSmoothingEnabled = false;
-
- await page.render({
- // @ts-expect-error @napi-rs/canvas satisfies runtime requirements for pdfjs
- canvas,
- // @ts-expect-error @napi-rs/canvas satisfies runtime requirements for pdfjs
- canvasContext,
- viewport,
- }).promise;
-
- return {
- image: await canvas.encode('png'),
-
- // Rounded down because the certificate page somehow gives dimensions with decimals
- width: Math.floor(viewport.width),
- height: Math.floor(viewport.height),
- };
- }),
- );
+ return (await pdfToImages(pdfBytes, { scale, imageFormat: 'png' })).map((image) => ({
+ image: image.image,
+ width: Math.floor(image.scaledWidth),
+ height: Math.floor(image.scaledHeight),
+ }));
}
type CompareSignedPdfWithImagesOptions = {
diff --git a/packages/app-tests/e2e/templates-flow/duplicate-recipients.spec.ts b/packages/app-tests/e2e/templates-flow/duplicate-recipients.spec.ts
index 8982aef52..280478246 100644
--- a/packages/app-tests/e2e/templates-flow/duplicate-recipients.spec.ts
+++ b/packages/app-tests/e2e/templates-flow/duplicate-recipients.spec.ts
@@ -42,21 +42,21 @@ const completeTemplateFlowWithDuplicateRecipients = async (options: {
// Step 3: Add fields for each recipient instance
// Add signature field for first instance
await page.getByRole('button', { name: 'Signature' }).click();
- await page.locator('canvas').click({ position: { x: 100, y: 100 } });
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
// Switch to second instance and add their field
await page.getByRole('combobox').first().click();
await page.getByText('Second Instance').first().click();
await page.getByRole('button', { name: 'Signature' }).click();
- await page.locator('canvas').click({ position: { x: 200, y: 100 } });
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
// Switch to different recipient and add their fields
await page.getByRole('combobox').first().click();
await page.getByText('Different Recipient').first().click();
await page.getByRole('button', { name: 'Signature' }).click();
- await page.locator('canvas').click({ position: { x: 300, y: 100 } });
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 300, y: 100 } });
await page.getByRole('button', { name: 'Name' }).click();
- await page.locator('canvas').click({ position: { x: 300, y: 150 } });
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 300, y: 150 } });
// Save template
await page.getByRole('button', { name: 'Save Template' }).click();
@@ -209,17 +209,17 @@ test.describe('[TEMPLATE_FLOW]: Duplicate Recipients', () => {
// Add fields for each recipient
await page.getByRole('button', { name: 'Signature' }).click();
- await page.locator('canvas').click({ position: { x: 100, y: 100 } });
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
await page.getByRole('combobox').first().click();
await page.getByText('Duplicate Recipient 2').first().click();
await page.getByRole('button', { name: 'Date' }).click();
- await page.locator('canvas').click({ position: { x: 200, y: 100 } });
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
await page.getByRole('combobox').first().click();
await page.getByText('Different Recipient').first().click();
await page.getByRole('button', { name: 'Name' }).click();
- await page.locator('canvas').click({ position: { x: 100, y: 200 } });
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 200 } });
// Save template
await page.getByRole('button', { name: 'Save Template' }).click();
@@ -272,7 +272,7 @@ test.describe('[TEMPLATE_FLOW]: Duplicate Recipients', () => {
await page.getByRole('combobox').first().click();
await page.getByRole('option', { name: 'First Instance' }).first().click();
await page.getByRole('button', { name: 'Name' }).click();
- await page.locator('canvas').click({ position: { x: 100, y: 300 } });
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 300 } });
await page.waitForTimeout(2500);
diff --git a/packages/app-tests/e2e/templates-flow/template-autosave-fields-step.spec.ts b/packages/app-tests/e2e/templates-flow/template-autosave-fields-step.spec.ts
index c7d1b5583..8a8b88ad5 100644
--- a/packages/app-tests/e2e/templates-flow/template-autosave-fields-step.spec.ts
+++ b/packages/app-tests/e2e/templates-flow/template-autosave-fields-step.spec.ts
@@ -1,6 +1,7 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
+import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
@@ -47,7 +48,7 @@ test.describe('AutoSave Fields Step', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
- await page.locator('canvas').click({
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 100,
@@ -55,7 +56,7 @@ test.describe('AutoSave Fields Step', () => {
});
await page.getByRole('button', { name: 'Text' }).click();
- await page.locator('canvas').click({
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 200,
@@ -75,7 +76,7 @@ test.describe('AutoSave Fields Step', () => {
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
- await page.locator('canvas').click({
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 500,
@@ -110,7 +111,7 @@ test.describe('AutoSave Fields Step', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
- await page.locator('canvas').click({
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 100,
@@ -118,7 +119,7 @@ test.describe('AutoSave Fields Step', () => {
});
await page.getByRole('button', { name: 'Text' }).click();
- await page.locator('canvas').click({
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 200,
@@ -138,7 +139,7 @@ test.describe('AutoSave Fields Step', () => {
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
- await page.locator('canvas').click({
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 500,
@@ -179,7 +180,7 @@ test.describe('AutoSave Fields Step', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
- await page.locator('canvas').click({
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 100,
@@ -187,7 +188,7 @@ test.describe('AutoSave Fields Step', () => {
});
await page.getByRole('button', { name: 'Text' }).click();
- await page.locator('canvas').click({
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 200,
@@ -207,7 +208,7 @@ test.describe('AutoSave Fields Step', () => {
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
- await page.locator('canvas').click({
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 500,
@@ -250,7 +251,7 @@ test.describe('AutoSave Fields Step', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
- await page.locator('canvas').click({
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 100,
@@ -258,7 +259,7 @@ test.describe('AutoSave Fields Step', () => {
});
await page.getByRole('button', { name: 'Text' }).click();
- await page.locator('canvas').click({
+ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 200,
diff --git a/packages/lib/client-only/get-bounding-client-rect.ts b/packages/lib/client-only/get-bounding-client-rect.ts
index 5d8d3e02f..495a49a9c 100644
--- a/packages/lib/client-only/get-bounding-client-rect.ts
+++ b/packages/lib/client-only/get-bounding-client-rect.ts
@@ -1,4 +1,4 @@
-export const getBoundingClientRect = (element: HTMLElement) => {
+export const getBoundingClientRect = (element: HTMLElement | Element) => {
const rect = element.getBoundingClientRect();
const { width, height } = rect;
diff --git a/packages/lib/client-only/hooks/use-document-element.ts b/packages/lib/client-only/hooks/use-document-element.ts
index 6366a7eee..3ee870da3 100644
--- a/packages/lib/client-only/hooks/use-document-element.ts
+++ b/packages/lib/client-only/hooks/use-document-element.ts
@@ -14,7 +14,10 @@ export const useDocumentElement = () => {
const target = event.target;
const $page =
- target.closest(pageSelector) ?? target.querySelector(pageSelector);
+ target.closest(pageSelector) ??
+ document
+ .elementsFromPoint(event.clientX, event.clientY)
+ .find((el) => el.matches(pageSelector));
if (!$page) {
return null;
diff --git a/packages/lib/client-only/hooks/use-page-renderer.ts b/packages/lib/client-only/hooks/use-page-renderer.ts
index de4054f86..232905bb2 100644
--- a/packages/lib/client-only/hooks/use-page-renderer.ts
+++ b/packages/lib/client-only/hooks/use-page-renderer.ts
@@ -1,46 +1,45 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import Konva from 'konva';
-import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
-import { usePageContext } from 'react-pdf';
+
+import { PDF_VIEWER_PAGE_CLASSNAME } from '@documenso/lib/constants/pdf-viewer';
+
+import { EAGER_LOAD_PAGE_COUNT, type PageRenderData } from '../providers/envelope-render-provider';
type RenderFunction = (props: { stage: Konva.Stage; pageLayer: Konva.Layer }) => void;
-export function usePageRenderer(renderFunction: RenderFunction) {
- const pageContext = usePageContext();
+export function usePageRenderer(renderFunction: RenderFunction, pageData: PageRenderData) {
+ const { pageWidth, pageHeight, scale, imageUrl, pageNumber } = pageData;
- if (!pageContext) {
- throw new Error('Unable to find Page context.');
- }
-
- const { page, rotate, scale } = pageContext;
-
- if (!page) {
- throw new Error('Attempted to render page canvas, but no page was specified.');
- }
-
- const canvasElement = useRef(null);
const konvaContainer = useRef(null);
const stage = useRef(null);
const pageLayer = useRef(null);
- const [renderError, setRenderError] = useState(false);
+ const [renderStatus, setRenderStatus] = useState<'loading' | 'loaded' | 'error'>('loading');
/**
* The raw viewport with no scaling. Basically the actual PDF size.
*/
const unscaledViewport = useMemo(
- () => page.getViewport({ scale: 1, rotation: rotate }),
- [page, rotate, scale],
+ () => ({
+ scale: 1,
+ width: pageWidth,
+ height: pageHeight,
+ }),
+ [pageWidth, pageHeight],
);
/**
* The viewport scaled according to page width.
*/
const scaledViewport = useMemo(
- () => page.getViewport({ scale, rotation: rotate }),
- [page, rotate, scale],
+ () => ({
+ scale,
+ width: pageWidth * scale,
+ height: pageHeight * scale,
+ }),
+ [pageWidth, pageHeight, scale],
);
/**
@@ -48,88 +47,77 @@ export function usePageRenderer(renderFunction: RenderFunction) {
* in a higher resolution.
*/
const renderViewport = useMemo(
- () => page.getViewport({ scale: scale * window.devicePixelRatio, rotation: rotate }),
- [page, rotate, scale],
+ () => ({
+ scale: scale * window.devicePixelRatio,
+ width: pageWidth * scale * window.devicePixelRatio,
+ height: pageHeight * scale * window.devicePixelRatio,
+ }),
+ [pageWidth, pageHeight, scale],
);
/**
- * Render the PDF and create the scaled Konva stage.
+ * The props for the image element which will render the page.
*/
- useEffect(
- function drawPageOnCanvas() {
- if (!page) {
- return;
- }
-
- const { current: canvas } = canvasElement;
- const { current: kContainer } = konvaContainer;
-
- if (!canvas || !kContainer) {
- return;
- }
-
- canvas.width = renderViewport.width;
- canvas.height = renderViewport.height;
-
- canvas.style.width = `${Math.floor(scaledViewport.width)}px`;
- canvas.style.height = `${Math.floor(scaledViewport.height)}px`;
-
- const renderContext: RenderParameters = {
- canvas,
- // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
- canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
- viewport: renderViewport,
- };
-
- const cancellable = page.render(renderContext);
- const runningTask = cancellable;
-
- cancellable.promise.catch(() => {
- // Intentionally empty
- });
-
- void cancellable.promise.then(() => {
- stage.current = new Konva.Stage({
- container: kContainer,
- width: scaledViewport.width,
- height: scaledViewport.height,
- scale: {
- x: scale,
- y: scale,
- },
- });
-
- // Create the main layer for interactive elements.
- pageLayer.current = new Konva.Layer();
-
- stage.current.add(pageLayer.current);
-
- renderFunction({
- stage: stage.current,
- pageLayer: pageLayer.current,
- });
-
- void document.fonts.ready.then(function () {
- pageLayer.current?.batchDraw();
- });
- });
-
- return () => {
- runningTask.cancel();
- };
- },
- [page, scaledViewport],
+ const imageProps = useMemo(
+ (): React.ImgHTMLAttributes & Record & { alt: '' } => ({
+ className: PDF_VIEWER_PAGE_CLASSNAME,
+ width: `${Math.floor(scaledViewport.width)}px`,
+ height: `${Math.floor(scaledViewport.height)}px`,
+ alt: '',
+ onLoad: () => setRenderStatus('loaded'),
+ // Purposely not using lazy here since we can use the virtual list overscan to let us prerender images.
+ loading: pageNumber < EAGER_LOAD_PAGE_COUNT ? 'eager' : undefined,
+ src: imageUrl,
+ 'data-page-number': pageNumber,
+ }),
+ [renderViewport, scaledViewport],
);
+ useEffect(() => {
+ const { current: container } = konvaContainer;
+
+ if (renderStatus !== 'loaded' || !container) {
+ return;
+ }
+
+ stage.current = new Konva.Stage({
+ container,
+ width: scaledViewport.width,
+ height: scaledViewport.height,
+ scale: {
+ x: scale,
+ y: scale,
+ },
+ });
+
+ // Create the main layer for interactive elements.
+ pageLayer.current = new Konva.Layer();
+
+ stage.current.add(pageLayer.current);
+
+ renderFunction({
+ stage: stage.current,
+ pageLayer: pageLayer.current,
+ });
+
+ void document.fonts.ready.then(function () {
+ pageLayer.current?.batchDraw();
+ });
+
+ return () => {
+ stage.current?.destroy();
+ stage.current = null;
+ };
+ }, [renderStatus, imageProps]);
+
return {
- canvasElement,
konvaContainer,
+ imageProps,
stage,
pageLayer,
unscaledViewport,
scaledViewport,
- pageContext,
- renderError,
- setRenderError,
+ renderStatus,
+ setRenderStatus,
};
}
diff --git a/packages/lib/client-only/providers/envelope-render-provider.tsx b/packages/lib/client-only/providers/envelope-render-provider.tsx
index d9fb64cb6..5cd16756e 100644
--- a/packages/lib/client-only/providers/envelope-render-provider.tsx
+++ b/packages/lib/client-only/providers/envelope-render-provider.tsx
@@ -1,23 +1,46 @@
-import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
import React from 'react';
import type { Field, Recipient } from '@prisma/client';
+import type { TGetEnvelopeItemsMetaResponse } from '@documenso/remix/server/api/files/files.types';
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
import { AVAILABLE_RECIPIENT_COLORS } from '@documenso/ui/lib/recipient-colors';
+import type { DocumentDataVersion } from '../../types/document-data';
import type { TEnvelope } from '../../types/envelope';
import type { FieldRenderMode } from '../../universal/field-renderer/render-field';
-import { getEnvelopeItemPdfUrl } from '../../utils/envelope-download';
+import { getEnvelopeItemMetaUrl, getEnvelopeItemPageImageUrl } from '../../utils/envelope-images';
-type FileData =
- | {
- status: 'loading' | 'error';
- }
- | {
- file: Uint8Array;
- status: 'loaded';
- };
+/**
+ * Number of pages to load eagerly on initial render.
+ * Pages beyond this threshold will be loaded lazily when they enter the viewport.
+ */
+export const EAGER_LOAD_PAGE_COUNT = 5;
+
+export type PageRenderData = BasePageRenderData & {
+ scale: number;
+};
+
+export type BasePageRenderData = {
+ envelopeItemId: string;
+ documentDataId: string;
+ pageIndex: number;
+ pageNumber: number;
+ pageWidth: number;
+ pageHeight: number;
+ imageUrl: string;
+};
+
+export type ImageLoadingState = 'loading' | 'loaded' | 'error';
type EnvelopeRenderOverrideSettings = {
mode?: FieldRenderMode;
@@ -28,8 +51,10 @@ type EnvelopeRenderOverrideSettings = {
type EnvelopeRenderItem = TEnvelope['envelopeItems'][number];
type EnvelopeRenderProviderValue = {
- getPdfBuffer: (envelopeItemId: string) => FileData | null;
+ version: DocumentDataVersion;
envelopeItems: EnvelopeRenderItem[];
+ envelopeItemsMeta: Record;
+ envelopeItemsMetaLoadingState: ImageLoadingState;
envelopeStatus: TEnvelope['status'];
envelopeType: TEnvelope['type'];
currentEnvelopeItem: EnvelopeRenderItem | null;
@@ -46,7 +71,12 @@ type EnvelopeRenderProviderValue = {
interface EnvelopeRenderProviderProps {
children: React.ReactNode;
- envelope: Pick;
+ /**
+ * The envelope item version to render.
+ */
+ version: DocumentDataVersion;
+
+ envelope: Pick;
/**
* Optional fields which are passed down to renderers for custom rendering needs.
@@ -89,7 +119,7 @@ export const useCurrentEnvelopeRender = () => {
};
/**
- * Manages fetching and storing PDF files to render on the client.
+ * Manages fetching the data required to render an envelope and it's items.
*/
export const EnvelopeRenderProvider = ({
children,
@@ -97,71 +127,107 @@ export const EnvelopeRenderProvider = ({
fields,
token,
recipients = [],
+ version,
overrideSettings,
}: EnvelopeRenderProviderProps) => {
- // Indexed by documentDataId.
- const [files, setFiles] = useState>({});
+ // Indexed by envelope item ID.
+ const [envelopeItemsMeta, setEnvelopeItemsMeta] = useState>(
+ {},
+ );
- const [currentItem, setCurrentItem] = useState(null);
+ const [envelopeItemsMetaLoadingState, setEnvelopeItemsMetaLoadingState] =
+ useState('loading');
const [renderError, setRenderError] = useState(false);
+ // Track the timestamp of the most recent fetch to prevent race conditions
+ const fetchStartedAtRef = useRef(0);
+
const envelopeItems = useMemo(
() => envelope.envelopeItems.sort((a, b) => a.order - b.order),
[envelope.envelopeItems],
);
- const loadEnvelopeItemPdfFile = async (envelopeItem: EnvelopeRenderItem) => {
- if (files[envelopeItem.id]?.status === 'loading') {
+ const [currentItem, setCurrentItem] = useState(
+ envelope.envelopeItems[0] ?? null,
+ );
+
+ /**
+ * Fetch metadata and preload initial images when the envelope or token changes.
+ */
+ useEffect(() => {
+ void fetchEnvelopeRenderData();
+ }, [envelope.id, envelope.envelopeItems.length, token]);
+
+ const fetchEnvelopeRenderData = async () => {
+ if (!envelope.id || envelope.envelopeItems.length === 0) {
return;
}
- if (!files[envelopeItem.id]) {
- setFiles((prev) => ({
- ...prev,
- [envelopeItem.id]: {
- status: 'loading',
- },
- }));
- }
+ // Record when this fetch started to detect stale responses
+ const fetchStartedAt = Date.now();
+ fetchStartedAtRef.current = fetchStartedAt;
+
+ setEnvelopeItemsMetaLoadingState('loading');
try {
- const downloadUrl = getEnvelopeItemPdfUrl({
- type: 'view',
- envelopeItem: envelopeItem,
+ // Fetch metadata for all envelope items
+ const metaUrl = getEnvelopeItemMetaUrl({
+ envelopeId: envelope.id,
token,
});
- const blob = await fetch(downloadUrl).then(async (res) => await res.blob());
+ const response = await fetch(metaUrl);
- const file = await blob.arrayBuffer();
+ if (!response.ok) {
+ throw new Error(`Failed to fetch envelope meta: ${response.status}`);
+ }
- setFiles((prev) => ({
- ...prev,
- [envelopeItem.id]: {
- file: new Uint8Array(file),
- status: 'loaded',
- },
- }));
+ const data: TGetEnvelopeItemsMetaResponse = await response.json();
+
+ // Check again after parsing JSON in case a newer fetch started
+ if (fetchStartedAtRef.current !== fetchStartedAt) {
+ return;
+ }
+
+ // Build a map of envelope items by ID
+ const metaMap: Record = {};
+
+ for (const item of data.envelopeItems) {
+ metaMap[item.envelopeItemId] = item.pages.map((page, pageIndex) => {
+ const imageUrl = getEnvelopeItemPageImageUrl({
+ envelopeId: envelope.id,
+ envelopeItemId: item.envelopeItemId,
+ documentDataId: item.documentDataId,
+ pageIndex,
+ token,
+ version,
+ });
+
+ return {
+ envelopeItemId: item.envelopeItemId,
+ documentDataId: item.documentDataId,
+ pageIndex,
+ pageNumber: pageIndex + 1,
+ pageWidth: page.originalWidth,
+ pageHeight: page.originalHeight,
+ imageUrl,
+ };
+ });
+ }
+
+ setEnvelopeItemsMeta(metaMap);
+
+ setEnvelopeItemsMetaLoadingState('loaded');
} catch (error) {
- console.error(error);
-
- setFiles((prev) => ({
- ...prev,
- [envelopeItem.id]: {
- status: 'error',
- },
- }));
+ // Only set error state if this is still the most recent fetch
+ if (fetchStartedAtRef.current === fetchStartedAt) {
+ console.error('Failed to load envelope data:', error);
+ setEnvelopeItemsMetaLoadingState('error');
+ }
}
};
- const getPdfBuffer = useCallback(
- (envelopeItemId: string) => {
- return files[envelopeItemId] || null;
- },
- [files],
- );
-
const setCurrentEnvelopeItem = (envelopeItemId: string) => {
const foundItem = envelope.envelopeItems.find((item) => item.id === envelopeItemId);
@@ -179,15 +245,6 @@ export const EnvelopeRenderProvider = ({
}
}, [currentItem, envelopeItems]);
- // Look for any missing pdf files and load them.
- useEffect(() => {
- const missingFiles = envelope.envelopeItems.filter((item) => !files[item.id]);
-
- for (const item of missingFiles) {
- void loadEnvelopeItemPdfFile(item);
- }
- }, [envelope.envelopeItems]);
-
const recipientIds = useMemo(
() => recipients.map((recipient) => recipient.id).sort(),
[recipients],
@@ -207,7 +264,9 @@ export const EnvelopeRenderProvider = ({
return (
;
diff --git a/packages/lib/constants/pdf-viewer.ts b/packages/lib/constants/pdf-viewer.ts
index 54c9c5d5a..0e3688efa 100644
--- a/packages/lib/constants/pdf-viewer.ts
+++ b/packages/lib/constants/pdf-viewer.ts
@@ -1,2 +1,8 @@
-export const PDF_VIEWER_CONTAINER_SELECTOR = '.react-pdf__Document';
+// Keep these two constants in sync.
export const PDF_VIEWER_PAGE_SELECTOR = '.react-pdf__Page';
+export const PDF_VIEWER_PAGE_CLASSNAME = 'react-pdf__Page z-0';
+
+/**
+ * Changing this will require large testing.
+ */
+export const PDF_IMAGE_RENDER_SCALE = 2;
diff --git a/packages/lib/jobs/definitions/internal/seal-document.handler.ts b/packages/lib/jobs/definitions/internal/seal-document.handler.ts
index b46996e51..2331126ea 100644
--- a/packages/lib/jobs/definitions/internal/seal-document.handler.ts
+++ b/packages/lib/jobs/definitions/internal/seal-document.handler.ts
@@ -242,18 +242,13 @@ export const run = async ({
await prisma.$transaction(async (tx) => {
for (const { oldDocumentDataId, newDocumentDataId } of newDocumentData) {
- const newData = await tx.documentData.findFirstOrThrow({
+ await tx.envelopeItem.update({
where: {
- id: newDocumentDataId,
- },
- });
-
- await tx.documentData.update({
- where: {
- id: oldDocumentDataId,
+ envelopeId: envelope.id,
+ documentDataId: oldDocumentDataId,
},
data: {
- data: newData.data,
+ documentDataId: newDocumentDataId,
},
});
}
diff --git a/packages/lib/package.json b/packages/lib/package.json
index f324f258e..a37b30d20 100644
--- a/packages/lib/package.json
+++ b/packages/lib/package.json
@@ -55,7 +55,6 @@
"posthog-js": "^1.297.2",
"posthog-node": "4.18.0",
"react": "^18",
- "react-pdf": "^10.3.0",
"remeda": "^2.32.0",
"sharp": "0.34.5",
"skia-canvas": "^3.0.8",
diff --git a/packages/lib/server-only/ai/envelope/detect-fields/index.ts b/packages/lib/server-only/ai/envelope/detect-fields/index.ts
index f4b65300f..119b66537 100644
--- a/packages/lib/server-only/ai/envelope/detect-fields/index.ts
+++ b/packages/lib/server-only/ai/envelope/detect-fields/index.ts
@@ -162,8 +162,8 @@ export const detectFieldsFromPdf = async ({
// Mask existing fields on the image
const maskedImage = await maskFieldsOnImage({
image: page.image,
- width: page.width,
- height: page.height,
+ width: page.scaledWidth,
+ height: page.scaledHeight,
fields: fieldsOnPage,
});
diff --git a/packages/lib/server-only/ai/pdf-to-images.ts b/packages/lib/server-only/ai/pdf-to-images.ts
index e14128278..575597b37 100644
--- a/packages/lib/server-only/ai/pdf-to-images.ts
+++ b/packages/lib/server-only/ai/pdf-to-images.ts
@@ -1,7 +1,11 @@
import pMap from 'p-map';
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
+import type { PDFDocumentProxy } from 'pdfjs-dist/types/src/display/api';
+import type { ExportFormat } from 'skia-canvas';
import { Canvas, Image, Path2D } from 'skia-canvas';
+import { PDF_IMAGE_RENDER_SCALE } from '../../constants/pdf-viewer';
+
// @ts-expect-error napi-rs/canvas satisfies the requirements
globalThis.Path2D = Path2D;
// @ts-expect-error napi-rs/canvas satisfies the requirements
@@ -42,10 +46,17 @@ class SkiaCanvasFactory {
export type PdfToImagesOptions = {
scale?: number;
+
+ /**
+ * The format of the images to return.
+ *
+ * Defaults to 'jpeg'.
+ */
+ imageFormat?: ExportFormat;
};
export const pdfToImages = async (pdfBytes: Uint8Array, options: PdfToImagesOptions = {}) => {
- const { scale = 2 } = options;
+ const { scale = PDF_IMAGE_RENDER_SCALE, imageFormat = 'jpeg' } = options;
const task = await pdfjsLib.getDocument({
data: pdfBytes,
@@ -56,37 +67,7 @@ export const pdfToImages = async (pdfBytes: Uint8Array, options: PdfToImagesOpti
const images = await pMap(
Array.from({ length: pdf.numPages }),
- async (_, index) => {
- const pageNumber = index + 1;
- const page = await pdf.getPage(pageNumber);
-
- const viewport = page.getViewport({ scale });
-
- const canvas = new Canvas(viewport.width, viewport.height);
- canvas.gpu = false;
-
- const canvasContext = canvas.getContext('2d');
-
- await page.render({
- // @ts-expect-error napi-rs/canvas satifies the requirements
- canvas,
- // @ts-expect-error napi-rs/canvas satifies the requirements
- canvasContext,
- viewport,
- }).promise;
-
- const result = {
- pageNumber,
- image: await canvas.toBuffer('jpeg'),
- width: Math.floor(viewport.width),
- height: Math.floor(viewport.height),
- mimeType: 'image/jpeg',
- };
-
- void page.cleanup();
-
- return result;
- },
+ async (_, pageIndex) => getPdfPageAsImage({ pdf, pageIndex, scale, imageFormat }),
{ concurrency: 10 },
);
@@ -95,3 +76,85 @@ export const pdfToImages = async (pdfBytes: Uint8Array, options: PdfToImagesOpti
return images;
};
+
+export type PdfToImageOptions = {
+ scale?: number;
+ pageIndex: number;
+
+ /**
+ * The format of the image to return.
+ * Defaults to 'jpeg'.
+ */
+ imageFormat?: ExportFormat;
+};
+
+export const pdfToImage = async (pdfBytes: Uint8Array, options: PdfToImageOptions) => {
+ const { scale = PDF_IMAGE_RENDER_SCALE, pageIndex, imageFormat = 'jpeg' } = options;
+
+ if (pageIndex !== undefined && pageIndex < 0) {
+ throw new Error('Page index must be greater than 0');
+ }
+
+ const task = await pdfjsLib.getDocument({
+ data: pdfBytes,
+ CanvasFactory: SkiaCanvasFactory,
+ });
+
+ const pdf = await task.promise;
+
+ const image = await getPdfPageAsImage({ pdf, pageIndex, scale, imageFormat });
+
+ void pdf.destroy().catch((e) => console.error(e));
+ void task.destroy().catch((e) => console.error(e));
+
+ return image;
+};
+
+type GetPdfPageAsImageOptions = {
+ pdf: PDFDocumentProxy;
+ pageIndex: number;
+ scale: number;
+ imageFormat: ExportFormat;
+};
+
+const getPdfPageAsImage = async ({
+ pdf,
+ pageIndex,
+ scale,
+ imageFormat,
+}: GetPdfPageAsImageOptions) => {
+ const page = await pdf.getPage(pageIndex + 1);
+
+ const viewport = page.getViewport({ scale });
+
+ const canvas = new Canvas(viewport.width, viewport.height);
+ canvas.gpu = false;
+
+ const canvasContext = canvas.getContext('2d');
+
+ await page.render({
+ // @ts-expect-error napi-rs/canvas satifies the requirements
+ canvas,
+ // @ts-expect-error napi-rs/canvas satifies the requirements
+ canvasContext,
+ viewport,
+ }).promise;
+
+ const originalViewport = page.getViewport({ scale: 1 });
+
+ const result = {
+ pageIndex,
+ pageNumber: pageIndex + 1,
+ image: await canvas.toBuffer(imageFormat),
+ originalWidth: originalViewport.width,
+ originalHeight: originalViewport.height,
+ scale,
+ scaledWidth: Math.floor(viewport.width),
+ scaledHeight: Math.floor(viewport.height),
+ mimeType: 'image/jpeg',
+ };
+
+ void page.cleanup();
+
+ return result;
+};
diff --git a/packages/lib/server-only/document/send-document.ts b/packages/lib/server-only/document/send-document.ts
index 4c6ae4493..cf65aa47e 100644
--- a/packages/lib/server-only/document/send-document.ts
+++ b/packages/lib/server-only/document/send-document.ts
@@ -310,7 +310,7 @@ export const sendDocument = async ({
const injectFormValuesIntoDocument = async (
envelope: Envelope,
- envelopeItem: Pick & { documentData: DocumentData },
+ envelopeItem: Pick & { documentData: Omit },
) => {
const file = await getFileServerSide(envelopeItem.documentData);
diff --git a/packages/lib/types/document-data.ts b/packages/lib/types/document-data.ts
new file mode 100644
index 000000000..683d8417e
--- /dev/null
+++ b/packages/lib/types/document-data.ts
@@ -0,0 +1,18 @@
+import { z } from 'zod';
+
+export const ZDocumentDataMetaSchema = z.object({
+ // Could store other things such as PDF size, etc here.
+ pages: z
+ .object({
+ originalWidth: z.number().describe('Original PDF page width'),
+ originalHeight: z.number().describe('Original PDF page height'),
+ scale: z.number().describe('The scale applied to the width/height of the PDF page'),
+ scaledWidth: z.number().describe('Scaled PDF page image width'),
+ scaledHeight: z.number().describe('Scaled PDF page image height'),
+ })
+ .array(),
+});
+
+export type TDocumentDataMeta = z.infer;
+
+export type DocumentDataVersion = 'initial' | 'current';
diff --git a/packages/lib/universal/upload/put-file.server.ts b/packages/lib/universal/upload/put-file.server.ts
index 5d7e0fe26..00139f523 100644
--- a/packages/lib/universal/upload/put-file.server.ts
+++ b/packages/lib/universal/upload/put-file.server.ts
@@ -1,13 +1,19 @@
import { PDF } from '@libpdf/core';
import { DocumentDataType } from '@prisma/client';
import { base64 } from '@scure/base';
+import pMap from 'p-map';
import { match } from 'ts-pattern';
+import type { TDocumentDataMeta } from '@documenso/lib/types/document-data';
+import { ZDocumentDataMetaSchema } from '@documenso/lib/types/document-data';
import { env } from '@documenso/lib/utils/env';
+import { prisma } from '@documenso/prisma';
import { AppError } from '../../errors/app-error';
+import { pdfToImages } from '../../server-only/ai/pdf-to-images';
import { createDocumentData } from '../../server-only/document-data/create-document-data';
import { normalizePdf } from '../../server-only/pdf/normalize-pdf';
+import { getEnvelopeItemPageImageS3Key } from '../../utils/envelope-images';
import { uploadS3File } from './server-actions';
type File = {
@@ -41,7 +47,67 @@ export const putPdfFileServerSide = async (file: File) => {
const { type, data } = await putFileServerSide(file);
- return await createDocumentData({ type, data });
+ const newDocumentData = await createDocumentData({ type, data });
+
+ void extractAndStorePdfImages(arrayBuffer, newDocumentData.id);
+
+ return newDocumentData;
+};
+
+/**
+ * Extract and stores page images and metadata to S3.
+ */
+export const extractAndStorePdfImages = async (
+ arrayBuffer: ArrayBuffer,
+ documentDataId: string,
+) => {
+ const images = await pdfToImages(new Uint8Array(arrayBuffer));
+
+ const pageMetadata = images.map((image) => ({
+ originalWidth: image.originalWidth,
+ originalHeight: image.originalHeight,
+ scale: image.scale,
+ scaledWidth: image.scaledWidth,
+ scaledHeight: image.scaledHeight,
+ }));
+
+ const documentDataMetadata = ZDocumentDataMetaSchema.parse({
+ pages: pageMetadata,
+ } satisfies TDocumentDataMeta);
+
+ const updatedDocumentData = await prisma.documentData.update({
+ where: { id: documentDataId },
+ data: {
+ metadata: documentDataMetadata,
+ },
+ });
+
+ if (
+ env('NEXT_PUBLIC_UPLOAD_TRANSPORT') === 's3' &&
+ updatedDocumentData.type === DocumentDataType.S3_PATH
+ ) {
+ await pMap(
+ images,
+ async (image) => {
+ const imageBlob = new Blob([new Uint8Array(image.image)], { type: 'image/jpeg' });
+
+ const pageIndex = image.pageIndex;
+
+ const s3Key = getEnvelopeItemPageImageS3Key(updatedDocumentData.data, pageIndex);
+
+ const imageFile = new File([imageBlob], `${pageIndex}.jpeg`, {
+ type: 'image/jpeg',
+ });
+
+ const { key } = await uploadS3File(imageFile, s3Key);
+
+ return key;
+ },
+ { concurrency: 100 },
+ );
+ }
+
+ return pageMetadata;
};
/**
@@ -63,10 +129,14 @@ export const putNormalizedPdfFileServerSide = async (
arrayBuffer: async () => Promise.resolve(normalized),
});
- return await createDocumentData({
+ const newDocumentData = await createDocumentData({
type: documentData.type,
data: documentData.data,
});
+
+ void extractAndStorePdfImages(normalized, newDocumentData.id);
+
+ return newDocumentData;
};
/**
diff --git a/packages/lib/universal/upload/server-actions.ts b/packages/lib/universal/upload/server-actions.ts
index 79bc90090..36bdaf737 100644
--- a/packages/lib/universal/upload/server-actions.ts
+++ b/packages/lib/universal/upload/server-actions.ts
@@ -91,13 +91,13 @@ export const getPresignGetUrl = async (key: string) => {
/**
* Uploads a file to S3.
*/
-export const uploadS3File = async (file: File) => {
+export const uploadS3File = async (file: File, keyOverride?: string) => {
const client = getS3Client();
// Get the basename and extension for the file
const { name, ext } = path.parse(file.name);
- const key = `${alphaid(12)}/${slugify(name)}${ext}`;
+ const key = keyOverride ?? `${alphaid(12)}/${slugify(name)}${ext}`;
const fileBuffer = await file.arrayBuffer();
@@ -124,6 +124,28 @@ export const deleteS3File = async (key: string) => {
);
};
+/**
+ * Be careful about using this function as we don't allow the
+ * frontend to ever pull a file from S3 directly.
+ */
+export const UNSAFE_getS3File = async (key: string) => {
+ // Additional safeguard to prevent path traversal.
+ if (key.includes('..') || key.startsWith('/')) {
+ throw new Error('Invalid S3 key');
+ }
+
+ const client = getS3Client();
+
+ const response = await client.send(
+ new GetObjectCommand({
+ Bucket: env('NEXT_PRIVATE_UPLOAD_BUCKET'),
+ Key: key,
+ }),
+ );
+
+ return response.Body;
+};
+
const getS3Client = () => {
const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT');
diff --git a/packages/lib/utils/envelope-images.ts b/packages/lib/utils/envelope-images.ts
new file mode 100644
index 000000000..df2fb6d25
--- /dev/null
+++ b/packages/lib/utils/envelope-images.ts
@@ -0,0 +1,77 @@
+import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
+import type { DocumentDataVersion } from '../types/document-data';
+
+export type EnvelopeItemPageImageUrlOptions = {
+ envelopeId: string;
+ envelopeItemId: string;
+ documentDataId: string;
+ pageIndex: number;
+ token: string | undefined;
+ presignToken?: string | undefined;
+ version: DocumentDataVersion;
+};
+
+/**
+ * Generates the URL for fetching a single page of a PDF as an image.
+ */
+export const getEnvelopeItemPageImageUrl = (options: EnvelopeItemPageImageUrlOptions): string => {
+ const { envelopeId, envelopeItemId, documentDataId, pageIndex, token, presignToken, version } =
+ options;
+
+ const partialUrl = `envelope/${envelopeId}/envelopeItem/${envelopeItemId}/dataId/${documentDataId}/${version}/${pageIndex}/image.jpeg`;
+
+ // Recipient token endpoint.
+ if (token) {
+ return `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/token/${token}/${partialUrl}`;
+ }
+
+ // Endpoint authenticated by session or presigned token.
+ const baseUrl = `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/${partialUrl}`;
+
+ if (presignToken) {
+ return `${baseUrl}?presignToken=${presignToken}`;
+ }
+
+ return baseUrl;
+};
+
+export type EnvelopeItemMetaUrlOptions = {
+ envelopeId: string;
+ token: string | undefined;
+ presignToken?: string | undefined;
+};
+
+/**
+ * Generates the URL for fetching envelope metadata (page counts and dimensions).
+ */
+export const getEnvelopeItemMetaUrl = (options: EnvelopeItemMetaUrlOptions): string => {
+ const { envelopeId, token, presignToken } = options;
+
+ // Recipient token endpoint.
+ if (token) {
+ return `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/token/${token}/envelope/${envelopeId}/meta`;
+ }
+
+ // Endpoint authenticated by session or presigned token.
+ const baseUrl = `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/envelope/${envelopeId}/meta`;
+
+ if (presignToken) {
+ return `${baseUrl}?presignToken=${presignToken}`;
+ }
+
+ return baseUrl;
+};
+
+export const getEnvelopeItemPageImageS3Key = (
+ documentDataId: string,
+ pageIndex: number,
+): string => {
+ // Sanity check incase someone passes in a base64 PDF somehow.
+ if (documentDataId.length > 100) {
+ throw new Error('Document data is too long to be a valid S3 key');
+ }
+
+ const baseKey = documentDataId.split('/')[0];
+
+ return `${baseKey}/${pageIndex}.jpeg`;
+};
diff --git a/packages/prisma/migrations/20260118064000_add_document_data_meta/migration.sql b/packages/prisma/migrations/20260118064000_add_document_data_meta/migration.sql
new file mode 100644
index 000000000..eca7701df
--- /dev/null
+++ b/packages/prisma/migrations/20260118064000_add_document_data_meta/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "DocumentData" ADD COLUMN "metadata" JSONB;
diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma
index 886c6ea9c..dc36bf98f 100644
--- a/packages/prisma/schema.prisma
+++ b/packages/prisma/schema.prisma
@@ -487,11 +487,13 @@ enum DocumentSigningOrder {
SEQUENTIAL
}
+/// @zod.import(["import { ZDocumentDataMetaSchema } from '@documenso/lib/types/document-data';"])
model DocumentData {
id String @id @default(cuid())
type DocumentDataType
data String
initialData String
+ metadata Json? /// [DocumentDataMeta] @zod.custom.use(ZDocumentDataMetaSchema)
envelopeItem EnvelopeItem?
}
diff --git a/packages/prisma/types/types.d.ts b/packages/prisma/types/types.d.ts
index bca40ec19..62fb31c0d 100644
--- a/packages/prisma/types/types.d.ts
+++ b/packages/prisma/types/types.d.ts
@@ -4,6 +4,7 @@ import type {
TDocumentAuthOptions,
TRecipientAuthOptions,
} from '@documenso/lib/types/document-auth';
+import type { TDocumentDataMeta } from '@documenso/lib/types/document-data';
import type { TDocumentEmailSettings } from '@documenso/lib/types/document-email';
import type { TDocumentFormValues } from '@documenso/lib/types/document-form-values';
import type { TEnvelopeAttachmentType } from '@documenso/lib/types/envelope-attachment';
@@ -29,6 +30,7 @@ declare global {
type EnvelopeAttachmentType = TEnvelopeAttachmentType;
type DefaultRecipient = TDefaultRecipient;
+ type DocumentDataMeta = TDocumentDataMeta;
}
}
diff --git a/packages/trpc/server/document-router/get-document-by-token.ts b/packages/trpc/server/document-router/get-document-by-token.ts
index 60a91e1cd..539ac108d 100644
--- a/packages/trpc/server/document-router/get-document-by-token.ts
+++ b/packages/trpc/server/document-router/get-document-by-token.ts
@@ -28,7 +28,14 @@ export const getDocumentByTokenRoute = authenticatedProcedure
include: {
envelopeItems: {
include: {
- documentData: true,
+ documentData: {
+ select: {
+ id: true,
+ type: true,
+ data: true,
+ initialData: true,
+ },
+ },
},
},
},
diff --git a/packages/trpc/server/document-router/get-document-by-token.types.ts b/packages/trpc/server/document-router/get-document-by-token.types.ts
index 34f79c620..550d24ca5 100644
--- a/packages/trpc/server/document-router/get-document-by-token.types.ts
+++ b/packages/trpc/server/document-router/get-document-by-token.types.ts
@@ -7,7 +7,12 @@ export const ZGetDocumentByTokenRequestSchema = z.object({
});
export const ZGetDocumentByTokenResponseSchema = z.object({
- documentData: DocumentDataSchema,
+ documentData: DocumentDataSchema.pick({
+ id: true,
+ type: true,
+ data: true,
+ initialData: true,
+ }),
});
export type TGetDocumentByTokenRequest = z.infer;
diff --git a/packages/ui/components/pdf-viewer/envelope-pdf-viewer.tsx b/packages/ui/components/pdf-viewer/envelope-pdf-viewer.tsx
new file mode 100644
index 000000000..6758072c2
--- /dev/null
+++ b/packages/ui/components/pdf-viewer/envelope-pdf-viewer.tsx
@@ -0,0 +1,241 @@
+import React, { useEffect, useMemo, useRef } from 'react';
+
+import type { MessageDescriptor } from '@lingui/core';
+import { Trans, useLingui } from '@lingui/react/macro';
+
+import type {
+ BasePageRenderData,
+ PageRenderData,
+} from '@documenso/lib/client-only/providers/envelope-render-provider';
+import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
+import { PDF_VIEWER_PAGE_CLASSNAME } from '@documenso/lib/constants/pdf-viewer';
+import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
+import { cn } from '@documenso/ui/lib/utils';
+import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
+
+import { useVirtualList } from '../virtual-list/use-virtual-list';
+import { PdfViewerErrorState, PdfViewerLoadingState } from './pdf-viewer-states';
+
+export type EnvelopePdfViewerProps = {
+ className?: string;
+ scrollParentRef?: React.RefObject;
+ onDocumentLoad?: () => void;
+
+ /**
+ * Custom page renderer to use instead of just rendering the page as an image.
+ *
+ * Mainly used for when you want to render the page with Konva.
+ */
+ customPageRenderer?: React.FunctionComponent<{ pageData: PageRenderData }>;
+
+ /**
+ * The error message to render when there is an error.
+ */
+ errorMessage: { title: MessageDescriptor; description: MessageDescriptor } | null;
+} & React.HTMLAttributes;
+
+export const EnvelopePdfViewer = ({
+ className,
+ scrollParentRef,
+ onDocumentLoad,
+ customPageRenderer: CustomPageRenderer,
+ errorMessage,
+ ...props
+}: EnvelopePdfViewerProps) => {
+ const { t } = useLingui();
+
+ const $el = useRef(null);
+
+ const { currentEnvelopeItem, envelopeItemsMeta, envelopeItemsMetaLoadingState, renderError } =
+ useCurrentEnvelopeRender();
+
+ /**
+ * The metadata for the current item.
+ *
+ * `null` if no current item is selected.
+ */
+ const currentItemMeta = useMemo(() => {
+ if (!currentEnvelopeItem) {
+ return null;
+ }
+
+ return envelopeItemsMeta[currentEnvelopeItem.id] ?? null;
+ }, [currentEnvelopeItem, envelopeItemsMeta]);
+
+ const numPages = currentItemMeta?.length ?? 0;
+
+ /**
+ * Trigger the onDocumentLoad callback when the document is loaded.
+ */
+ useEffect(() => {
+ if (envelopeItemsMetaLoadingState === 'loaded' && onDocumentLoad) {
+ onDocumentLoad();
+ }
+ }, [envelopeItemsMetaLoadingState, onDocumentLoad]);
+
+ const isLoading = envelopeItemsMetaLoadingState === 'loading';
+ const hasError = envelopeItemsMetaLoadingState === 'error';
+
+ return (
+
+ {renderError && (
+
+
+ {t(errorMessage?.title || PDF_VIEWER_ERROR_MESSAGES.default.title)}
+
+
+ {t(errorMessage?.description || PDF_VIEWER_ERROR_MESSAGES.default.description)}
+
+
+ )}
+
+ {/* Loading State */}
+ {isLoading &&
}
+
+ {/* Error State */}
+ {hasError &&
}
+
+ {/* Render pages in a virtualized list. */}
+ {envelopeItemsMetaLoadingState === 'loaded' &&
+ currentEnvelopeItem &&
+ currentItemMeta &&
+ numPages > 0 && (
+
+ )}
+
+ {/* No current item selected */}
+ {envelopeItemsMetaLoadingState === 'loaded' && !currentEnvelopeItem && (
+
+
+ No document selected
+
+
+ )}
+
+ );
+};
+
+type VirtualizedPageListProps = {
+ scrollParentRef: React.RefObject;
+ constraintRef: React.RefObject;
+ pages: BasePageRenderData[];
+ envelopeItemId: string;
+ numPages: number;
+ CustomPageRenderer?: React.FunctionComponent<{ pageData: PageRenderData }>;
+};
+
+// Note: There is a duplicate of this component in `PDFViewer`.
+const VirtualizedPageList = ({
+ scrollParentRef,
+ constraintRef,
+ pages,
+ envelopeItemId,
+ numPages,
+ CustomPageRenderer,
+}: VirtualizedPageListProps) => {
+ const { virtualItems, totalSize, constraintWidth } = useVirtualList({
+ scrollRef: scrollParentRef,
+ constraintRef,
+ itemCount: numPages,
+ itemSize: (index, width) => {
+ const pageMeta = pages[index];
+
+ // Calculate height based on aspect ratio and available width
+ const aspectRatio = pageMeta.pageHeight / pageMeta.pageWidth;
+ const scaledHeight = width * aspectRatio;
+
+ // Add 32px for the page number text and margins (my-2 = 8px * 2 + text height ~16px)
+ return scaledHeight + 32;
+ },
+ overscan: 10,
+ });
+
+ 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.pageWidth;
+
+ const pageData: PageRenderData = {
+ ...pageMeta,
+ scale,
+ };
+
+ return (
+
+
+ {CustomPageRenderer ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ Page {pageNumber} of {numPages}
+
+
+
+ );
+ })}
+
+ );
+};
+
+const ImagePageRenderer = ({ pageData }: { pageData: PageRenderData }) => {
+ const { pageWidth, pageHeight, scale, imageUrl, pageNumber } = pageData;
+
+ const scaledWidth = pageWidth * scale;
+ const scaledHeight = pageHeight * scale;
+
+ return (
+
+

+
+ );
+};
+
+export default EnvelopePdfViewer;
diff --git a/packages/ui/components/pdf-viewer/pdf-viewer-konva-lazy.tsx b/packages/ui/components/pdf-viewer/pdf-viewer-konva-lazy.tsx
deleted file mode 100644
index ab85b329d..000000000
--- a/packages/ui/components/pdf-viewer/pdf-viewer-konva-lazy.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import React, { Suspense, lazy } from 'react';
-
-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, 'onPageClick'>;
-
-const EnvelopePdfViewer = lazy(async () => import('./pdf-viewer-konva'));
-
-export const PDFViewerKonvaLazy = (props: PDFViewerProps) => {
- return (
- Loading...
}>
-
-
- );
-};
-
-export default PDFViewerKonvaLazy;
diff --git a/packages/ui/components/pdf-viewer/pdf-viewer-konva.tsx b/packages/ui/components/pdf-viewer/pdf-viewer-konva.tsx
deleted file mode 100644
index 098fd5876..000000000
--- a/packages/ui/components/pdf-viewer/pdf-viewer-konva.tsx
+++ /dev/null
@@ -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 = () => (
- <>
-
-
-
- Loading document...
-
- >
-);
-
-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
, 'onPageClick'>;
-
-export const PdfViewerKonva = ({
- className,
- onDocumentLoad,
- customPageRenderer,
- renderer,
- ...props
-}: PdfViewerKonvaProps) => {
- const { t } = useLingui();
-
- const $el = useRef(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 (
-
- {renderError && (
-
- {t(RendererErrorMessages[renderer].title)}
- {t(RendererErrorMessages[renderer].description)}
-
- )}
-
- {envelopeItemFile && Konva ? (
-
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={
-
- {pdfError ? (
-
-
- Something went wrong while loading the document.
-
-
- Please try again or contact our support.
-
-
- ) : (
-
- )}
-
- }
- error={
-
-
-
- Something went wrong while loading the document.
-
-
- Please try again or contact our support.
-
-
-
- }
- options={pdfViewerOptions}
- >
- {Array(numPages)
- .fill(null)
- .map((_, i) => (
-
-
-
''}
- renderMode={customPageRenderer ? 'custom' : 'canvas'}
- customRenderer={customPageRenderer}
- />
-
-
-
- Page {i + 1} of {numPages}
-
-
-
- ))}
-
- ) : (
-
- )}
-
- );
-};
-
-export default PdfViewerKonva;
diff --git a/packages/ui/components/pdf-viewer/pdf-viewer-states.tsx b/packages/ui/components/pdf-viewer/pdf-viewer-states.tsx
new file mode 100644
index 000000000..fc84f21fa
--- /dev/null
+++ b/packages/ui/components/pdf-viewer/pdf-viewer-states.tsx
@@ -0,0 +1,31 @@
+import { Trans } from '@lingui/react/macro';
+
+import { cn } from '@documenso/ui/lib/utils';
+import { Spinner } from '@documenso/ui/primitives/spinner';
+
+export const PdfViewerLoadingState = () => {
+ return (
+
+
+
+ );
+};
+
+export const PdfViewerErrorState = () => {
+ return (
+
+
+
+ Something went wrong while loading the document.
+
+
+ Please try again or contact our support.
+
+
+
+ );
+};
diff --git a/packages/ui/components/pdf-viewer/pdf-viewer.tsx b/packages/ui/components/pdf-viewer/pdf-viewer.tsx
new file mode 100644
index 000000000..3fcfba3d9
--- /dev/null
+++ b/packages/ui/components/pdf-viewer/pdf-viewer.tsx
@@ -0,0 +1,283 @@
+import React, { useEffect, useMemo, useRef, useState } from 'react';
+
+import { msg } from '@lingui/core/macro';
+import { useLingui } from '@lingui/react';
+import { Trans } from '@lingui/react/macro';
+import type { EnvelopeItem } from '@prisma/client';
+
+import { PDF_VIEWER_PAGE_CLASSNAME } from '@documenso/lib/constants/pdf-viewer';
+import type { DocumentDataVersion } from '@documenso/lib/types/document-data';
+import {
+ getEnvelopeItemMetaUrl,
+ getEnvelopeItemPageImageUrl,
+} from '@documenso/lib/utils/envelope-images';
+import type { TGetEnvelopeItemsMetaResponse } from '@documenso/remix/server/api/files/files.types';
+
+import { cn } from '../../lib/utils';
+import { useToast } from '../../primitives/use-toast';
+import { useVirtualList } from '../virtual-list/use-virtual-list';
+import { PdfViewerErrorState, PdfViewerLoadingState } from './pdf-viewer-states';
+
+export type OverrideImage = {
+ image: Buffer;
+ width: number;
+ height: number;
+};
+
+export type OnPDFViewerPageClick = (_event: {
+ pageNumber: number;
+ numPages: number;
+ originalEvent: React.MouseEvent;
+ pageHeight: number;
+ pageWidth: number;
+ pageX: number;
+ pageY: number;
+}) => void | Promise;
+
+type PageMeta = {
+ imageUrl: string;
+ width: number;
+ height: number;
+ documentDataId: string;
+};
+
+type LoadingState = 'loading' | 'loaded' | 'error';
+
+export type PDFViewerProps = {
+ className?: string;
+ envelopeItem: Pick;
+ token: string | undefined;
+ presignToken?: string | undefined;
+ version: DocumentDataVersion;
+ onDocumentLoad?: () => void;
+ overrideImages?: OverrideImage[];
+} & React.HTMLAttributes;
+
+export const PDFViewer = ({
+ className,
+ envelopeItem,
+ token,
+ presignToken,
+ version,
+ onDocumentLoad,
+ overrideImages,
+ ...props
+}: PDFViewerProps) => {
+ const { _ } = useLingui();
+ const { toast } = useToast();
+
+ const $el = useRef(null);
+
+ const [loadingState, setLoadingState] = useState(
+ overrideImages ? 'loaded' : 'loading',
+ );
+ const [pages, setPages] = useState([]);
+
+ const numPages = overrideImages ? overrideImages.length : pages.length;
+
+ const derivedPages = useMemo((): PageMeta[] => {
+ if (overrideImages) {
+ return overrideImages.map((image) => ({
+ imageUrl: `data:image/jpeg;base64,${image.image.toString('base64')}`,
+ width: image.width,
+ height: image.height,
+ documentDataId: '',
+ }));
+ }
+
+ return pages;
+ }, [overrideImages, pages]);
+
+ // Fetch metadata when not using override images
+ useEffect(() => {
+ if (overrideImages) {
+ setLoadingState('loaded');
+ return;
+ }
+
+ const fetchMetadata = async () => {
+ try {
+ setLoadingState('loading');
+
+ const metaUrl = getEnvelopeItemMetaUrl({
+ envelopeId: envelopeItem.envelopeId,
+ token,
+ presignToken,
+ });
+
+ const response = await fetch(metaUrl);
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch envelope meta: ${response.status}`);
+ }
+
+ const data: TGetEnvelopeItemsMetaResponse = await response.json();
+
+ // Find the specific envelope item
+ const itemMeta = data.envelopeItems.find((item) => item.envelopeItemId === envelopeItem.id);
+
+ if (!itemMeta) {
+ throw new Error('Envelope item not found in metadata');
+ }
+
+ // Map pages to our internal format
+ const mappedPages: PageMeta[] = itemMeta.pages.map((page, pageIndex) => {
+ const imageUrl = getEnvelopeItemPageImageUrl({
+ envelopeId: envelopeItem.envelopeId,
+ envelopeItemId: envelopeItem.id,
+ documentDataId: itemMeta.documentDataId,
+ pageIndex,
+ token,
+ presignToken,
+ version,
+ });
+
+ return {
+ imageUrl,
+ width: page.originalWidth,
+ height: page.originalHeight,
+ documentDataId: itemMeta.documentDataId,
+ };
+ });
+
+ setPages(mappedPages);
+ setLoadingState('loaded');
+ } catch (err) {
+ console.error(err);
+ setLoadingState('error');
+
+ toast({
+ title: _(msg`Error`),
+ description: _(msg`An error occurred while loading the document.`),
+ variant: 'destructive',
+ });
+ }
+ };
+
+ void fetchMetadata();
+ }, [envelopeItem.envelopeId, envelopeItem.id, token, presignToken, version, overrideImages]);
+
+ // Notify when document is loaded
+ useEffect(() => {
+ if (loadingState === 'loaded' && onDocumentLoad) {
+ onDocumentLoad();
+ }
+ }, [loadingState, onDocumentLoad]);
+
+ const isLoading = loadingState === 'loading';
+ const hasError = loadingState === 'error';
+
+ return (
+
+ {/* Loading State */}
+ {isLoading &&
}
+
+ {/* Error State */}
+ {hasError &&
}
+
+ {/* Loaded State */}
+ {loadingState === 'loaded' && numPages > 0 && (
+
+ )}
+
+ );
+};
+
+type VirtualizedPageListProps = {
+ scrollParentRef: React.RefObject;
+ constraintRef: React.RefObject;
+ pages: PageMeta[];
+ numPages: number;
+};
+
+// Note: There is a duplicate of this component in `EnvelopePdfViewer`.
+// This current component is for V1 and legacy use cases.
+const VirtualizedPageList = ({
+ scrollParentRef,
+ constraintRef,
+ pages,
+ numPages,
+}: VirtualizedPageListProps) => {
+ const { virtualItems, totalSize, constraintWidth } = useVirtualList({
+ scrollRef: scrollParentRef,
+ constraintRef,
+ 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)
+ return scaledHeight + 32;
+ },
+ overscan: 5,
+ });
+
+ 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 = pageMeta.width * scale;
+ const scaledHeight = pageMeta.height * scale;
+
+ return (
+
+
+
+

+
+
+
+
+
+ Page {pageNumber} of {numPages}
+
+
+
+ );
+ })}
+
+ );
+};
+
+export default PDFViewer;
diff --git a/packages/ui/components/virtual-list/use-virtual-list.ts b/packages/ui/components/virtual-list/use-virtual-list.ts
new file mode 100644
index 000000000..9fb746f12
--- /dev/null
+++ b/packages/ui/components/virtual-list/use-virtual-list.ts
@@ -0,0 +1,211 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+
+export type VirtualListOptions = {
+ scrollRef: React.RefObject;
+ constraintRef?: React.RefObject;
+ itemCount: number;
+ itemSize: number | ((index: number, constraintWidth: number) => number);
+ overscan?: number;
+};
+
+export type VirtualItem = {
+ index: number;
+ start: number;
+ size: number;
+ key: string;
+};
+
+export type VirtualListResult = {
+ virtualItems: VirtualItem[];
+ totalSize: number;
+ constraintWidth: number;
+};
+
+/**
+ * A minimal list virtualizer hook that supports fixed item sizes and external scroll containers.
+ *
+ * @param options - Configuration options for the virtual list
+ * @returns Virtual items to render, total size, and constraint width
+ */
+export const useVirtualList = (options: VirtualListOptions): VirtualListResult => {
+ const { scrollRef, constraintRef, itemCount, itemSize, overscan = 3 } = options;
+
+ const [scrollTop, setScrollTop] = useState(0);
+ const [viewportHeight, setViewportHeight] = useState(0);
+ const [constraintWidth, setConstraintWidth] = useState(0);
+
+ // Track constraint element width with ResizeObserver
+ useEffect(() => {
+ const el = constraintRef?.current;
+
+ if (!el) {
+ return;
+ }
+
+ const observer = new ResizeObserver((entries) => {
+ const entry = entries[0];
+
+ if (entry) {
+ setConstraintWidth(entry.contentRect.width);
+ }
+ });
+
+ observer.observe(el);
+
+ // Set initial width
+ setConstraintWidth(el.getBoundingClientRect().width);
+
+ return () => observer.disconnect();
+ }, [constraintRef]);
+
+ // Track scroll container dimensions with ResizeObserver
+ useEffect(() => {
+ const el = scrollRef.current;
+
+ if (!el) {
+ return;
+ }
+
+ const observer = new ResizeObserver((entries) => {
+ const entry = entries[0];
+
+ if (entry) {
+ setViewportHeight(entry.contentRect.height);
+ }
+ });
+
+ observer.observe(el);
+
+ // Set initial height
+ setViewportHeight(el.getBoundingClientRect().height);
+
+ return () => observer.disconnect();
+ }, [scrollRef]);
+
+ // Handle scroll events
+ useEffect(() => {
+ const el = scrollRef.current;
+
+ if (!el) {
+ return;
+ }
+
+ const handleScroll = () => {
+ setScrollTop(el.scrollTop);
+ };
+
+ el.addEventListener('scroll', handleScroll, { passive: true });
+
+ // Set initial scroll position
+ setScrollTop(el.scrollTop);
+
+ return () => el.removeEventListener('scroll', handleScroll);
+ }, [scrollRef]);
+
+ // Get item size helper
+ const getItemSize = useCallback(
+ (index: number): number => {
+ if (typeof itemSize === 'function') {
+ return itemSize(index, constraintWidth);
+ }
+
+ return itemSize;
+ },
+ [itemSize, constraintWidth],
+ );
+
+ // Precompute item offsets for O(1) lookup
+ const { offsets, totalSize } = useMemo(() => {
+ const result: number[] = [];
+ let offset = 0;
+
+ for (let i = 0; i < itemCount; i++) {
+ result.push(offset);
+ offset += getItemSize(i);
+ }
+
+ return { offsets: result, totalSize: offset };
+ }, [itemCount, getItemSize]);
+
+ // Binary search to find the first visible item
+ const findStartIndex = useCallback(
+ (scrollTop: number): number => {
+ let low = 0;
+ let high = itemCount - 1;
+
+ while (low <= high) {
+ const mid = Math.floor((low + high) / 2);
+ const offset = offsets[mid];
+
+ if (offset < scrollTop) {
+ low = mid + 1;
+ } else {
+ high = mid - 1;
+ }
+ }
+
+ return Math.max(0, low - 1);
+ },
+ [offsets, itemCount],
+ );
+
+ // Calculate virtual items to render
+ const virtualItems = useMemo((): VirtualItem[] => {
+ if (itemCount === 0 || constraintWidth === 0) {
+ return [];
+ }
+
+ const startIndex = findStartIndex(scrollTop);
+ const items: VirtualItem[] = [];
+
+ // Apply overscan before visible area
+ const overscanStart = Math.max(0, startIndex - overscan);
+
+ // Find items within the visible area + overscan
+ for (let i = overscanStart; i < itemCount; i++) {
+ const start = offsets[i];
+ const size = getItemSize(i);
+
+ // Stop if we've gone past the visible area + overscan
+ if (start > scrollTop + viewportHeight) {
+ // Add overscan items after visible area
+ const overscanEnd = Math.min(itemCount, i + overscan);
+
+ for (let j = i; j < overscanEnd; j++) {
+ items.push({
+ index: j,
+ start: offsets[j],
+ size: getItemSize(j),
+ key: `virtual-item-${j}`,
+ });
+ }
+
+ break;
+ }
+
+ items.push({
+ index: i,
+ start,
+ size,
+ key: `virtual-item-${i}`,
+ });
+ }
+
+ return items;
+ }, [
+ itemCount,
+ constraintWidth,
+ scrollTop,
+ viewportHeight,
+ overscan,
+ offsets,
+ getItemSize,
+ findStartIndex,
+ ]);
+
+ return {
+ virtualItems,
+ totalSize,
+ constraintWidth,
+ };
+};
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 8c8f28c65..42595248f 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -66,14 +66,13 @@
"framer-motion": "^12.23.24",
"lucide-react": "^0.554.0",
"luxon": "^3.7.2",
- "perfect-freehand": "^1.2.2",
"pdfjs-dist": "5.4.296",
+ "perfect-freehand": "^1.2.2",
"react": "^18",
"react-colorful": "^5.6.1",
"react-day-picker": "^8.10.1",
"react-dom": "^18",
"react-hook-form": "^7.66.1",
- "react-pdf": "^10.3.0",
"react-rnd": "^10.5.2",
"remeda": "^2.32.0",
"tailwind-merge": "^1.14.0",
diff --git a/packages/ui/primitives/pdf-viewer/base.client.tsx b/packages/ui/primitives/pdf-viewer/base.client.tsx
deleted file mode 100644
index 496ea706d..000000000
--- a/packages/ui/primitives/pdf-viewer/base.client.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-import {
- type LoadedPDFDocument,
- type OnPDFViewerPageClick,
- PDFViewer,
- type PDFViewerProps,
-} from './base';
-
-export { PDFViewer, type LoadedPDFDocument, type OnPDFViewerPageClick, type PDFViewerProps };
-
-export default PDFViewer;
diff --git a/packages/ui/primitives/pdf-viewer/base.tsx b/packages/ui/primitives/pdf-viewer/base.tsx
deleted file mode 100644
index 4b150421b..000000000
--- a/packages/ui/primitives/pdf-viewer/base.tsx
+++ /dev/null
@@ -1,290 +0,0 @@
-import React, { useEffect, useMemo, useRef, useState } from 'react';
-
-import { msg } from '@lingui/core/macro';
-import { useLingui } from '@lingui/react';
-import { Trans } from '@lingui/react/macro';
-import type { EnvelopeItem } from '@prisma/client';
-import { base64 } from '@scure/base';
-import { Loader } from 'lucide-react';
-import { type PDFDocumentProxy } from 'pdfjs-dist';
-import { Document as PDFDocument, Page as PDFPage, pdfjs } from 'react-pdf';
-
-import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
-import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
-import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download';
-
-import { cn } from '../../lib/utils';
-import { useToast } from '../use-toast';
-
-export type LoadedPDFDocument = PDFDocumentProxy;
-
-/**
- * This imports the worker from the `pdfjs-dist` package.
- * Wrapped in typeof window check to prevent SSR evaluation.
- */
-if (typeof window !== 'undefined') {
- 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/`,
-};
-
-export type OnPDFViewerPageClick = (_event: {
- pageNumber: number;
- numPages: number;
- originalEvent: React.MouseEvent;
- pageHeight: number;
- pageWidth: number;
- pageX: number;
- pageY: number;
-}) => void | Promise;
-
-const PDFLoader = () => (
- <>
-
-
-
- Loading document...
-
- >
-);
-
-export type PDFViewerProps = {
- className?: string;
- envelopeItem: Pick;
- token: string | undefined;
- presignToken?: string | undefined;
- version: 'original' | 'signed';
- onDocumentLoad?: (_doc: LoadedPDFDocument) => void;
- onPageClick?: OnPDFViewerPageClick;
- overrideData?: string;
- customPageRenderer?: React.FunctionComponent;
- [key: string]: unknown;
-} & Omit, 'onPageClick'>;
-
-export const PDFViewer = ({
- className,
- envelopeItem,
- token,
- presignToken,
- version,
- onDocumentLoad,
- onPageClick,
- overrideData,
- customPageRenderer,
- ...props
-}: PDFViewerProps) => {
- const { _ } = useLingui();
- const { toast } = useToast();
-
- const $el = useRef(null);
-
- const [isDocumentBytesLoading, setIsDocumentBytesLoading] = useState(false);
- const [documentBytes, setDocumentBytes] = useState(
- overrideData ? base64.decode(overrideData) : null,
- );
-
- const [width, setWidth] = useState(0);
- const [numPages, setNumPages] = useState(0);
- const [pdfError, setPdfError] = useState(false);
-
- const isLoading = isDocumentBytesLoading || !documentBytes;
-
- const envelopeItemFile = useMemo(() => {
- if (!documentBytes) {
- return null;
- }
-
- return {
- data: documentBytes,
- };
- }, [documentBytes]);
-
- const onDocumentLoaded = (doc: LoadedPDFDocument) => {
- setNumPages(doc.numPages);
- onDocumentLoad?.(doc);
- };
-
- const onDocumentPageClick = (
- event: React.MouseEvent,
- pageNumber: number,
- ) => {
- const $el = event.target instanceof HTMLElement ? event.target : null;
-
- if (!$el) {
- return;
- }
-
- const $page = $el.closest(PDF_VIEWER_PAGE_SELECTOR);
-
- if (!$page) {
- return;
- }
-
- const { height, width, top, left } = $page.getBoundingClientRect();
-
- const pageX = event.clientX - left;
- const pageY = event.clientY - top;
-
- if (onPageClick) {
- void onPageClick({
- pageNumber,
- numPages,
- originalEvent: event,
- pageHeight: height,
- pageWidth: width,
- pageX,
- pageY,
- });
- }
- };
-
- 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);
- };
- }
- }, []);
-
- useEffect(() => {
- if (overrideData) {
- const bytes = base64.decode(overrideData);
-
- setDocumentBytes(bytes);
- return;
- }
-
- const fetchDocumentBytes = async () => {
- try {
- setIsDocumentBytesLoading(true);
-
- const documentUrl = getEnvelopeItemPdfUrl({
- type: 'view',
- envelopeItem: envelopeItem,
- token,
- presignToken,
- });
-
- const bytes = await fetch(documentUrl).then(async (res) => await res.arrayBuffer());
-
- setDocumentBytes(new Uint8Array(bytes));
-
- setIsDocumentBytesLoading(false);
- } catch (err) {
- console.error(err);
-
- toast({
- title: _(msg`Error`),
- description: _(msg`An error occurred while loading the document.`),
- variant: 'destructive',
- });
- }
- };
-
- void fetchDocumentBytes();
- }, [envelopeItem.envelopeId, envelopeItem.id, token, version, toast, overrideData]);
-
- return (
-
- {isLoading ? (
-
- ) : (
- <>
-
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={
-
- {pdfError ? (
-
-
- Something went wrong while loading the document.
-
-
- Please try again or contact our support.
-
-
- ) : (
-
- )}
-
- }
- error={
-
-
-
- Something went wrong while loading the document.
-
-
- Please try again or contact our support.
-
-
-
- }
- options={pdfViewerOptions}
- >
- {Array(numPages)
- .fill(null)
- .map((_, i) => (
-
-
-
''}
- renderMode={customPageRenderer ? 'custom' : 'canvas'}
- customRenderer={customPageRenderer}
- onClick={(e) => onDocumentPageClick(e, i + 1)}
- />
-
-
-
- Page {i + 1} of {numPages}
-
-
-
- ))}
-
- >
- )}
-
- );
-};
-
-export default PDFViewer;
diff --git a/packages/ui/primitives/pdf-viewer/index.ts b/packages/ui/primitives/pdf-viewer/index.ts
deleted file mode 100644
index 8a185aaec..000000000
--- a/packages/ui/primitives/pdf-viewer/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './base';
diff --git a/packages/ui/primitives/pdf-viewer/lazy.tsx b/packages/ui/primitives/pdf-viewer/lazy.tsx
deleted file mode 100644
index 30ce49d53..000000000
--- a/packages/ui/primitives/pdf-viewer/lazy.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-import { ClientOnly } from '../../components/client-only';
-
-import { PDFViewer, type PDFViewerProps } from './base.client';
-
-export const PDFViewerLazy = (props: PDFViewerProps) => {
- return Loading... }>{() => };
-};