(null);
return (
@@ -207,10 +216,14 @@ 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..2584ff577 100644
--- a/apps/remix/app/components/general/document/document-edit-form.tsx
+++ b/apps/remix/app/components/general/document/document-edit-form.tsx
@@ -14,6 +14,7 @@ import {
} from '@documenso/lib/constants/trpc';
import type { TDocument } from '@documenso/lib/types/document';
import { ZDocumentAccessAuthTypesSchema } from '@documenso/lib/types/document-auth';
+import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
@@ -27,10 +28,10 @@ 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';
+import { PDFViewer } from '~/components/general/pdf-viewer/pdf-viewer';
import { useCurrentTeam } from '~/providers/team';
export type DocumentEditFormProps = {
@@ -440,11 +441,17 @@ 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,
)}
>
{
const { t, i18n } = useLingui();
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
@@ -37,34 +40,22 @@ export default function EnvelopeEditorFieldsPageRenderer() {
const [isFieldChanging, setIsFieldChanging] = useState(false);
const [pendingFieldCreation, setPendingFieldCreation] = useState(null);
- const {
- stage,
- pageLayer,
- canvasElement,
- konvaContainer,
- pageContext,
- scaledViewport,
- unscaledViewport,
- } = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer));
+ const { stage, pageLayer, konvaContainer, scaledViewport, unscaledViewport } = 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, currentEnvelopeItem?.id],
);
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 +335,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 &&
@@ -531,7 +521,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
removePendingField();
- if (!canvasElement.current || !currentEnvelopeItem || !editorFields.selectedRecipient) {
+ if (!currentEnvelopeItem || !editorFields.selectedRecipient) {
return;
}
@@ -546,7 +536,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
editorFields.addField({
envelopeItemId: currentEnvelopeItem.id,
- page: pageContext.pageNumber,
+ page: pageNumber,
type,
positionX: fieldX,
positionY: fieldY,
@@ -575,10 +565,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
}
return (
-
+ <>
{selectedKonvaFieldGroups.length > 0 &&
interactiveTransformer.current &&
!isFieldChanging && (
@@ -640,17 +627,9 @@ export default function EnvelopeEditorFieldsPageRenderer() {
{/* The element Konva will inject it's canvas into. */}
-
- {/* Canvas the PDF will be rendered on. */}
-
-
+ >
);
-}
+};
type FieldActionButtonsProps = React.HTMLAttributes & {
handleDuplicateSelectedFields: () => void;
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 8eba4eb8a..3ef6992a2 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,6 @@ 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 { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator';
@@ -46,16 +46,14 @@ import { EditorFieldNumberForm } from '~/components/forms/editor/editor-field-nu
import { EditorFieldRadioForm } from '~/components/forms/editor/editor-field-radio-form';
import { EditorFieldSignatureForm } from '~/components/forms/editor/editor-field-signature-form';
import { EditorFieldTextForm } from '~/components/forms/editor/editor-field-text-form';
+import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
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..086eaa199 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
@@ -1,4 +1,4 @@
-import { lazy, useEffect, useMemo, useState } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
import { faker } from '@faker-js/faker/locale/en';
import { Trans } from '@lingui/react/macro';
@@ -11,21 +11,20 @@ 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 { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
import { Separator } from '@documenso/ui/primitives/separator';
-import { EnvelopeRendererFileSelector } from './envelope-file-selector';
+import { EnvelopeGenericPageRenderer } from '~/components/general/envelope-editor/envelope-generic-page-renderer';
+import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
-const EnvelopeGenericPageRenderer = lazy(
- async () => import('~/components/general/envelope-editor/envelope-generic-page-renderer'),
-);
+import { EnvelopeRendererFileSelector } from './envelope-file-selector';
// Todo: Envelopes - Dynamically import faker
export const EnvelopeEditorPreviewPage = () => {
@@ -33,6 +32,8 @@ export const EnvelopeEditorPreviewPage = () => {
const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender();
+ const scrollableContainerRef = useRef
(null);
+
const [selectedPreviewMode, setSelectedPreviewMode] = useState<'recipient' | 'signed'>(
'recipient',
);
@@ -200,7 +201,9 @@ export const EnvelopeEditorPreviewPage = () => {
// Override the parent renderer provider so we can inject custom fields.
return (
({
@@ -212,12 +215,12 @@ export const EnvelopeEditorPreviewPage = () => {
}}
>
-
+
{/* Horizontal envelope item selector */}
{/* Document View */}
-
+
Preview Mode
@@ -228,9 +231,10 @@ 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..9e2b1e4aa 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,7 +5,10 @@ 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';
@@ -15,7 +18,7 @@ type GenericLocalField = TEnvelope['fields'][number] & {
recipient: Pick
;
};
-export default function EnvelopeGenericPageRenderer() {
+export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRenderData }) => {
const { i18n } = useLingui();
const {
@@ -28,19 +31,14 @@ export default function EnvelopeGenericPageRenderer() {
overrideSettings,
} = useCurrentEnvelopeRender();
- const {
- stage,
- pageLayer,
- canvasElement,
- konvaContainer,
- pageContext,
- scaledViewport,
- unscaledViewport,
- } = usePageRenderer(({ stage, pageLayer }) => {
- createPageCanvas(stage, pageLayer);
- });
+ const { stage, pageLayer, konvaContainer, unscaledViewport } = 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, envelopeStatus]);
const unsafeRenderFieldOnLayer = (field: GenericLocalField) => {
if (!pageLayer.current) {
@@ -160,11 +157,9 @@ export default function EnvelopeGenericPageRenderer() {
}
return (
-
+ <>
{overrideSettings?.showRecipientTooltip &&
+ pageData.imageLoadingState === 'loaded' &&
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..76432c229 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';
@@ -49,7 +52,7 @@ type GenericLocalField = TEnvelope['fields'][number] & {
recipient: Pick
;
};
-export default function EnvelopeSignerPageRenderer() {
+export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderData }) => {
const { t, i18n } = useLingui();
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
const { sessionData } = useOptionalSession();
@@ -77,17 +80,12 @@ 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, konvaContainer, unscaledViewport } = usePageRenderer(
+ ({ stage, pageLayer }) => createPageCanvas(stage, pageLayer),
+ pageData,
+ );
- const { _className, scale } = pageContext;
+ const { scale, pageNumber } = pageData;
const { envelope } = envelopeData;
@@ -99,10 +97,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, currentEnvelopeItem?.id]);
/**
* Returns fields that have been fully signed by other recipients for this specific
@@ -117,7 +114,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 +129,7 @@ export default function EnvelopeSignerPageRenderer() {
},
}));
});
- }, [envelope.recipients, pageContext.pageNumber]);
+ }, [envelope.recipients, pageNumber, currentEnvelopeItem?.id]);
const unsafeRenderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
if (!pageLayer.current) {
@@ -534,14 +531,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-signing/envelope-signing-complete-dialog.tsx b/apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx
index ca1a80729..76a6133f2 100644
--- a/apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx
+++ b/apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx
@@ -6,6 +6,7 @@ import { useNavigate, useRevalidator, useSearchParams } from 'react-router';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
+import { PDF_VIEWER_CONTENT_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { isBase64Image } from '@documenso/lib/constants/signatures';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
@@ -71,6 +72,14 @@ export const EnvelopeSignerCompleteDialog = () => {
if (fieldTooltip) {
fieldTooltip.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ } else {
+ // Tooltip not in DOM (page virtualized away) — signal the PDF viewer
+ // to scroll to the correct page via the data attribute.
+ const pdfContent = document.querySelector(PDF_VIEWER_CONTENT_SELECTOR);
+
+ if (pdfContent) {
+ pdfContent.setAttribute('data-scroll-to-page', String(nextField.page));
+ }
}
},
isEnvelopeItemSwitch ? 150 : 50,
diff --git a/apps/remix/app/components/general/pdf-viewer/envelope-pdf-viewer.tsx b/apps/remix/app/components/general/pdf-viewer/envelope-pdf-viewer.tsx
new file mode 100644
index 000000000..af2ea1690
--- /dev/null
+++ b/apps/remix/app/components/general/pdf-viewer/envelope-pdf-viewer.tsx
@@ -0,0 +1,63 @@
+import React, { useRef } from 'react';
+
+import type { MessageDescriptor } from '@lingui/core';
+import { Trans, useLingui } from '@lingui/react/macro';
+
+import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
+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 { PDFViewer, type PDFViewerProps } from './pdf-viewer';
+
+export type EnvelopePdfViewerProps = {
+ /**
+ * The error message to render when there is an error.
+ */
+ errorMessage: { title: MessageDescriptor; description: MessageDescriptor } | null;
+} & Omit
;
+
+export const EnvelopePdfViewer = ({
+ errorMessage,
+ className,
+ ...props
+}: EnvelopePdfViewerProps) => {
+ const { t } = useLingui();
+
+ const $el = useRef(null);
+
+ const { currentEnvelopeItem, renderError } = useCurrentEnvelopeRender();
+
+ if (renderError || !currentEnvelopeItem) {
+ return (
+
+ {renderError ? (
+
+
+ {t(errorMessage?.title || PDF_VIEWER_ERROR_MESSAGES.default.title)}
+
+
+ {t(errorMessage?.description || PDF_VIEWER_ERROR_MESSAGES.default.description)}
+
+
+ ) : (
+
+
+ No document selected
+
+
+ )}
+
+ );
+ }
+
+ return (
+
+ );
+};
+
+export default EnvelopePdfViewer;
diff --git a/apps/remix/app/components/general/pdf-viewer/pdf-viewer-page-image.tsx b/apps/remix/app/components/general/pdf-viewer/pdf-viewer-page-image.tsx
new file mode 100644
index 000000000..271cedd2c
--- /dev/null
+++ b/apps/remix/app/components/general/pdf-viewer/pdf-viewer-page-image.tsx
@@ -0,0 +1,34 @@
+import { Trans } from '@lingui/react/macro';
+
+import type { ImageLoadingState } from '@documenso/lib/client-only/providers/envelope-render-provider';
+import { cn } from '@documenso/ui/lib/utils';
+import { Spinner } from '@documenso/ui/primitives/spinner';
+
+type PdfViewerPageImageProps = {
+ imageLoadingState: ImageLoadingState;
+ imageProps: React.ImgHTMLAttributes & Record & { alt: '' };
+};
+
+export const PdfViewerPageImage = ({ imageLoadingState, imageProps }: PdfViewerPageImageProps) => {
+ return (
+ <>
+ {/* Loading State */}
+ {imageLoadingState === 'loading' && (
+
+
+
+ )}
+
+ {imageLoadingState === 'error' && (
+
+
+ Error loading page
+
+
+ )}
+
+ {/* The PDF image. */}
+ {imageProps.src &&
}
+ >
+ );
+};
diff --git a/apps/remix/app/components/general/pdf-viewer/pdf-viewer-states.tsx b/apps/remix/app/components/general/pdf-viewer/pdf-viewer-states.tsx
new file mode 100644
index 000000000..975a5efde
--- /dev/null
+++ b/apps/remix/app/components/general/pdf-viewer/pdf-viewer-states.tsx
@@ -0,0 +1,26 @@
+import { Trans } from '@lingui/react/macro';
+
+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/apps/remix/app/components/general/pdf-viewer/pdf-viewer.tsx b/apps/remix/app/components/general/pdf-viewer/pdf-viewer.tsx
new file mode 100644
index 000000000..d6d2f34e5
--- /dev/null
+++ b/apps/remix/app/components/general/pdf-viewer/pdf-viewer.tsx
@@ -0,0 +1,478 @@
+import React, { useEffect, useMemo, useRef, useState } from 'react';
+
+import { Trans, useLingui } from '@lingui/react/macro';
+import pMap from 'p-map';
+import * as pdfjsLib from 'pdfjs-dist';
+import pdfjsWorker from 'pdfjs-dist/build/pdf.worker?url';
+
+import type {
+ ImageLoadingState,
+ PageRenderData,
+} from '@documenso/lib/client-only/providers/envelope-render-provider';
+import { PDF_VIEWER_PAGE_CLASSNAME } from '@documenso/lib/constants/pdf-viewer';
+import { cn } from '@documenso/ui/lib/utils';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+import type { ScrollTarget } from '../virtual-list/use-virtual-list';
+import { useVirtualList } from '../virtual-list/use-virtual-list';
+import { PdfViewerPageImage } from './pdf-viewer-page-image';
+import { PdfViewerErrorState, PdfViewerLoadingState } from './pdf-viewer-states';
+import { useScrollToPage } from './use-scroll-to-page';
+
+pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker;
+
+type PageMeta = {
+ width: number;
+ height: number;
+};
+
+type LoadingState = 'loading' | 'loaded' | 'error';
+
+const LOW_RENDER_RESOLUTION = 1;
+const HIGH_RENDER_RESOLUTION = 2;
+const IDLE_RENDER_DELAY = 200;
+
+export type PDFViewerProps = {
+ className?: string;
+
+ /**
+ * The PDF data to render.
+ *
+ * If it's a URL, it will be fetched and rendered.
+ */
+ data: Uint8Array | string;
+
+ /**
+ * Ref to the scrollable parent container that handles scrolling.
+ *
+ * This must point to an element with `overflow-y: auto` or `overflow-y: scroll`
+ * that is an ancestor of this component, or `'window'` to use the browser
+ * window as the scroll container.
+ */
+ scrollParentRef: ScrollTarget;
+
+ onDocumentLoad?: () => void;
+
+ /**
+ * Additional component to render next to the image, such as a Konva canvas
+ * for rendering fields.
+ */
+ customPageRenderer?: React.FunctionComponent<{ pageData: PageRenderData }>;
+} & React.HTMLAttributes;
+
+export const PDFViewer = ({
+ className,
+ data,
+ scrollParentRef,
+ onDocumentLoad,
+ customPageRenderer,
+ ...props
+}: PDFViewerProps) => {
+ const { t } = useLingui();
+ const { toast } = useToast();
+
+ const $el = useRef(null);
+
+ const [loadingState, setLoadingState] = useState('loading');
+
+ const [pdf, setPdf] = useState(null);
+
+ const [pages, setPages] = useState([]);
+
+ useEffect(() => {
+ const fetchMetadata = async () => {
+ try {
+ setLoadingState('loading');
+ setPages([]);
+
+ let result: Uint8Array | null = typeof data === 'string' ? null : new Uint8Array(data);
+
+ if (typeof data === 'string') {
+ const response = await fetch(data);
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch PDF data: ${response.status}`);
+ }
+
+ result = new Uint8Array(await response.arrayBuffer());
+ }
+
+ const loadedPdf = await pdfjsLib.getDocument({ data: result! }).promise;
+
+ if (pdf) {
+ await pdf.destroy();
+ }
+
+ setPdf(loadedPdf);
+
+ // Fetch the pages
+ const pages = await pMap(
+ Array.from({ length: loadedPdf.numPages }),
+ async (_, pageIndex) => {
+ const page = await loadedPdf.getPage(pageIndex + 1);
+ const viewport = page.getViewport({ scale: 1 });
+
+ return {
+ width: viewport.width,
+ height: viewport.height,
+ };
+ },
+ );
+
+ setPages(pages);
+
+ setLoadingState('loaded');
+ } catch (err) {
+ console.error(err);
+ setLoadingState('error');
+
+ toast({
+ title: t`Error`,
+ description: t`An error occurred while loading the document.`,
+ variant: 'destructive',
+ });
+ }
+ };
+
+ void fetchMetadata();
+
+ return () => {
+ if (pdf) {
+ void pdf.destroy();
+ }
+ };
+ }, [data]);
+
+ // 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' && pages.length > 0 && pdf && (
+
+ )}
+
+ );
+};
+
+type VirtualizedPageListProps = {
+ scrollParentRef: ScrollTarget;
+ constraintRef: React.RefObject;
+ pages: PageMeta[];
+ numPages: number;
+ pdf: pdfjsLib.PDFDocumentProxy;
+ customPageRenderer?: React.FunctionComponent<{ pageData: PageRenderData }>;
+};
+
+const VirtualizedPageList = ({
+ scrollParentRef,
+ constraintRef,
+ pages,
+ numPages,
+ pdf,
+ customPageRenderer,
+}: VirtualizedPageListProps) => {
+ const contentRef = useRef(null);
+
+ const { virtualItems, totalSize, constraintWidth, scrollToItem } = useVirtualList({
+ scrollRef: scrollParentRef,
+ constraintRef,
+ contentRef,
+ itemCount: numPages,
+ itemSize: (index, width) => {
+ const pageMeta = pages[index];
+
+ // Calculate height based on aspect ratio and available width
+ const aspectRatio = pageMeta.height / pageMeta.width;
+ const scaledHeight = width * aspectRatio;
+
+ // Add 32px for the page number text and margins (my-2 = 8px * 2 + text height ~16px)
+ // Add additional 2px for the top and bottom borders.
+ return scaledHeight + 32 + 2;
+ },
+ overscan: 5,
+ });
+
+ useScrollToPage(contentRef, scrollToItem);
+
+ return (
+
+ {virtualItems.map((virtualItem) => {
+ const index = virtualItem.index;
+ const pageMeta = pages[index];
+ const pageNumber = index + 1;
+
+ // Calculate scale based on constraint width
+ const scale = constraintWidth / pageMeta.width;
+
+ const scaledWidth = Math.floor(pageMeta.width * scale);
+ const scaledHeight = Math.floor(pageMeta.height * scale);
+
+ return (
+
+
+
+
+
+ Page {pageNumber} of {numPages}
+
+
+
+ );
+ })}
+
+ );
+};
+
+type PdfViewerPageProps = {
+ pageNumber: number;
+ pdf: pdfjsLib.PDFDocumentProxy;
+ unscaledWidth: number;
+ unscaledHeight: number;
+ scaledWidth: number;
+ scaledHeight: number;
+ scale: number;
+ customPageRenderer?: React.FunctionComponent<{ pageData: PageRenderData }>;
+};
+
+const PdfViewerPage = ({
+ pageNumber,
+ pdf,
+ unscaledWidth,
+ unscaledHeight,
+ scaledWidth,
+ scaledHeight,
+ scale,
+ customPageRenderer: CustomPageRenderer,
+}: PdfViewerPageProps) => {
+ const { imageProps, imageLoadingState } = usePdfPageImage({
+ pageNumber,
+ pdf,
+ unscaledWidth,
+ unscaledHeight,
+ scaledWidth,
+ scaledHeight,
+ scale,
+ });
+
+ return (
+
+ {CustomPageRenderer && imageLoadingState === 'loaded' && (
+
+ )}
+
+
+
+ );
+};
+
+/**
+ * Manages rendering a page from a pdf.
+ */
+const usePdfPageImage = ({
+ pageNumber,
+ pdf,
+ scale,
+ scaledWidth,
+ scaledHeight,
+}: PdfViewerPageProps) => {
+ const [imageLoadingState, setImageLoadingState] = useState('loading');
+
+ const [imageUrl, setImageUrl] = useState('');
+ const renderTaskRef = useRef(null);
+ const idleTimerRef = useRef | null>(null);
+
+ const renderedResolutionRef = useRef(null);
+ const renderedPageNumberRef = useRef(null);
+ const renderedPdfRef = useRef(null);
+
+ useEffect(() => {
+ let isCancelled = false;
+
+ const cancelRenderTask = () => {
+ if (!renderTaskRef.current) {
+ return;
+ }
+
+ renderTaskRef.current.cancel();
+ renderTaskRef.current = null;
+ };
+
+ const hasMatchingRenderedImage = (resolution: number) => {
+ return (
+ renderedPdfRef.current === pdf &&
+ renderedPageNumberRef.current === pageNumber &&
+ renderedResolutionRef.current === resolution
+ );
+ };
+
+ const setRenderedImageMeta = (resolution: number) => {
+ renderedPdfRef.current = pdf;
+ renderedPageNumberRef.current = pageNumber;
+ renderedResolutionRef.current = resolution;
+ };
+
+ const renderAtResolution = async (resolution: number) => {
+ let currentTask: pdfjsLib.RenderTask | null = null;
+
+ try {
+ if (isCancelled) {
+ return;
+ }
+
+ if (hasMatchingRenderedImage(resolution)) {
+ return;
+ }
+
+ cancelRenderTask();
+
+ const page = await pdf.getPage(pageNumber);
+
+ if (isCancelled) {
+ return;
+ }
+
+ const renderScale = scale * resolution;
+ const viewport = page.getViewport({ scale: renderScale });
+ const canvas = document.createElement('canvas');
+ canvas.width = Math.floor(viewport.width);
+ canvas.height = Math.floor(viewport.height);
+
+ const context = canvas.getContext('2d');
+
+ if (!context) {
+ throw new Error('Failed to get canvas context');
+ }
+
+ currentTask = page.render({
+ canvasContext: context,
+ viewport,
+ canvas,
+ });
+ renderTaskRef.current = currentTask;
+
+ await currentTask.promise;
+
+ if (isCancelled || renderTaskRef.current !== currentTask) {
+ return;
+ }
+
+ setRenderedImageMeta(resolution);
+
+ setImageUrl(canvas.toDataURL('image/jpeg'));
+ } catch (err) {
+ if (err instanceof Error && err.name === 'RenderingCancelledException') {
+ return;
+ }
+
+ if (!isCancelled) {
+ console.error(err);
+ setImageLoadingState('error');
+ }
+ } finally {
+ if (renderTaskRef.current === currentTask) {
+ renderTaskRef.current = null;
+ }
+ }
+ };
+
+ void renderAtResolution(LOW_RENDER_RESOLUTION);
+
+ idleTimerRef.current = setTimeout(() => {
+ void renderAtResolution(HIGH_RENDER_RESOLUTION);
+ }, IDLE_RENDER_DELAY);
+
+ return () => {
+ isCancelled = true;
+
+ if (idleTimerRef.current) {
+ clearTimeout(idleTimerRef.current);
+ idleTimerRef.current = null;
+ }
+
+ cancelRenderTask();
+ };
+ }, [pdf, pageNumber, scale]);
+
+ const imageProps = useMemo(
+ (): React.ImgHTMLAttributes & Record & { alt: '' } => ({
+ className: PDF_VIEWER_PAGE_CLASSNAME,
+ width: Math.floor(scaledWidth),
+ height: Math.floor(scaledHeight),
+ alt: '',
+ onLoad: () => setImageLoadingState('loaded'),
+ onError: () => setImageLoadingState('error'),
+ src: imageUrl,
+ 'data-page-number': pageNumber,
+ draggable: false,
+ }),
+ [scaledWidth, scaledHeight, imageUrl, pageNumber],
+ );
+
+ return {
+ imageProps,
+ imageLoadingState,
+ };
+};
diff --git a/apps/remix/app/components/general/pdf-viewer/use-scroll-to-page.ts b/apps/remix/app/components/general/pdf-viewer/use-scroll-to-page.ts
new file mode 100644
index 000000000..54247a16a
--- /dev/null
+++ b/apps/remix/app/components/general/pdf-viewer/use-scroll-to-page.ts
@@ -0,0 +1,46 @@
+import { type RefObject, useEffect } from 'react';
+
+/**
+ * Watch for `data-scroll-to-page` attribute changes on a container element.
+ *
+ * When set (by `validateFieldsInserted`, `handleOnNextFieldClick`, or similar),
+ * scroll the virtual list to the requested page and clear the attribute.
+ *
+ * This is the communication bridge between field validation logic (which knows
+ * which page to scroll to) and the virtual list (which knows how to scroll).
+ */
+export const useScrollToPage = (
+ contentRef: RefObject,
+ scrollToItem: (index: number) => void,
+) => {
+ useEffect(() => {
+ const el = contentRef.current;
+
+ if (!el) {
+ return;
+ }
+
+ const observer = new MutationObserver((mutations) => {
+ for (const mutation of mutations) {
+ if (mutation.type === 'attributes' && mutation.attributeName === 'data-scroll-to-page') {
+ const raw = el.getAttribute('data-scroll-to-page');
+
+ if (raw) {
+ const pageNumber = parseInt(raw, 10);
+
+ if (!isNaN(pageNumber) && pageNumber >= 1) {
+ // Pages are 1-indexed, virtual list items are 0-indexed.
+ scrollToItem(pageNumber - 1);
+ }
+
+ el.removeAttribute('data-scroll-to-page');
+ }
+ }
+ }
+ });
+
+ observer.observe(el, { attributes: true, attributeFilter: ['data-scroll-to-page'] });
+
+ return () => observer.disconnect();
+ }, [contentRef, scrollToItem]);
+};
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..c084f6357 100644
--- a/apps/remix/app/components/general/template/template-edit-form.tsx
+++ b/apps/remix/app/components/general/template/template-edit-form.tsx
@@ -13,12 +13,12 @@ import {
} from '@documenso/lib/constants/trpc';
import { ZDocumentAccessAuthTypesSchema } from '@documenso/lib/types/document-auth';
import type { TTemplate } from '@documenso/lib/types/template';
+import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download';
import { trpc } from '@documenso/trpc/react';
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';
@@ -28,6 +28,7 @@ import { AddTemplateSettingsFormPartial } from '@documenso/ui/primitives/templat
import type { TAddTemplateSettingsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-settings.types';
import { useToast } from '@documenso/ui/primitives/use-toast';
+import { PDFViewer } from '~/components/general/pdf-viewer/pdf-viewer';
import { useCurrentTeam } from '~/providers/team';
export type TemplateEditFormProps = {
@@ -312,11 +313,17 @@ export const TemplateEditForm = ({
gradient
>
- setIsDocumentPdfLoaded(true)}
/>
diff --git a/apps/remix/app/components/general/virtual-list/use-virtual-list.ts b/apps/remix/app/components/general/virtual-list/use-virtual-list.ts
new file mode 100644
index 000000000..21db2e5c9
--- /dev/null
+++ b/apps/remix/app/components/general/virtual-list/use-virtual-list.ts
@@ -0,0 +1,355 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+
+export type ScrollTarget = React.RefObject | 'window';
+
+export type VirtualListOptions = {
+ scrollRef: ScrollTarget;
+ constraintRef?: React.RefObject;
+
+ /**
+ * Ref to the element that contains the virtual list content.
+ *
+ * Used to calculate the offset between the scroll container and the virtual
+ * list when the scroll container is a parent element higher in the DOM tree.
+ *
+ * When the virtual list is not at the top of the scroll container (e.g. there
+ * are headers, alerts, or other content above it), this offset ensures the
+ * scroll position is correctly adjusted for virtualization calculations.
+ */
+ contentRef?: 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;
+
+ /**
+ * Scroll the scroll container so that the item at the given index is visible.
+ *
+ * The scroll position is calculated from the precomputed item offsets and
+ * adjusted for any content offset (e.g. headers above the virtual list).
+ */
+ scrollToItem: (index: number) => void;
+};
+
+/**
+ * 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, contentRef, itemCount, itemSize, overscan = 3 } = options;
+
+ const [scrollTop, setScrollTop] = useState(0);
+ const [viewportHeight, setViewportHeight] = useState(0);
+ const [constraintWidth, setConstraintWidth] = useState(0);
+
+ /**
+ * The offset of the content element relative to the scroll container.
+ *
+ * This is recalculated on scroll to handle cases where dynamic content
+ * above the virtual list changes size.
+ */
+ const contentOffsetRef = useRef(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(() => {
+ if (scrollRef === 'window') {
+ const handleResize = () => {
+ setViewportHeight(window.innerHeight);
+ };
+
+ window.addEventListener('resize', handleResize);
+
+ // Set initial height
+ setViewportHeight(window.innerHeight);
+
+ return () => window.removeEventListener('resize', handleResize);
+ }
+
+ 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 and calculate content offset
+ useEffect(() => {
+ if (scrollRef === 'window') {
+ const calculateOffset = () => {
+ const contentEl = contentRef?.current;
+
+ if (!contentEl) {
+ contentOffsetRef.current = 0;
+ return;
+ }
+
+ // For window scrolling, the offset is the distance from the top of the
+ // content element to the top of the document, which is its bounding rect
+ // top plus the current scroll position.
+ contentOffsetRef.current = contentEl.getBoundingClientRect().top + window.scrollY;
+ };
+
+ const handleScroll = () => {
+ calculateOffset();
+
+ const adjustedScrollTop = Math.max(0, window.scrollY - contentOffsetRef.current);
+ setScrollTop(adjustedScrollTop);
+ };
+
+ window.addEventListener('scroll', handleScroll, { passive: true });
+
+ // Set initial values
+ calculateOffset();
+ const adjustedScrollTop = Math.max(0, window.scrollY - contentOffsetRef.current);
+ setScrollTop(adjustedScrollTop);
+
+ return () => window.removeEventListener('scroll', handleScroll);
+ }
+
+ const scrollEl = scrollRef.current;
+
+ if (!scrollEl) {
+ return;
+ }
+
+ const calculateOffset = () => {
+ const contentEl = contentRef?.current;
+
+ if (!contentEl) {
+ contentOffsetRef.current = 0;
+ return;
+ }
+
+ const scrollRect = scrollEl.getBoundingClientRect();
+ const contentRect = contentEl.getBoundingClientRect();
+
+ // The offset is the distance from the top of the content element to
+ // the top of the scroll container, adjusted for current scroll position.
+ contentOffsetRef.current = contentRect.top - scrollRect.top + scrollEl.scrollTop;
+ };
+
+ const handleScroll = () => {
+ calculateOffset();
+
+ const adjustedScrollTop = Math.max(0, scrollEl.scrollTop - contentOffsetRef.current);
+ setScrollTop(adjustedScrollTop);
+ };
+
+ scrollEl.addEventListener('scroll', handleScroll, { passive: true });
+
+ // Set initial values
+ calculateOffset();
+ const adjustedScrollTop = Math.max(0, scrollEl.scrollTop - contentOffsetRef.current);
+ setScrollTop(adjustedScrollTop);
+
+ return () => scrollEl.removeEventListener('scroll', handleScroll);
+ }, [scrollRef, contentRef]);
+
+ // 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,
+ ]);
+
+ /**
+ * Imperatively scroll the scroll container so that the item at the given
+ * index is at the top of the viewport.
+ */
+ const scrollToItem = useCallback(
+ (index: number) => {
+ if (index < 0 || index >= itemCount) {
+ return;
+ }
+
+ const itemOffset = offsets[index] ?? 0;
+
+ if (scrollRef === 'window') {
+ const contentEl = contentRef?.current;
+ const contentTop = contentEl ? contentEl.getBoundingClientRect().top + window.scrollY : 0;
+
+ window.scrollTo({
+ top: contentTop + itemOffset,
+ behavior: 'smooth',
+ });
+ } else {
+ const scrollEl = scrollRef.current;
+
+ if (!scrollEl) {
+ return;
+ }
+
+ // Recalculate content offset to get the most up-to-date value.
+ const contentEl = contentRef?.current;
+ let contentOffset = 0;
+
+ if (contentEl) {
+ const scrollRect = scrollEl.getBoundingClientRect();
+ const contentRect = contentEl.getBoundingClientRect();
+ contentOffset = contentRect.top - scrollRect.top + scrollEl.scrollTop;
+ }
+
+ scrollEl.scrollTo({
+ top: contentOffset + itemOffset,
+ behavior: 'smooth',
+ });
+ }
+ },
+ [scrollRef, contentRef, offsets, itemCount],
+ );
+
+ return {
+ virtualItems,
+ totalSize,
+ constraintWidth,
+ scrollToItem,
+ };
+};
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..cc28707ef 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
@@ -1,5 +1,3 @@
-import { lazy } from 'react';
-
import { msg } from '@lingui/core/macro';
import { Plural, Trans, useLingui } from '@lingui/react/macro';
import { DocumentStatus } from '@prisma/client';
@@ -9,19 +7,19 @@ 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 { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
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 { 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';
@@ -35,16 +33,15 @@ import {
FRIENDLY_STATUS_MAP,
} from '~/components/general/document/document-status';
import { EnvelopeRendererFileSelector } from '~/components/general/envelope-editor/envelope-file-selector';
+import { EnvelopeGenericPageRenderer } from '~/components/general/envelope-editor/envelope-generic-page-renderer';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
+import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
+import { PDFViewer } from '~/components/general/pdf-viewer/pdf-viewer';
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
import { useCurrentTeam } from '~/providers/team';
import type { Route } from './+types/documents.$id._index';
-const EnvelopeGenericPageRenderer = lazy(
- async () => import('~/components/general/envelope-editor/envelope-generic-page-renderer'),
-);
-
export default function DocumentPage({ params }: Route.ComponentProps) {
const { t } = useLingui();
const { user } = useSession();
@@ -154,7 +151,9 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
{envelope.internalVersion === 2 ? (
-
@@ -193,11 +193,17 @@ 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..115d93bc8 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,7 +99,9 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
return (
import('~/components/general/envelope-editor/envelope-generic-page-renderer'),
-);
-
export default function TemplatePage({ params }: Route.ComponentProps) {
const { t } = useLingui();
const { user } = useSession();
@@ -173,7 +170,9 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
{envelope.internalVersion === 2 ? (
-
@@ -210,11 +210,17 @@ 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..7045c1f30 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,12 @@ 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 c7ba1eaaa..204abfbb9 100644
--- a/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
+++ b/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
@@ -504,7 +504,12 @@ 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..01788540d 100644
--- a/apps/remix/app/routes/embed+/_v0+/direct.$token.tsx
+++ b/apps/remix/app/routes/embed+/_v0+/direct.$token.tsx
@@ -320,7 +320,12 @@ const EmbedDirectTemplatePageV2 = ({
user={user}
isDirectTemplate={true}
>
-
+
-
+
()
/**
@@ -319,3 +321,8 @@ export const filesRoute = new Hono()
});
},
);
+
+// PDF routes for both tokens and auth based
+// Is different to the other file endpoints since it uses documentDataId for hard caching.
+filesRoute.route('/', getEnvelopeItemPdfRoute);
+filesRoute.route('/', getEnvelopeItemPdfByTokenRoute);
diff --git a/apps/remix/server/api/files/routes/get-envelope-item-pdf-by-token.ts b/apps/remix/server/api/files/routes/get-envelope-item-pdf-by-token.ts
new file mode 100644
index 000000000..81d74a860
--- /dev/null
+++ b/apps/remix/server/api/files/routes/get-envelope-item-pdf-by-token.ts
@@ -0,0 +1,81 @@
+import { sValidator } from '@hono/standard-validator';
+import type { Prisma } from '@prisma/client';
+import { Hono } from 'hono';
+import { z } from 'zod';
+
+import { prisma } from '@documenso/prisma';
+
+import type { HonoEnv } from '../../../router';
+import { handleEnvelopeItemPdfRequest } from './get-envelope-item-pdf';
+
+const route = new Hono();
+
+const ZGetEnvelopeItemByTokenParamsSchema = 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']),
+});
+
+/**
+ * Returns a PDF file for an envelope item using a token.
+ */
+route.get(
+ '/token/:token/envelope/:envelopeId/envelopeItem/:envelopeItemId/dataId/:documentDataId/:version/item.pdf',
+ sValidator('param', ZGetEnvelopeItemByTokenParamsSchema),
+ async (c) => {
+ const { token, envelopeId, envelopeItemId, documentDataId, version } = c.req.valid('param');
+
+ if (!token) {
+ return c.json({ error: 'Not found' }, 404);
+ }
+
+ // Recipient token based query.
+ let envelopeItemWhereQuery: Prisma.EnvelopeItemWhereInput = {
+ id: envelopeItemId,
+ documentDataId,
+ envelope: {
+ id: envelopeId,
+ recipients: {
+ some: {
+ token,
+ },
+ },
+ },
+ };
+
+ // QR token based query.
+ if (token.startsWith('qr_')) {
+ envelopeItemWhereQuery = {
+ id: envelopeItemId,
+ documentDataId,
+ envelope: {
+ id: envelopeId,
+ qrToken: token,
+ },
+ };
+ }
+
+ // Validate envelope access.
+ const envelopeItem = await prisma.envelopeItem.findFirst({
+ where: envelopeItemWhereQuery,
+ include: {
+ documentData: true,
+ },
+ });
+
+ if (!envelopeItem) {
+ return c.json({ error: 'Not found' }, 404);
+ }
+
+ return await handleEnvelopeItemPdfRequest({
+ c,
+ envelopeItem,
+ version,
+ cacheStrategy: 'private',
+ });
+ },
+);
+
+export default route;
diff --git a/apps/remix/server/api/files/routes/get-envelope-item-pdf.ts b/apps/remix/server/api/files/routes/get-envelope-item-pdf.ts
new file mode 100644
index 000000000..587be164e
--- /dev/null
+++ b/apps/remix/server/api/files/routes/get-envelope-item-pdf.ts
@@ -0,0 +1,145 @@
+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 type { DocumentDataVersion } from '@documenso/lib/types/document';
+import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
+import { prisma } from '@documenso/prisma';
+
+import type { HonoEnv } from '../../../router';
+
+const route = new Hono();
+
+const ZGetEnvelopeItemPdfRequestParamsSchema = z.object({
+ envelopeId: z.string().min(1),
+ envelopeItemId: z.string().min(1),
+ documentDataId: z.string().min(1),
+ version: z.enum(['initial', 'current']),
+});
+
+const ZGetEnvelopeItemPdfRequestQuerySchema = z.object({
+ presignToken: z.string().optional(),
+});
+
+/**
+ * Returns a PDF file for an envelope item.
+ */
+route.get(
+ '/envelope/:envelopeId/envelopeItem/:envelopeItemId/dataId/:documentDataId/:version/item.pdf',
+ sValidator('param', ZGetEnvelopeItemPdfRequestParamsSchema),
+ sValidator('query', ZGetEnvelopeItemPdfRequestQuerySchema),
+ async (c) => {
+ const { envelopeId, envelopeItemId, documentDataId, version } = 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: We authenticate whether the user can access this in the `getTeamById` below.
+ const envelopeItem = await prisma.envelopeItem.findFirst({
+ where: {
+ id: envelopeItemId,
+ envelopeId,
+ documentDataId,
+ },
+ include: {
+ documentData: true,
+ envelope: {
+ select: {
+ id: true,
+ teamId: true,
+ },
+ },
+ },
+ });
+
+ if (!envelopeItem) {
+ return c.json({ error: 'Not found' }, 404);
+ }
+
+ // Check whether the user has access to the document.
+ const team = await getTeamById({
+ userId,
+ teamId: envelopeItem.envelope.teamId,
+ }).catch(() => null);
+
+ if (!team) {
+ return c.json({ error: 'Not found' }, 404);
+ }
+
+ return await handleEnvelopeItemPdfRequest({
+ c,
+ envelopeItem,
+ version,
+ cacheStrategy: 'private',
+ });
+ },
+);
+
+type HandleEnvelopeItemPdfRequestOptions = {
+ c: Context;
+ envelopeItem: EnvelopeItem & {
+ documentData: DocumentData;
+ };
+ version: DocumentDataVersion;
+
+ /**
+ * The type of cache strategy to use.
+ *
+ * For access via tokens, we can use a public cache to allow the CDN to cache it.
+ *
+ * For access via session, we must use a private cache.
+ */
+ cacheStrategy: 'private' | 'public';
+};
+
+export const handleEnvelopeItemPdfRequest = async ({
+ c,
+ envelopeItem,
+ version,
+ cacheStrategy,
+}: HandleEnvelopeItemPdfRequestOptions) => {
+ // Determine which PDF data to use based on version requested.
+ const documentDataToUse =
+ version === 'current' ? envelopeItem.documentData.data : envelopeItem.documentData.initialData;
+
+ const file = await getFileServerSide({
+ type: envelopeItem.documentData.type,
+ data: documentDataToUse,
+ }).catch((error) => {
+ console.error(error);
+
+ return null;
+ });
+
+ if (!file) {
+ return c.json({ error: 'Not found' }, 404);
+ }
+
+ // Note: Only set these headers on success.
+ c.header('Content-Type', 'application/pdf');
+ c.header('Cache-Control', `${cacheStrategy}, max-age=31536000, immutable`);
+
+ return c.body(file);
+};
+
+export default route;
diff --git a/package-lock.json b/package-lock.json
index c90398d0f..ca1f32b63 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -27753,24 +27753,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",
@@ -28155,23 +28137,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",
@@ -32012,44 +31977,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",
@@ -36264,15 +36191,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",
@@ -37189,7 +37107,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",
@@ -37347,7 +37264,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 404a03ae1..074c49626 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,
@@ -108,7 +109,9 @@ test.describe('AutoSave Subject Step', () => {
// Toggle some email settings checkboxes (randomly - some checked, some unchecked)
await page.getByText('Email the owner when a recipient signs').click();
await page.getByText("Email recipients when they're removed from a pending document").click();
- await page.getByText('Email recipients when the document is completed', { exact: true }).click();
+ await page
+ .getByText('Email recipients when the document is completed', { exact: true })
+ .click();
await page.getByText('Email recipients when a pending document is deleted').click();
await triggerAutosave(page);
@@ -139,16 +142,20 @@ test.describe('AutoSave Subject Step', () => {
).toBeChecked({
checked: emailSettings?.documentCompleted,
});
- await expect(page.getByText('Email recipients when a pending document is deleted')).toBeChecked({
+ await expect(
+ page.getByText('Email recipients when a pending document is deleted'),
+ ).toBeChecked({
checked: emailSettings?.documentDeleted,
});
await expect(page.getByText('Email recipients with a signing request')).toBeChecked({
checked: emailSettings?.recipientSigningRequest,
});
- await expect(page.getByText('Email the signer if the document is still pending')).toBeChecked({
- checked: emailSettings?.documentPending,
- });
+ await expect(page.getByText('Email the signer if the document is still pending')).toBeChecked(
+ {
+ checked: emailSettings?.documentPending,
+ },
+ );
await expect(page.getByText('Email the owner when the document is completed')).toBeChecked({
checked: emailSettings?.ownerDocumentCompleted,
});
@@ -167,7 +174,9 @@ test.describe('AutoSave Subject Step', () => {
await page.getByText('Email the owner when a recipient signs').click();
await page.getByText("Email recipients when they're removed from a pending document").click();
- await page.getByText('Email recipients when the document is completed', { exact: true }).click();
+ await page
+ .getByText('Email recipients when the document is completed', { exact: true })
+ .click();
await page.getByText('Email recipients when a pending document is deleted').click();
await triggerAutosave(page);
@@ -207,16 +216,20 @@ test.describe('AutoSave Subject Step', () => {
).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentCompleted,
});
- await expect(page.getByText('Email recipients when a pending document is deleted')).toBeChecked({
+ await expect(
+ page.getByText('Email recipients when a pending document is deleted'),
+ ).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentDeleted,
});
await expect(page.getByText('Email recipients with a signing request')).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.recipientSigningRequest,
});
- await expect(page.getByText('Email the signer if the document is still pending')).toBeChecked({
- checked: retrievedDocumentData.documentMeta?.emailSettings?.documentPending,
- });
+ await expect(page.getByText('Email the signer if the document is still pending')).toBeChecked(
+ {
+ checked: retrievedDocumentData.documentMeta?.emailSettings?.documentPending,
+ },
+ );
await expect(page.getByText('Email the owner when the document is completed')).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.ownerDocumentCompleted,
});
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/pdf-viewer/pdf-viewer.spec.ts b/packages/app-tests/e2e/pdf-viewer/pdf-viewer.spec.ts
new file mode 100644
index 000000000..a5c12854d
--- /dev/null
+++ b/packages/app-tests/e2e/pdf-viewer/pdf-viewer.spec.ts
@@ -0,0 +1,416 @@
+import { expect, test } from '@playwright/test';
+import { FieldType } from '@prisma/client';
+import path from 'node:path';
+
+import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
+import { createEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/create-embedding-presign-token';
+import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
+import { prefixedId } from '@documenso/lib/universal/id';
+import {
+ mapSecondaryIdToDocumentId,
+ mapSecondaryIdToTemplateId,
+} from '@documenso/lib/utils/envelope';
+import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
+import { prisma } from '@documenso/prisma';
+import {
+ seedBlankDocument,
+ seedCompletedDocument,
+ seedPendingDocumentWithFullFields,
+} from '@documenso/prisma/seed/documents';
+import { seedBlankTemplate, seedDirectTemplate } from '@documenso/prisma/seed/templates';
+import { seedUser } from '@documenso/prisma/seed/users';
+
+import { apiSignin } from '../fixtures/authentication';
+
+const PDF_PAGE_SELECTOR = 'img[data-page-number]';
+
+async function addSecondEnvelopeItem(envelopeId: string) {
+ const firstItem = await prisma.envelopeItem.findFirstOrThrow({
+ where: { envelopeId },
+ orderBy: { order: 'asc' },
+ include: { documentData: true },
+ });
+
+ const newDocumentData = await prisma.documentData.create({
+ data: {
+ type: firstItem.documentData.type,
+ data: firstItem.documentData.data,
+ initialData: firstItem.documentData.initialData,
+ },
+ });
+
+ await prisma.envelopeItem.create({
+ data: {
+ id: prefixedId('envelope_item'),
+ title: `${firstItem.title} - Page 2`,
+ documentDataId: newDocumentData.id,
+ order: 2,
+ envelopeId,
+ },
+ });
+}
+
+test.describe('PDF Viewer Rendering', () => {
+ test.describe('Authenticated Pages', () => {
+ test('should render PDF on all authenticated pages (V1 and V2)', async ({ page }) => {
+ const { user, team } = await seedUser();
+
+ const documentV1 = await seedBlankDocument(user, team.id);
+ const documentV2 = await seedBlankDocument(user, team.id, { internalVersion: 2 });
+ await addSecondEnvelopeItem(documentV2.id);
+
+ const templateV1 = await seedBlankTemplate(user, team.id);
+ const templateV2 = await seedBlankTemplate(user, team.id, {
+ createTemplateOptions: { internalVersion: 2 },
+ });
+ await addSecondEnvelopeItem(templateV2.id);
+
+ await apiSignin({
+ page,
+ email: user.email,
+ redirectPath: `/t/${team.url}/documents/${documentV1.id}`,
+ });
+
+ await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
+
+ await page.goto(`/t/${team.url}/documents/${documentV2.id}`);
+ await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
+ await page.getByRole('button', { name: /Page 2/ }).click();
+ await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
+
+ await page.goto(`/t/${team.url}/templates/${templateV1.id}`);
+ await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
+
+ await page.goto(`/t/${team.url}/templates/${templateV2.id}`);
+ await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
+ await page.getByRole('button', { name: /Page 2/ }).click();
+ await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
+
+ await page.goto(`/t/${team.url}/documents/${documentV1.id}/edit`);
+ await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
+
+ await page.goto(`/t/${team.url}/documents/${documentV2.id}/edit?step=addFields`);
+ await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
+ await page.getByRole('button', { name: /Page 2/ }).click();
+ await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
+
+ await page.goto(`/t/${team.url}/templates/${templateV1.id}/edit`);
+ await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
+
+ await page.goto(`/t/${team.url}/templates/${templateV2.id}/edit?step=addFields`);
+ await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
+ await page.getByRole('button', { name: /Page 2/ }).click();
+ await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
+
+ await page.goto(`/t/${team.url}/documents/${documentV1.id}`);
+ await page.locator(PDF_PAGE_SELECTOR).first().waitFor({ state: 'visible', timeout: 30_000 });
+ });
+ });
+
+ test.describe('Recipient Signing', () => {
+ test('should render PDF on signing page (V1 and V2)', async ({ page }) => {
+ const { user, team } = await seedUser();
+
+ const { recipients: recipientsV1 } = await seedPendingDocumentWithFullFields({
+ owner: user,
+ teamId: team.id,
+ recipients: ['signer-v1@test.documenso.com'],
+ fields: [FieldType.SIGNATURE],
+ });
+
+ const { document: documentV2, recipients: recipientsV2 } =
+ await seedPendingDocumentWithFullFields({
+ owner: user,
+ teamId: team.id,
+ recipients: ['signer-v2@test.documenso.com'],
+ fields: [FieldType.SIGNATURE],
+ updateDocumentOptions: { internalVersion: 2 },
+ });
+ await addSecondEnvelopeItem(documentV2.id);
+
+ await page.goto(`/sign/${recipientsV1[0].token}`);
+ await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
+
+ await page.goto(`/sign/${recipientsV2[0].token}`);
+ await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
+ await page.getByRole('button', { name: /Page 2/ }).click();
+ await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
+ });
+ });
+
+ test.describe('Direct Template', () => {
+ test('should render PDF on direct template page (V1 and V2)', async ({ page }) => {
+ const { user, team } = await seedUser();
+
+ const templateV1 = await seedDirectTemplate({
+ title: 'PDF Viewer Test Template V1',
+ userId: user.id,
+ teamId: team.id,
+ });
+
+ const templateV2 = await seedDirectTemplate({
+ title: 'PDF Viewer Test Template V2',
+ userId: user.id,
+ teamId: team.id,
+ internalVersion: 2,
+ });
+ await addSecondEnvelopeItem(templateV2.id);
+
+ await page.goto(formatDirectTemplatePath(templateV1.directLink?.token || ''));
+ await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
+
+ await page.goto(formatDirectTemplatePath(templateV2.directLink?.token || ''));
+ await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
+ await page.getByRole('button', { name: /Page 2/ }).click();
+ await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
+ });
+ });
+
+ test.describe('Share Page', () => {
+ test('should render PDF on share page (V1 and V2)', async ({ page }) => {
+ const { user, team } = await seedUser();
+
+ const qrTokenV1 = prefixedId('qr');
+ const qrTokenV2 = prefixedId('qr');
+
+ const documentV1 = await seedCompletedDocument(
+ user,
+ team.id,
+ ['share-v1@test.documenso.com'],
+ {
+ createDocumentOptions: { qrToken: qrTokenV1 },
+ },
+ );
+
+ const documentV2 = await seedCompletedDocument(
+ user,
+ team.id,
+ ['share-v2@test.documenso.com'],
+ {
+ createDocumentOptions: { qrToken: qrTokenV2 },
+ internalVersion: 2,
+ },
+ );
+ await addSecondEnvelopeItem(documentV2.id);
+
+ await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/share/${qrTokenV1}`);
+ await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
+
+ await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/share/${qrTokenV2}`);
+ await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
+
+ await page.getByRole('button', { name: /Page 2/ }).click();
+ await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
+ });
+ });
+
+ test.describe('Embed Pages', () => {
+ test('should render PDF on embed sign page (V1 and V2)', async ({ page }) => {
+ const { user, team } = await seedUser();
+
+ const { recipients: recipientsV1 } = await seedPendingDocumentWithFullFields({
+ owner: user,
+ teamId: team.id,
+ recipients: ['embed-signer-v1@test.documenso.com'],
+ fields: [FieldType.SIGNATURE],
+ });
+
+ const { document: documentV2, recipients: recipientsV2 } =
+ await seedPendingDocumentWithFullFields({
+ owner: user,
+ teamId: team.id,
+ recipients: ['embed-signer-v2@test.documenso.com'],
+ fields: [FieldType.SIGNATURE],
+ updateDocumentOptions: { internalVersion: 2 },
+ });
+ await addSecondEnvelopeItem(documentV2.id);
+
+ await page.goto(`/embed/sign/${recipientsV1[0].token}`);
+ await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
+
+ await page.goto(`/embed/sign/${recipientsV2[0].token}`);
+ await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
+
+ // Todo: Multisign does not support multiple envelope items.
+ // await page.getByRole('button', { name: /Page 2/ }).click();
+ // await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
+ });
+
+ test('should render PDF on embed direct template page (V1 and V2)', async ({ page }) => {
+ const { user, team } = await seedUser();
+
+ const templateV1 = await seedDirectTemplate({
+ title: 'Embed Direct Template V1',
+ userId: user.id,
+ teamId: team.id,
+ });
+
+ const templateV2 = await seedDirectTemplate({
+ title: 'Embed Direct Template V2',
+ userId: user.id,
+ teamId: team.id,
+ internalVersion: 2,
+ });
+ await addSecondEnvelopeItem(templateV2.id);
+
+ await page.goto(`/embed/direct/${templateV1.directLink?.token || ''}`);
+ await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
+
+ await page.goto(`/embed/direct/${templateV2.directLink?.token || ''}`);
+ await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
+ await page.getByRole('button', { name: /Page 2/ }).click();
+ await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
+ });
+
+ test('should render PDF on embed multisign page (V1 and V2)', async ({ page }) => {
+ const { user, team } = await seedUser();
+
+ const { recipients: recipientsV1 } = await seedPendingDocumentWithFullFields({
+ owner: user,
+ teamId: team.id,
+ recipients: ['multisign-v1@test.documenso.com'],
+ fields: [FieldType.SIGNATURE],
+ });
+
+ const { document: documentV2, recipients: recipientsV2 } =
+ await seedPendingDocumentWithFullFields({
+ owner: user,
+ teamId: team.id,
+ recipients: ['multisign-v2@test.documenso.com'],
+ fields: [FieldType.SIGNATURE],
+ updateDocumentOptions: { internalVersion: 2 },
+ });
+ await addSecondEnvelopeItem(documentV2.id);
+
+ await page.goto(`/embed/v1/multisign?token=${recipientsV1[0].token}`);
+ await expect(page.getByText('Sign Documents')).toBeVisible({ timeout: 15_000 });
+
+ // Todo: Multisign does not support multiple envelope items.
+ // await page.getByRole('button', { name: /View/i }).first().click();
+ // await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
+
+ await page.goto(`/embed/v1/multisign?token=${recipientsV2[0].token}`);
+ await expect(page.getByText('Sign Documents')).toBeVisible({ timeout: 15_000 });
+ await page.getByRole('button', { name: /View/i }).first().click();
+ await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
+
+ // Todo: Multisign does not support multiple envelope items.
+ // await page.getByRole('button', { name: /Page 2/ }).click();
+ // await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
+ });
+
+ test('should render PDF on embed authoring document create page', async ({ page }) => {
+ const { user, team } = await seedUser();
+
+ const { token: apiToken } = await createApiToken({
+ userId: user.id,
+ teamId: team.id,
+ tokenName: 'pdf-viewer-test',
+ expiresIn: null,
+ });
+
+ const { token: presignToken } = await createEmbeddingPresignToken({
+ apiToken,
+ });
+
+ const embedParams = { darkModeDisabled: false, features: {} };
+ const hash = btoa(encodeURIComponent(JSON.stringify(embedParams)));
+
+ await page.goto(
+ `${NEXT_PUBLIC_WEBAPP_URL()}/embed/v1/authoring/document/create?token=${presignToken}#${hash}`,
+ );
+
+ await expect(page.getByText('Configure Document')).toBeVisible({ timeout: 15_000 });
+
+ const titleInput = page.getByLabel('Title');
+ await titleInput.click();
+ await titleInput.fill('PDF Viewer E2E Test');
+
+ const emailInput = page.getByPlaceholder('Email').first();
+ await emailInput.click();
+ await emailInput.fill('test-signer@documenso.com');
+
+ const [fileChooser] = await Promise.all([
+ page.waitForEvent('filechooser'),
+ page
+ .locator('input[type=file]')
+ .first()
+ .evaluate((el) => {
+ if (el instanceof HTMLInputElement) {
+ el.click();
+ }
+ }),
+ ]);
+
+ await fileChooser.setFiles(path.join(__dirname, '../../../../assets/example.pdf'));
+
+ await page.getByRole('button', { name: 'Continue' }).click();
+
+ await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
+ });
+
+ test('should render PDF on embed document edit page', async ({ page }) => {
+ const { user, team } = await seedUser();
+
+ const document = await seedBlankDocument(user, team.id);
+ const documentId = mapSecondaryIdToDocumentId(document.secondaryId);
+
+ const { token: apiToken } = await createApiToken({
+ userId: user.id,
+ teamId: team.id,
+ tokenName: 'pdf-viewer-doc-edit-test',
+ expiresIn: null,
+ });
+
+ const { token: presignToken } = await createEmbeddingPresignToken({
+ apiToken,
+ scope: `documentId:${documentId}`,
+ });
+
+ const embedParams = {
+ darkModeDisabled: false,
+ features: {},
+ onlyEditFields: true,
+ };
+ const hash = btoa(encodeURIComponent(JSON.stringify(embedParams)));
+
+ await page.goto(
+ `${NEXT_PUBLIC_WEBAPP_URL()}/embed/v1/authoring/document/edit/${documentId}?token=${presignToken}#${hash}`,
+ );
+
+ await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
+ });
+
+ test('should render PDF on embed template edit page', async ({ page }) => {
+ const { user, team } = await seedUser();
+
+ const template = await seedBlankTemplate(user, team.id);
+ const templateId = mapSecondaryIdToTemplateId(template.secondaryId);
+
+ const { token: apiToken } = await createApiToken({
+ userId: user.id,
+ teamId: team.id,
+ tokenName: 'pdf-viewer-template-edit-test',
+ expiresIn: null,
+ });
+
+ const { token: presignToken } = await createEmbeddingPresignToken({
+ apiToken,
+ scope: `templateId:${templateId}`,
+ });
+
+ const embedParams = {
+ darkModeDisabled: false,
+ features: {},
+ onlyEditFields: true,
+ };
+ const hash = btoa(encodeURIComponent(JSON.stringify(embedParams)));
+
+ await page.goto(
+ `${NEXT_PUBLIC_WEBAPP_URL()}/embed/v1/authoring/template/edit/${templateId}?token=${presignToken}#${hash}`,
+ );
+
+ await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
+ });
+ });
+});
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-editor-fields.ts b/packages/lib/client-only/hooks/use-editor-fields.ts
index 6b0b37776..1f6ab98df 100644
--- a/packages/lib/client-only/hooks/use-editor-fields.ts
+++ b/packages/lib/client-only/hooks/use-editor-fields.ts
@@ -6,6 +6,7 @@ import { FieldType } from '@prisma/client';
import { useFieldArray, useForm } from 'react-hook-form';
import { z } from 'zod';
+import { getPdfPagesCount } from '@documenso/lib/constants/pdf-viewer';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { nanoid } from '@documenso/lib/universal/id';
@@ -222,14 +223,16 @@ export const useEditorFields = ({
const duplicateFieldToAllPages = useCallback(
(field: TLocalField): TLocalField[] => {
- const pages = Array.from(document.querySelectorAll('[data-page-number]'));
+ const totalPages = getPdfPagesCount();
const newFields: TLocalField[] = [];
- pages.forEach((_, index) => {
- const pageNumber = index + 1;
+ if (totalPages < 1) {
+ return newFields;
+ }
+ for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) {
if (pageNumber === field.page) {
- return;
+ continue;
}
const newField: TLocalField = {
@@ -241,7 +244,7 @@ export const useEditorFields = ({
append(newField);
newFields.push(newField);
- });
+ }
triggerFieldsUpdate();
return newFields;
diff --git a/packages/lib/client-only/hooks/use-element-bounds.ts b/packages/lib/client-only/hooks/use-element-bounds.ts
index 560accca6..53724697d 100644
--- a/packages/lib/client-only/hooks/use-element-bounds.ts
+++ b/packages/lib/client-only/hooks/use-element-bounds.ts
@@ -17,7 +17,7 @@ export const useElementBounds = (elementOrSelector: HTMLElement | string, withSc
: elementOrSelector;
if (!$el) {
- throw new Error('Element not found');
+ return { top: 0, left: 0, width: 0, height: 0 };
}
if (withScroll) {
@@ -36,7 +36,7 @@ export const useElementBounds = (elementOrSelector: HTMLElement | string, withSc
useEffect(() => {
setBounds(calculateBounds());
- }, []);
+ }, [calculateBounds]);
useEffect(() => {
const onResize = () => {
@@ -48,7 +48,7 @@ export const useElementBounds = (elementOrSelector: HTMLElement | string, withSc
return () => {
window.removeEventListener('resize', onResize);
};
- }, []);
+ }, [calculateBounds]);
useEffect(() => {
const $el =
@@ -69,7 +69,7 @@ export const useElementBounds = (elementOrSelector: HTMLElement | string, withSc
return () => {
observer.disconnect();
};
- }, []);
+ }, [elementOrSelector, calculateBounds]);
return bounds;
};
diff --git a/packages/lib/client-only/hooks/use-field-page-coords.ts b/packages/lib/client-only/hooks/use-field-page-coords.ts
index e212e7328..9d908ad9c 100644
--- a/packages/lib/client-only/hooks/use-field-page-coords.ts
+++ b/packages/lib/client-only/hooks/use-field-page-coords.ts
@@ -3,7 +3,10 @@ import { useCallback, useEffect, useState } from 'react';
import type { Field } from '@prisma/client';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
-import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
+import {
+ PDF_VIEWER_CONTENT_SELECTOR,
+ PDF_VIEWER_PAGE_SELECTOR,
+} from '@documenso/lib/constants/pdf-viewer';
export const useFieldPageCoords = (
field: Pick,
@@ -57,23 +60,65 @@ export const useFieldPageCoords = (
};
}, [calculateCoords]);
+ // Watch for the page element to appear in the DOM (e.g. after a virtual list
+ // scroll) and recalculate coords. Also attach a ResizeObserver once the page
+ // element exists.
useEffect(() => {
- const $page = document.querySelector(
- `${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`,
- );
+ const pageSelector = `${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`;
- if (!$page) {
- return;
+ let resizeObserver: ResizeObserver | null = null;
+ let observedElement: HTMLElement | null = null;
+
+ const attachResizeObserver = ($page: HTMLElement) => {
+ if ($page === observedElement) {
+ return;
+ }
+
+ resizeObserver?.disconnect();
+ resizeObserver = new ResizeObserver(() => {
+ calculateCoords();
+ });
+ resizeObserver.observe($page);
+ observedElement = $page;
+ };
+
+ // Try to attach immediately if the page already exists.
+ const existingPage = document.querySelector(pageSelector);
+
+ if (existingPage) {
+ attachResizeObserver(existingPage);
}
- const observer = new ResizeObserver(() => {
+ // Watch for DOM mutations to detect when the page element appears (e.g.
+ // after the virtual list scrolls to a new page and renders it).
+ // Scope to the PDF viewer content container to avoid firing on unrelated
+ // DOM changes elsewhere in the document.
+ const mutationObserver = new MutationObserver(() => {
+ const $page = document.querySelector(pageSelector);
+
+ if (!$page) {
+ return;
+ }
+
+ if ($page === observedElement) {
+ return;
+ }
+
calculateCoords();
+ attachResizeObserver($page);
});
- observer.observe($page);
+ const $container = document.querySelector(PDF_VIEWER_CONTENT_SELECTOR) ?? document.body;
+
+ mutationObserver.observe($container, {
+ childList: true,
+ subtree: true,
+ });
return () => {
- observer.disconnect();
+ mutationObserver.disconnect();
+ resizeObserver?.disconnect();
+ observedElement = null;
};
}, [calculateCoords, field.page]);
diff --git a/packages/lib/client-only/hooks/use-is-page-in-dom.ts b/packages/lib/client-only/hooks/use-is-page-in-dom.ts
new file mode 100644
index 000000000..f871cfce6
--- /dev/null
+++ b/packages/lib/client-only/hooks/use-is-page-in-dom.ts
@@ -0,0 +1,39 @@
+import { useEffect, useState } from 'react';
+
+import {
+ PDF_VIEWER_CONTENT_SELECTOR,
+ PDF_VIEWER_PAGE_SELECTOR,
+} from '@documenso/lib/constants/pdf-viewer';
+
+/**
+ * Returns whether the PDF page element for the given page number is currently
+ * present in the DOM. With virtual list rendering only pages near the viewport
+ * are mounted, so this hook lets consumers skip rendering when their page is
+ * virtualised away.
+ */
+export const useIsPageInDom = (pageNumber: number) => {
+ const [isPageInDom, setIsPageInDom] = useState(false);
+
+ useEffect(() => {
+ const selector = `${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${pageNumber}"]`;
+
+ setIsPageInDom(document.querySelector(selector) !== null);
+
+ const observer = new MutationObserver(() => {
+ setIsPageInDom(document.querySelector(selector) !== null);
+ });
+
+ const $container = document.querySelector(PDF_VIEWER_CONTENT_SELECTOR) ?? document.body;
+
+ observer.observe($container, {
+ childList: true,
+ subtree: true,
+ });
+
+ return () => {
+ observer.disconnect();
+ };
+ }, [pageNumber]);
+
+ return isPageInDom;
+};
diff --git a/packages/lib/client-only/hooks/use-page-renderer.ts b/packages/lib/client-only/hooks/use-page-renderer.ts
index de4054f86..f677b637b 100644
--- a/packages/lib/client-only/hooks/use-page-renderer.ts
+++ b/packages/lib/client-only/hooks/use-page-renderer.ts
@@ -1,135 +1,85 @@
-import { useEffect, useMemo, useRef, useState } from 'react';
+import { useEffect, useMemo, useRef } from 'react';
import Konva from 'konva';
-import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
-import { usePageContext } from 'react-pdf';
+
+import { 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 const usePageRenderer = (renderFunction: RenderFunction, pageData: PageRenderData) => {
+ const { pageWidth, pageHeight, scale, imageLoadingState } = 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);
-
/**
* 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],
);
- /**
- * Viewport with the device pixel ratio applied so we can render the PDF
- * in a higher resolution.
- */
- const renderViewport = useMemo(
- () => page.getViewport({ scale: scale * window.devicePixelRatio, rotation: rotate }),
- [page, rotate, scale],
- );
+ useEffect(() => {
+ const { current: container } = konvaContainer;
- /**
- * Render the PDF and create the scaled Konva stage.
- */
- useEffect(
- function drawPageOnCanvas() {
- if (!page) {
- return;
- }
+ if (!container || imageLoadingState !== 'loaded') {
+ return;
+ }
- const { current: canvas } = canvasElement;
- const { current: kContainer } = konvaContainer;
+ stage.current = new Konva.Stage({
+ container,
+ width: scaledViewport.width,
+ height: scaledViewport.height,
+ scale: {
+ x: scale,
+ y: scale,
+ },
+ });
- if (!canvas || !kContainer) {
- return;
- }
+ // Create the main layer for interactive elements.
+ pageLayer.current = new Konva.Layer();
- canvas.width = renderViewport.width;
- canvas.height = renderViewport.height;
+ stage.current.add(pageLayer.current);
- canvas.style.width = `${Math.floor(scaledViewport.width)}px`;
- canvas.style.height = `${Math.floor(scaledViewport.height)}px`;
+ renderFunction({
+ stage: stage.current,
+ pageLayer: pageLayer.current,
+ });
- const renderContext: RenderParameters = {
- canvas,
- // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
- canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
- viewport: renderViewport,
- };
+ void document.fonts.ready.then(function () {
+ pageLayer.current?.batchDraw();
+ });
- 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],
- );
+ return () => {
+ stage.current?.destroy();
+ stage.current = null;
+ };
+ }, [imageLoadingState, scaledViewport]);
return {
- canvasElement,
konvaContainer,
stage,
pageLayer,
unscaledViewport,
scaledViewport,
- pageContext,
- renderError,
- setRenderError,
};
-}
+};
diff --git a/packages/lib/client-only/providers/envelope-render-provider.tsx b/packages/lib/client-only/providers/envelope-render-provider.tsx
index d9fb64cb6..48fddc567 100644
--- a/packages/lib/client-only/providers/envelope-render-provider.tsx
+++ b/packages/lib/client-only/providers/envelope-render-provider.tsx
@@ -1,23 +1,26 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import React from 'react';
-import type { Field, Recipient } from '@prisma/client';
+import { type Field, type Recipient } from '@prisma/client';
+import type { DocumentDataVersion } from '@documenso/lib/types/document';
+import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download';
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
import { AVAILABLE_RECIPIENT_COLORS } from '@documenso/ui/lib/recipient-colors';
import type { TEnvelope } from '../../types/envelope';
import type { FieldRenderMode } from '../../universal/field-renderer/render-field';
-import { getEnvelopeItemPdfUrl } from '../../utils/envelope-download';
-type FileData =
- | {
- status: 'loading' | 'error';
- }
- | {
- file: Uint8Array;
- status: 'loaded';
- };
+export type PageRenderData = {
+ scale: number;
+ pageIndex: number;
+ pageNumber: number;
+ pageWidth: number;
+ pageHeight: number;
+ imageLoadingState: ImageLoadingState;
+};
+
+export type ImageLoadingState = 'loading' | 'loaded' | 'error';
type EnvelopeRenderOverrideSettings = {
mode?: FieldRenderMode;
@@ -25,10 +28,22 @@ type EnvelopeRenderOverrideSettings = {
showRecipientSigningStatus?: boolean;
};
-type EnvelopeRenderItem = TEnvelope['envelopeItems'][number];
+type EnvelopeRenderItem = {
+ id: string;
+ title: string;
+ order: number;
+ envelopeId: string;
+
+ /**
+ * The PDF data to render.
+ *
+ * If it's a string we assume it's a URL to the PDF file.
+ */
+ data: Uint8Array | string;
+};
type EnvelopeRenderProviderValue = {
- getPdfBuffer: (envelopeItemId: string) => FileData | null;
+ version: DocumentDataVersion;
envelopeItems: EnvelopeRenderItem[];
envelopeStatus: TEnvelope['status'];
envelopeType: TEnvelope['type'];
@@ -46,7 +61,26 @@ type EnvelopeRenderProviderValue = {
interface EnvelopeRenderProviderProps {
children: React.ReactNode;
- envelope: Pick;
+ /**
+ * The envelope item version to render.
+ */
+ version: DocumentDataVersion;
+
+ envelope: Pick;
+
+ /**
+ * The envelope items to render.
+ *
+ * If data is optional then we build the URL based of the IDs.
+ */
+ envelopeItems: {
+ id: string;
+ title: string;
+ order: number;
+ envelopeId: string;
+ documentDataId: string;
+ data?: Uint8Array | string;
+ }[];
/**
* Optional fields which are passed down to renderers for custom rendering needs.
@@ -70,6 +104,13 @@ interface EnvelopeRenderProviderProps {
*/
token: string | undefined;
+ /**
+ * The presign token to access the envelope.
+ *
+ * If not provided, it will be assumed that the current user can access the document.
+ */
+ presignToken?: string | undefined;
+
/**
* Custom override settings for generic page renderers.
*/
@@ -89,81 +130,51 @@ 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,
envelope,
+ envelopeItems: envelopeItemsFromProps,
fields,
token,
+ presignToken,
recipients = [],
+ version,
overrideSettings,
}: EnvelopeRenderProviderProps) => {
- // Indexed by documentDataId.
- const [files, setFiles] = useState>({});
-
- const [currentItem, setCurrentItem] = useState(null);
-
const [renderError, setRenderError] = useState(false);
const envelopeItems = useMemo(
- () => envelope.envelopeItems.sort((a, b) => a.order - b.order),
- [envelope.envelopeItems],
+ () =>
+ [...envelopeItemsFromProps]
+ .sort((a, b) => a.order - b.order)
+ .map((item) => {
+ const pdfUrl = getDocumentDataUrl({
+ envelopeId: envelope.id,
+ envelopeItemId: item.id,
+ documentDataId: item.documentDataId,
+ version,
+ token,
+ presignToken,
+ });
+
+ const data = item.data || pdfUrl;
+
+ return {
+ ...item,
+ data,
+ };
+ }),
+ [envelopeItemsFromProps, envelope.id, token, version, presignToken],
);
- const loadEnvelopeItemPdfFile = async (envelopeItem: EnvelopeRenderItem) => {
- if (files[envelopeItem.id]?.status === 'loading') {
- return;
- }
-
- if (!files[envelopeItem.id]) {
- setFiles((prev) => ({
- ...prev,
- [envelopeItem.id]: {
- status: 'loading',
- },
- }));
- }
-
- try {
- const downloadUrl = getEnvelopeItemPdfUrl({
- type: 'view',
- envelopeItem: envelopeItem,
- token,
- });
-
- const blob = await fetch(downloadUrl).then(async (res) => await res.blob());
-
- const file = await blob.arrayBuffer();
-
- setFiles((prev) => ({
- ...prev,
- [envelopeItem.id]: {
- file: new Uint8Array(file),
- status: 'loaded',
- },
- }));
- } catch (error) {
- console.error(error);
-
- setFiles((prev) => ({
- ...prev,
- [envelopeItem.id]: {
- status: 'error',
- },
- }));
- }
- };
-
- const getPdfBuffer = useCallback(
- (envelopeItemId: string) => {
- return files[envelopeItemId] || null;
- },
- [files],
+ const [currentItem, setCurrentItem] = useState(
+ envelopeItems[0] ?? null,
);
const setCurrentEnvelopeItem = (envelopeItemId: string) => {
- const foundItem = envelope.envelopeItems.find((item) => item.id === envelopeItemId);
+ const foundItem = envelopeItems.find((item) => item.id === envelopeItemId);
setCurrentItem(foundItem ?? null);
};
@@ -179,15 +190,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 +209,7 @@ export const EnvelopeRenderProvider = ({
return (
;
diff --git a/packages/lib/constants/pdf-viewer.ts b/packages/lib/constants/pdf-viewer.ts
index 54c9c5d5a..9c8c0e18b 100644
--- a/packages/lib/constants/pdf-viewer.ts
+++ b/packages/lib/constants/pdf-viewer.ts
@@ -1,2 +1,19 @@
-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';
+
+export const PDF_VIEWER_CONTENT_SELECTOR = '[data-pdf-content]';
+
+export const getPdfPagesCount = () => {
+ const pageCountAttr = document
+ .querySelector(PDF_VIEWER_CONTENT_SELECTOR)
+ ?.getAttribute('data-page-count');
+
+ const totalPages = Number(pageCountAttr);
+
+ if (!Number.isInteger(totalPages) || totalPages < 1) {
+ return 0;
+ }
+
+ return totalPages;
+};
diff --git a/packages/lib/jobs/definitions/internal/seal-document.handler.ts b/packages/lib/jobs/definitions/internal/seal-document.handler.ts
index 786de3a9c..5d44b7147 100644
--- a/packages/lib/jobs/definitions/internal/seal-document.handler.ts
+++ b/packages/lib/jobs/definitions/internal/seal-document.handler.ts
@@ -285,18 +285,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,
},
});
}
@@ -496,11 +491,14 @@ const decorateAndSignPdf = async ({
// Add suffix based on document status
const suffix = isRejected ? '_rejected.pdf' : '_signed.pdf';
- const newDocumentData = await putPdfFileServerSide({
- name: `${name}${suffix}`,
- type: 'application/pdf',
- arrayBuffer: async () => Promise.resolve(pdfBytes),
- });
+ const newDocumentData = await putPdfFileServerSide(
+ {
+ name: `${name}${suffix}`,
+ type: 'application/pdf',
+ arrayBuffer: async () => Promise.resolve(pdfBytes),
+ },
+ envelopeItem.documentData.initialData,
+ );
return {
oldDocumentDataId: envelopeItem.documentData.id,
diff --git a/packages/lib/package.json b/packages/lib/package.json
index 4421d71ce..a81488699 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/document-data/create-document-data.ts b/packages/lib/server-only/document-data/create-document-data.ts
index 9cf2d7979..62757abcb 100644
--- a/packages/lib/server-only/document-data/create-document-data.ts
+++ b/packages/lib/server-only/document-data/create-document-data.ts
@@ -5,14 +5,25 @@ import { prisma } from '@documenso/prisma';
export type CreateDocumentDataOptions = {
type: DocumentDataType;
data: string;
+
+ /**
+ * The initial data that was used to create the document data.
+ *
+ * If not provided, the current data will be used.
+ */
+ initialData?: string;
};
-export const createDocumentData = async ({ type, data }: CreateDocumentDataOptions) => {
+export const createDocumentData = async ({
+ type,
+ data,
+ initialData,
+}: CreateDocumentDataOptions) => {
return await prisma.documentData.create({
data: {
type,
data,
- initialData: data,
+ initialData: initialData || data,
},
});
};
diff --git a/packages/lib/server-only/document/get-document-by-token.ts b/packages/lib/server-only/document/get-document-by-token.ts
index 47b009a69..35793b9ef 100644
--- a/packages/lib/server-only/document/get-document-by-token.ts
+++ b/packages/lib/server-only/document/get-document-by-token.ts
@@ -96,6 +96,7 @@ export const getDocumentAndSenderByToken = async ({
title: true,
order: true,
envelopeId: true,
+ documentDataId: true,
documentData: true,
},
},
diff --git a/packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts b/packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts
index 6141b33c2..c0a3fd868 100644
--- a/packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts
+++ b/packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts
@@ -80,6 +80,7 @@ export const ZEnvelopeForSigningResponse = z.object({
id: true,
title: true,
order: true,
+ documentDataId: true,
}).array(),
team: TeamSchema.pick({
diff --git a/packages/lib/server-only/template/get-template-by-direct-link-token.ts b/packages/lib/server-only/template/get-template-by-direct-link-token.ts
index 6ede281f8..196ea3b9b 100644
--- a/packages/lib/server-only/template/get-template-by-direct-link-token.ts
+++ b/packages/lib/server-only/template/get-template-by-direct-link-token.ts
@@ -90,6 +90,7 @@ export const getTemplateByDirectLinkToken = async ({
envelopeItems: envelope.envelopeItems.map((item) => ({
id: item.id,
envelopeId: item.envelopeId,
+ documentDataId: item.documentDataId,
})),
};
};
diff --git a/packages/lib/types/document.ts b/packages/lib/types/document.ts
index 92daff749..125cae519 100644
--- a/packages/lib/types/document.ts
+++ b/packages/lib/types/document.ts
@@ -181,3 +181,5 @@ export const ZDocumentManySchema = LegacyDocumentSchema.pick({
});
export type TDocumentMany = z.infer;
+
+export type DocumentDataVersion = 'initial' | 'current';
diff --git a/packages/lib/types/envelope.ts b/packages/lib/types/envelope.ts
index f3ea46f15..806ee9773 100644
--- a/packages/lib/types/envelope.ts
+++ b/packages/lib/types/envelope.ts
@@ -61,6 +61,7 @@ export const ZEnvelopeSchema = EnvelopeSchema.pick({
fields: ZEnvelopeFieldSchema.array(),
envelopeItems: EnvelopeItemSchema.pick({
envelopeId: true,
+ documentDataId: true,
id: true,
title: true,
order: true,
diff --git a/packages/lib/universal/upload/put-file.server.ts b/packages/lib/universal/upload/put-file.server.ts
index 5d7e0fe26..f9950d3f8 100644
--- a/packages/lib/universal/upload/put-file.server.ts
+++ b/packages/lib/universal/upload/put-file.server.ts
@@ -20,7 +20,7 @@ type File = {
* Uploads a document file to the appropriate storage location and creates
* a document data record.
*/
-export const putPdfFileServerSide = async (file: File) => {
+export const putPdfFileServerSide = async (file: File, initialData?: string) => {
const isEncryptedDocumentsAllowed = false; // Was feature flag.
const arrayBuffer = await file.arrayBuffer();
@@ -41,7 +41,7 @@ export const putPdfFileServerSide = async (file: File) => {
const { type, data } = await putFileServerSide(file);
- return await createDocumentData({ type, data });
+ return await createDocumentData({ type, data, initialData });
};
/**
diff --git a/packages/lib/utils/envelope-download.ts b/packages/lib/utils/envelope-download.ts
index 82e1dd2cd..949ebbc82 100644
--- a/packages/lib/utils/envelope-download.ts
+++ b/packages/lib/utils/envelope-download.ts
@@ -1,5 +1,7 @@
import type { EnvelopeItem } from '@prisma/client';
+import type { DocumentDataVersion } from '@documenso/lib/types/document';
+
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
export type EnvelopeItemPdfUrlOptions =
@@ -34,3 +36,39 @@ export const getEnvelopeItemPdfUrl = (options: EnvelopeItemPdfUrlOptions) => {
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/token/${token}/envelopeItem/${id}${presignToken ? `?presignToken=${presignToken}` : ''}`
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/envelope/${envelopeId}/envelopeItem/${id}${presignToken ? `?token=${presignToken}` : ''}`;
};
+
+export type DocumentDataUrlOptions = {
+ envelopeId: string;
+ envelopeItemId: string;
+ documentDataId: string;
+ token: string | undefined;
+ presignToken?: string | undefined;
+ version: DocumentDataVersion;
+};
+
+/**
+ * The difference between this and `getEnvelopeItemPdfUrl` is that this will
+ * hard cache since we add the `documentDataId` to the URL.
+ *
+ * Since `documentDataId` should change when the document is changed/signed, this is a
+ * good way to cache an envelope item by.
+ */
+export const getDocumentDataUrl = (options: DocumentDataUrlOptions) => {
+ const { envelopeId, envelopeItemId, documentDataId, token, presignToken, version } = options;
+
+ const partialUrl = `envelope/${envelopeId}/envelopeItem/${envelopeItemId}/dataId/${documentDataId}/${version}/item.pdf`;
+
+ // 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;
+};
diff --git a/packages/lib/utils/fields.ts b/packages/lib/utils/fields.ts
index ec26192eb..fe2e1bf6e 100644
--- a/packages/lib/utils/fields.ts
+++ b/packages/lib/utils/fields.ts
@@ -2,6 +2,8 @@ import type { I18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { type Envelope, type Field, FieldType } from '@prisma/client';
+import { PDF_VIEWER_CONTENT_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
+
import { extractLegacyIds } from '../universal/id';
/**
@@ -23,25 +25,44 @@ export const sortFieldsByPosition = (fields: Field[]): Field[] => {
*/
export const validateFieldsInserted = (fields: Field[]): boolean => {
const fieldCardElements = document.getElementsByClassName('field-card-container');
+ const pdfContent = document.querySelector(PDF_VIEWER_CONTENT_SELECTOR);
- // Attach validate attribute on all fields.
+ const uninsertedFields = sortFieldsByPosition(fields.filter((field) => !field.inserted));
+
+ // All fields are inserted — clear the validation signal.
+ if (uninsertedFields.length === 0) {
+ pdfContent?.removeAttribute('data-validate-fields');
+ return true;
+ }
+
+ // Attach validate attribute on all fields currently in the DOM.
Array.from(fieldCardElements).forEach((element) => {
element.setAttribute('data-validate', 'true');
});
- const uninsertedFields = sortFieldsByPosition(fields.filter((field) => !field.inserted));
+ // Also set a signal on the PDF viewer container so that field elements that
+ // mount later (e.g. after the virtual list scrolls to a new page) can pick
+ // up the validation state.
+ pdfContent?.setAttribute('data-validate-fields', 'true');
const firstUninsertedField = uninsertedFields[0];
- const firstUninsertedFieldElement =
- firstUninsertedField && document.getElementById(`field-${firstUninsertedField.id}`);
+ if (firstUninsertedField) {
+ // Try direct element scroll first (works if the field's page is currently rendered).
+ const firstUninsertedFieldElement = document.getElementById(`field-${firstUninsertedField.id}`);
- if (firstUninsertedFieldElement) {
- firstUninsertedFieldElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
- return false;
+ if (firstUninsertedFieldElement) {
+ firstUninsertedFieldElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ } else {
+ // Field not in DOM (page virtualized away) — signal the PDF viewer to
+ // scroll to the correct page via the data attribute.
+ if (pdfContent) {
+ pdfContent.setAttribute('data-scroll-to-page', String(firstUninsertedField.page));
+ }
+ }
}
- return uninsertedFields.length === 0;
+ return false;
};
export const validateFieldsUninserted = (): boolean => {
diff --git a/packages/lib/utils/templates.ts b/packages/lib/utils/templates.ts
index 5e22474e2..7e69d407b 100644
--- a/packages/lib/utils/templates.ts
+++ b/packages/lib/utils/templates.ts
@@ -51,7 +51,7 @@ export const mapEnvelopeToTemplateLite = (envelope: Envelope): TTemplateLite =>
return {
id: legacyTemplateId,
- envelopeId: envelope.secondaryId,
+ envelopeId: envelope.id,
type: envelope.templateType,
visibility: envelope.visibility,
externalId: envelope.externalId,
diff --git a/packages/trpc/server/envelope-router/create-envelope-items.types.ts b/packages/trpc/server/envelope-router/create-envelope-items.types.ts
index 4bcfa7f90..9bd42560c 100644
--- a/packages/trpc/server/envelope-router/create-envelope-items.types.ts
+++ b/packages/trpc/server/envelope-router/create-envelope-items.types.ts
@@ -33,6 +33,7 @@ export const ZCreateEnvelopeItemsResponseSchema = z.object({
title: true,
envelopeId: true,
order: true,
+ documentDataId: true,
}).array(),
});
diff --git a/packages/ui/components/document/document-read-only-fields.tsx b/packages/ui/components/document/document-read-only-fields.tsx
index 5d3cb2539..dde70f32a 100644
--- a/packages/ui/components/document/document-read-only-fields.tsx
+++ b/packages/ui/components/document/document-read-only-fields.tsx
@@ -98,10 +98,8 @@ export const DocumentReadOnlyFields = ({
setHiddenFieldIds((prev) => ({ ...prev, [fieldId]: true }));
};
- const highestPageNumber = Math.max(...fields.map((field) => field.page));
-
return (
-
+
{fields.map(
(field) =>
!hiddenFieldIds[field.secondaryId] && (
@@ -163,7 +161,7 @@ export const DocumentReadOnlyFields = ({
-
+
{getRecipientDisplayText(field.recipient)}
diff --git a/packages/ui/components/field/field.tsx b/packages/ui/components/field/field.tsx
index 2886230b3..963a96fd3 100644
--- a/packages/ui/components/field/field.tsx
+++ b/packages/ui/components/field/field.tsx
@@ -5,7 +5,11 @@ import { createPortal } from 'react-dom';
import { useElementBounds } from '@documenso/lib/client-only/hooks/use-element-bounds';
import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords';
-import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
+import { useIsPageInDom } from '@documenso/lib/client-only/hooks/use-is-page-in-dom';
+import {
+ PDF_VIEWER_CONTENT_SELECTOR,
+ PDF_VIEWER_PAGE_SELECTOR,
+} from '@documenso/lib/constants/pdf-viewer';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import type { RecipientColorStyles } from '../../lib/recipient-colors';
@@ -81,6 +85,8 @@ export function FieldRootContainer({
readonly,
}: FieldRootContainerProps) {
const [isValidating, setIsValidating] = useState(false);
+ const isPageInDom = useIsPageInDom(field.page);
+
const ref = React.useRef
(null);
useEffect(() => {
@@ -88,6 +94,21 @@ export function FieldRootContainer({
return;
}
+ // Check the validation signal on the PDF viewer container. When a field
+ // mounts after the virtual list scrolls to its page, the per-element
+ // `data-validate` attribute will not have been set yet. The signal on the
+ // `[data-pdf-content]` container bridges this gap so newly-rendered fields
+ // pick up the validation state immediately.
+ const pdfContent = document.querySelector(PDF_VIEWER_CONTENT_SELECTOR);
+
+ if (
+ pdfContent?.getAttribute('data-validate-fields') === 'true' &&
+ isFieldUnsignedAndRequired(field)
+ ) {
+ ref.current.setAttribute('data-validate', 'true');
+ setIsValidating(true);
+ }
+
const observer = new MutationObserver((_mutations) => {
if (ref.current) {
setIsValidating(ref.current.getAttribute('data-validate') === 'true');
@@ -101,7 +122,11 @@ export function FieldRootContainer({
return () => {
observer.disconnect();
};
- }, []);
+ }, [isPageInDom]);
+
+ if (!isPageInDom) {
+ return null;
+ }
return (
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 5cf34870d..000000000
--- a/packages/ui/components/pdf-viewer/pdf-viewer-konva-lazy.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import React, { Suspense, lazy } from 'react';
-
-import { Trans } from '@lingui/react/macro';
-import { type PDFDocumentProxy } from 'pdfjs-dist';
-
-import type { PdfViewerRendererMode } from './pdf-viewer-konva';
-
-export type LoadedPDFDocument = PDFDocumentProxy;
-
-export type PDFViewerProps = {
- className?: string;
- onDocumentLoad?: () => void;
- renderer: PdfViewerRendererMode;
- [key: string]: unknown;
-} & Omit, '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/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/document-flow/add-fields.tsx b/packages/ui/primitives/document-flow/add-fields.tsx
index 80c0e864b..9cbd9ac6f 100644
--- a/packages/ui/primitives/document-flow/add-fields.tsx
+++ b/packages/ui/primitives/document-flow/add-fields.tsx
@@ -23,7 +23,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
-import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
+import { PDF_VIEWER_PAGE_SELECTOR, getPdfPagesCount } from '@documenso/lib/constants/pdf-viewer';
import {
type TFieldMetaSchema as FieldMeta,
ZFieldMetaSchema,
@@ -431,13 +431,15 @@ export const AddFieldsFormPartial = ({
}
if (duplicateAll) {
- const pages = Array.from(document.querySelectorAll(PDF_VIEWER_PAGE_SELECTOR));
+ const totalPages = getPdfPagesCount();
- pages.forEach((_, index) => {
- const pageNumber = index + 1;
+ if (totalPages < 1) {
+ return;
+ }
+ for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) {
if (pageNumber === lastActiveField.pageNumber) {
- return;
+ continue;
}
const newField: TAddFieldsFormSchema['fields'][0] = {
@@ -450,7 +452,7 @@ export const AddFieldsFormPartial = ({
};
append(newField);
- });
+ }
return;
}
diff --git a/packages/ui/primitives/document-flow/field-item.tsx b/packages/ui/primitives/document-flow/field-item.tsx
index 0cb271ae9..1f3156b38 100644
--- a/packages/ui/primitives/document-flow/field-item.tsx
+++ b/packages/ui/primitives/document-flow/field-item.tsx
@@ -10,6 +10,7 @@ import { Rnd } from 'react-rnd';
import { useSearchParams } from 'react-router';
import { useElementBounds } from '@documenso/lib/client-only/hooks/use-element-bounds';
+import { useIsPageInDom } from '@documenso/lib/client-only/hooks/use-is-page-in-dom';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import type { TFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { ZCheckboxFieldMeta, ZRadioFieldMeta } from '@documenso/lib/types/field-meta';
@@ -50,7 +51,17 @@ export type FieldItemProps = {
/**
* The item when editing fields??
*/
-export const FieldItem = ({
+export const FieldItem = (props: FieldItemProps) => {
+ const isPageInDom = useIsPageInDom(props.field.pageNumber);
+
+ if (!isPageInDom) {
+ return null;
+ }
+
+ return ;
+};
+
+const FieldItemInner = ({
fieldClassName,
field,
passive,
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 7f1bdc1ee..000000000
--- a/packages/ui/primitives/pdf-viewer/lazy.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import { ClientOnly } from '../../components/client-only';
-
-import { Trans } from '@lingui/react/macro';
-
-import { PDFViewer, type PDFViewerProps } from './base.client';
-
-export const PDFViewerLazy = (props: PDFViewerProps) => {
- return (
-
- Loading...
-
- }
- >
- {() => }
-
- );
-};
diff --git a/packages/ui/primitives/template-flow/add-template-fields.tsx b/packages/ui/primitives/template-flow/add-template-fields.tsx
index 3a3217fe7..cf5a05b50 100644
--- a/packages/ui/primitives/template-flow/add-template-fields.tsx
+++ b/packages/ui/primitives/template-flow/add-template-fields.tsx
@@ -24,7 +24,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
-import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
+import { PDF_VIEWER_PAGE_SELECTOR, getPdfPagesCount } from '@documenso/lib/constants/pdf-viewer';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
import {
@@ -188,13 +188,15 @@ export const AddTemplateFieldsFormPartial = ({
}
if (duplicateAll) {
- const pages = Array.from(document.querySelectorAll(PDF_VIEWER_PAGE_SELECTOR));
+ const totalPages = getPdfPagesCount();
- pages.forEach((_, index) => {
- const pageNumber = index + 1;
+ if (totalPages < 1) {
+ return;
+ }
+ for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) {
if (pageNumber === lastActiveField.pageNumber) {
- return;
+ continue;
}
const newField: TAddTemplateFieldsFormSchema['fields'][0] = {
@@ -208,7 +210,7 @@ export const AddTemplateFieldsFormPartial = ({
};
append(newField);
- });
+ }
void handleAutoSave();
return;