diff --git a/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx b/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx index 4ca68ea77..9b7c82404 100644 --- a/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx @@ -13,7 +13,6 @@ import { DialogHeader, DialogTitle, } from '@documenso/ui/primitives/dialog'; -import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { useCurrentTeam } from '~/providers/team'; @@ -38,19 +37,6 @@ export const DocumentDuplicateDialog = ({ const team = useCurrentTeam(); - const { data: envelopeItemsPayload, isLoading: isLoadingEnvelopeItems } = - trpcReact.envelope.item.getManyByToken.useQuery( - { - envelopeId: id, - access: token ? { type: 'recipient', token } : { type: 'user' }, - }, - { - enabled: open, - }, - ); - - const envelopeItems = envelopeItemsPayload?.data || []; - const documentsPath = formatDocumentsPath(team.url); const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } = @@ -88,22 +74,6 @@ export const DocumentDuplicateDialog = ({ Duplicate - {isLoadingEnvelopeItems || !envelopeItems || envelopeItems.length === 0 ? ( -
-

- Loading Document... -

-
- ) : ( -
- -
- )}
diff --git a/apps/remix/app/components/embed/authoring/configure-fields-view.tsx b/apps/remix/app/components/embed/authoring/configure-fields-view.tsx index 0a8b43e58..1a4bee33c 100644 --- a/apps/remix/app/components/embed/authoring/configure-fields-view.tsx +++ b/apps/remix/app/components/embed/authoring/configure-fields-view.tsx @@ -5,17 +5,17 @@ import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import type { EnvelopeItem, FieldType } from '@prisma/client'; import { ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client'; -import { base64 } from '@scure/base'; import { ChevronsUpDown } from 'lucide-react'; import { useFieldArray, useForm } from 'react-hook-form'; import { useHotkeys } from 'react-hotkeys-hook'; import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect'; 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, ZFieldMetaSchema } from '@documenso/lib/types/field-meta'; import { nanoid } from '@documenso/lib/universal/id'; import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers'; +import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download'; import { useRecipientColors } from '@documenso/ui/lib/recipient-colors'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; @@ -24,14 +24,15 @@ import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/type import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { FieldSelector } from '@documenso/ui/primitives/field-selector'; import { Form } from '@documenso/ui/primitives/form/form'; -import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy'; import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector'; import { Sheet, SheetContent, SheetTrigger } from '@documenso/ui/primitives/sheet'; import { useToast } from '@documenso/ui/primitives/use-toast'; +import { FieldAdvancedSettingsDrawer } from '~/components/embed/authoring/field-advanced-settings-drawer'; +import { PDFViewer } from '~/components/general/pdf-viewer/pdf-viewer'; + import type { TConfigureEmbedFormSchema } from './configure-document-view.types'; import type { TConfigureFieldsFormSchema } from './configure-fields-view.types'; -import { FieldAdvancedSettingsDrawer } from './field-advanced-settings-drawer'; const MIN_HEIGHT_PX = 12; const MIN_WIDTH_PX = 36; @@ -42,7 +43,7 @@ const DEFAULT_WIDTH_PX = MIN_WIDTH_PX * 2.5; export type ConfigureFieldsViewProps = { configData: TConfigureEmbedFormSchema; presignToken?: string | undefined; - envelopeItem?: Pick; + envelopeItem?: Pick; defaultValues?: Partial; onBack?: (data: TConfigureFieldsFormSchema) => void; onSubmit: (data: TConfigureFieldsFormSchema) => void; @@ -86,23 +87,22 @@ export const ConfigureFieldsView = ({ const normalizedDocumentData = useMemo(() => { if (envelopeItem) { - return undefined; + return getDocumentDataUrl({ + envelopeId: envelopeItem.envelopeId, + envelopeItemId: envelopeItem.id, + documentDataId: envelopeItem.documentDataId, + version: 'current', + token: undefined, + presignToken, + }); } if (!configData.documentData) { return undefined; } - return base64.encode(configData.documentData.data); - }, [configData.documentData]); - - const normalizedEnvelopeItem = useMemo(() => { - if (envelopeItem) { - return envelopeItem; - } - - return { id: '', envelopeId: '' }; - }, [envelopeItem]); + return configData.documentData.data; + }, [configData.documentData, envelopeItem, presignToken]); const recipients = useMemo(() => { return configData.signers.map((signer, index) => ({ @@ -179,8 +179,6 @@ export const ConfigureFieldsView = ({ name: 'fields', }); - const highestPageNumber = Math.max(...localFields.map((field) => field.pageNumber)); - const onFieldCopy = useCallback( (event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => { const { duplicate = false, duplicateAll = false } = options ?? {}; @@ -205,13 +203,15 @@ export const ConfigureFieldsView = ({ } 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: TConfigureFieldsFormSchema['fields'][0] = { @@ -224,7 +224,7 @@ export const ConfigureFieldsView = ({ }; append(newField); - }); + } return; } @@ -548,17 +548,11 @@ export const ConfigureFieldsView = ({
- + {normalizedDocumentData && ( + + )} - + {localFields.map((field, index) => { const recipientIndex = recipients.findIndex((r) => r.id === field.recipientId); diff --git a/apps/remix/app/components/embed/embed-direct-template-client-page.tsx b/apps/remix/app/components/embed/embed-direct-template-client-page.tsx index fc143e163..6b02a3b03 100644 --- a/apps/remix/app/components/embed/embed-direct-template-client-page.tsx +++ b/apps/remix/app/components/embed/embed-direct-template-client-page.tsx @@ -23,7 +23,8 @@ import { isFieldUnsignedAndRequired, isRequiredField, } from '@documenso/lib/utils/advanced-fields-helpers'; -import { validateFieldsInserted } from '@documenso/lib/utils/fields'; +import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download'; +import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields'; import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field'; import { trpc } from '@documenso/trpc/react'; import type { @@ -35,11 +36,11 @@ import { Button } from '@documenso/ui/primitives/button'; import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { Input } from '@documenso/ui/primitives/input'; import { Label } from '@documenso/ui/primitives/label'; -import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy'; import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { BrandingLogo } from '~/components/general/branding-logo'; +import { PDFViewer } from '~/components/general/pdf-viewer/pdf-viewer'; import { ZDirectTemplateEmbedDataSchema } from '~/types/embed-direct-template-schema'; import { injectCss } from '~/utils/css-vars'; @@ -54,7 +55,7 @@ export type EmbedDirectTemplateClientPageProps = { token: string; envelopeId: string; updatedAt: Date; - envelopeItems: Pick[]; + envelopeItems: Pick[]; recipient: Recipient; fields: Field[]; metadata?: DocumentMeta | null; @@ -97,12 +98,10 @@ export const EmbedDirectTemplateClientPage = ({ const [localFields, setLocalFields] = useState(() => fields); const [pendingFields, _completedFields] = [ - localFields.filter((field) => isFieldUnsignedAndRequired(field)), + sortFieldsByPosition(localFields.filter((field) => isFieldUnsignedAndRequired(field))), localFields.filter((field) => field.inserted), ]; - const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page)); - const hasSignatureField = localFields.some((field) => isSignatureFieldType(field.type)); const signatureValid = !hasSignatureField || (signature && signature.trim() !== ''); @@ -341,10 +340,16 @@ export const EmbedDirectTemplateClientPage = ({
{/* Viewer */}
- setHasDocumentLoaded(true)} />
@@ -478,15 +483,15 @@ export const EmbedDirectTemplateClientPage = ({
- - {showPendingFieldTooltip && pendingFields.length > 0 && ( + {showPendingFieldTooltip && pendingFields.length > 0 && ( + Click to insert field - )} - + + )} {/* Fields */} { - const highestPageNumber = Math.max(...fields.map((field) => field.page)); - return ( - + {fields.map((field) => match(field.type) .with(FieldType.SIGNATURE, () => ( diff --git a/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx b/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx index ecb642d49..aff4447aa 100644 --- a/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx +++ b/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx @@ -10,7 +10,8 @@ import { LucideChevronDown, LucideChevronUp } from 'lucide-react'; import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers'; -import { validateFieldsInserted } from '@documenso/lib/utils/fields'; +import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download'; +import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields'; import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field'; import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields'; import { trpc } from '@documenso/trpc/react'; @@ -23,12 +24,12 @@ import { Button } from '@documenso/ui/primitives/button'; import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { Input } from '@documenso/ui/primitives/input'; import { Label } from '@documenso/ui/primitives/label'; -import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy'; import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group'; import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { BrandingLogo } from '~/components/general/branding-logo'; +import { PDFViewer } from '~/components/general/pdf-viewer/pdf-viewer'; import { injectCss } from '~/utils/css-vars'; import { ZSignDocumentEmbedDataSchema } from '../../types/embed-document-sign-schema'; @@ -45,7 +46,7 @@ export type EmbedSignDocumentV1ClientPageProps = { token: string; documentId: number; envelopeId: string; - envelopeItems: Pick[]; + envelopeItems: (Pick & { documentData: { id: string } })[]; recipient: RecipientWithFields; fields: Field[]; completedFields: DocumentField[]; @@ -100,14 +101,14 @@ export const EmbedSignDocumentV1ClientPage = ({ const [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500); const [pendingFields, _completedFields] = [ - fields.filter( - (field) => field.recipientId === recipient.id && isFieldUnsignedAndRequired(field), + sortFieldsByPosition( + fields.filter( + (field) => field.recipientId === recipient.id && isFieldUnsignedAndRequired(field), + ), ), fields.filter((field) => field.inserted), ]; - const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page)); - const { mutateAsync: completeDocumentWithToken, isPending: isSubmitting } = trpc.recipient.completeDocumentWithToken.useMutation(); @@ -287,10 +288,16 @@ export const EmbedSignDocumentV1ClientPage = ({
{/* Viewer */}
- setHasDocumentLoaded(true)} />
@@ -491,15 +498,15 @@ export const EmbedSignDocumentV1ClientPage = ({
- - {showPendingFieldTooltip && pendingFields.length > 0 && ( + {showPendingFieldTooltip && pendingFields.length > 0 && ( + Click to insert field - )} - + + )} {/* Fields */} diff --git a/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx b/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx index 473722390..992ce9821 100644 --- a/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx +++ b/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx @@ -9,6 +9,8 @@ import { P, match } from 'ts-pattern'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download'; +import { sortFieldsByPosition } from '@documenso/lib/utils/fields'; import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field'; import { trpc } from '@documenso/trpc/react'; import type { @@ -22,10 +24,11 @@ import { Button } from '@documenso/ui/primitives/button'; import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { Input } from '@documenso/ui/primitives/input'; import { Label } from '@documenso/ui/primitives/label'; -import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy'; import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog'; import { useToast } from '@documenso/ui/primitives/use-toast'; +import { PDFViewer } from '~/components/general/pdf-viewer/pdf-viewer'; + import { useRequiredDocumentSigningContext } from '../../general/document-signing/document-signing-provider'; import { DocumentSigningRejectDialog } from '../../general/document-signing/document-signing-reject-dialog'; import { EmbedDocumentFields } from '../embed-document-fields'; @@ -87,14 +90,14 @@ export const MultiSignDocumentSigningView = ({ const hasSignatureField = document?.fields.some((field) => isSignatureFieldType(field.type)); const [pendingFields, completedFields] = [ - document?.fields.filter((field) => field.recipient.signingStatus !== SigningStatus.SIGNED) ?? - [], + sortFieldsByPosition( + document?.fields.filter((field) => field.recipient.signingStatus !== SigningStatus.SIGNED) ?? + [], + ), document?.fields.filter((field) => field.recipient.signingStatus === SigningStatus.SIGNED) ?? [], ]; - const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page)); - const uninsertedFields = document?.fields.filter((field) => !field.inserted) ?? []; const onSignField = async (payload: TSignFieldWithTokenMutationSchema) => { @@ -226,10 +229,16 @@ export const MultiSignDocumentSigningView = ({ 'md:mx-auto md:max-w-2xl': document.status === DocumentStatus.COMPLETED, })} > - { setHasDocumentLoaded(true); onDocumentReady?.(); @@ -362,19 +371,13 @@ export const MultiSignDocumentSigningView = ({ )} - {hasDocumentLoaded && ( + {hasDocumentLoaded && showPendingFieldTooltip && pendingFields.length > 0 && ( - {showPendingFieldTooltip && pendingFields.length > 0 && ( - - Click to insert field - - )} + + Click to insert field + )} diff --git a/apps/remix/app/components/general/direct-template/direct-template-page.tsx b/apps/remix/app/components/general/direct-template/direct-template-page.tsx index 2b668ab86..7bd25376c 100644 --- a/apps/remix/app/components/general/direct-template/direct-template-page.tsx +++ b/apps/remix/app/components/general/direct-template/direct-template-page.tsx @@ -9,16 +9,17 @@ import { useNavigate, useSearchParams } from 'react-router'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import type { TTemplate } from '@documenso/lib/types/template'; import { isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers'; +import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download'; import { trpc } from '@documenso/trpc/react'; 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 { useToast } from '@documenso/ui/primitives/use-toast'; import { useRequiredDocumentSigningAuthContext } from '~/components/general/document-signing/document-signing-auth-provider'; import { useRequiredDocumentSigningContext } from '~/components/general/document-signing/document-signing-provider'; +import { PDFViewer } from '~/components/general/pdf-viewer/pdf-viewer'; import { DirectTemplateConfigureForm, @@ -151,11 +152,17 @@ export const DirectTemplatePageView = ({ gradient > - setIsDocumentPdfLoaded(true)} /> diff --git a/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx b/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx index 69d1ae8b1..ddf2fcbe1 100644 --- a/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx +++ b/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx @@ -82,8 +82,6 @@ export const DirectTemplateSigningForm = ({ const [validateUninsertedFields, setValidateUninsertedFields] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); - const highestPageNumber = Math.max(...localFields.map((field) => field.page)); - const fieldsRequiringValidation = useMemo(() => { return localFields.filter((field) => isFieldUnsignedAndRequired(field)); }, [localFields]); @@ -250,9 +248,7 @@ export const DirectTemplateSigningForm = ({ - + {validateUninsertedFields && uninsertedFields[0] && ( Click to insert field diff --git a/apps/remix/app/components/general/document-signing/document-signing-field-container.tsx b/apps/remix/app/components/general/document-signing/document-signing-field-container.tsx index 7c67ae559..9f823bec2 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-field-container.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-field-container.tsx @@ -130,69 +130,67 @@ export const DocumentSigningFieldContainer = ({ }; return ( -
- - {!field.inserted && !loading && !readOnlyField && ( - + )} + + {type !== 'Checkbox' && field.inserted && !loading && !readOnlyField && ( + + + + + + - - - - + {tooltipText &&

{tooltipText}

} + + Remove + +
+
+ )} + + {(field.type === FieldType.RADIO || field.type === FieldType.CHECKBOX) && + field.fieldMeta?.label && ( +
+ {field.fieldMeta.label} +
)} - {type !== 'Checkbox' && field.inserted && !loading && !readOnlyField && ( - - - - - - - {tooltipText &&

{tooltipText}

} - - Remove - -
-
- )} - - {(field.type === FieldType.RADIO || field.type === FieldType.CHECKBOX) && - field.fieldMeta?.label && ( -
- {field.fieldMeta.label} -
- )} - - {children} -
-
+ {children} + ); }; diff --git a/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx b/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx index 33e05b749..493dc2e5c 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx @@ -22,6 +22,7 @@ import { } from '@documenso/lib/types/field-meta'; import type { CompletedField } from '@documenso/lib/types/fields'; import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers'; +import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download'; import { validateFieldsInserted } from '@documenso/lib/utils/fields'; import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta'; import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields'; @@ -30,7 +31,6 @@ import { DocumentReadOnlyFields } from '@documenso/ui/components/document/docume import { Button } from '@documenso/ui/primitives/button'; import { Card, CardContent } from '@documenso/ui/primitives/card'; import { ElementVisible } from '@documenso/ui/primitives/element-visible'; -import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy'; import { DocumentSigningAttachmentsPopover } from '~/components/general/document-signing/document-signing-attachments-popover'; import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign'; @@ -46,6 +46,7 @@ import { DocumentSigningRadioField } from '~/components/general/document-signing import { DocumentSigningRejectDialog } from '~/components/general/document-signing/document-signing-reject-dialog'; import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field'; import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field'; +import { PDFViewer } from '~/components/general/pdf-viewer/pdf-viewer'; import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog'; @@ -162,8 +163,6 @@ export const DocumentSigningPageViewV1 = ({ : undefined; }, [document.documentMeta?.signingOrder, allRecipients, recipient.id]); - const highestPageNumber = Math.max(...fields.map((field) => field.page)); - const pendingFields = fieldsRequiringValidation.filter((field) => !field.inserted); const hasPendingFields = pendingFields.length > 0; @@ -274,11 +273,17 @@ export const DocumentSigningPageViewV1 = ({
- @@ -400,9 +405,7 @@ export const DocumentSigningPageViewV1 = ({ )} - + {fields .filter( (field) => diff --git a/apps/remix/app/components/general/document-signing/document-signing-page-view-v2.tsx b/apps/remix/app/components/general/document-signing/document-signing-page-view-v2.tsx index d4dbb72bd..57a37bd0c 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-page-view-v2.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-page-view-v2.tsx @@ -1,4 +1,4 @@ -import { lazy, useMemo } from 'react'; +import { useMemo, useRef } from 'react'; import { Plural, Trans } from '@lingui/react/macro'; import { EnvelopeType, RecipientRole } from '@prisma/client'; @@ -8,8 +8,8 @@ import { Link } from 'react-router'; import { match } from 'ts-pattern'; import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider'; +import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n'; import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope'; -import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy'; import { Button } from '@documenso/ui/primitives/button'; import { Separator } from '@documenso/ui/primitives/separator'; @@ -23,6 +23,8 @@ import { SignFieldNumberDialog } from '~/components/dialogs/sign-field-number-di import { SignFieldSignatureDialog } from '~/components/dialogs/sign-field-signature-dialog'; import { SignFieldTextDialog } from '~/components/dialogs/sign-field-text-dialog'; import { useEmbedSigningContext } from '~/components/embed/embed-signing-context'; +import { EnvelopeSignerPageRenderer } from '~/components/general/envelope-signing/envelope-signer-page-renderer'; +import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer'; import { BrandingLogo } from '../branding-logo'; import { DocumentSigningAttachmentsPopover } from '../document-signing/document-signing-attachments-popover'; @@ -33,13 +35,11 @@ import { DocumentSigningMobileWidget } from './document-signing-mobile-widget'; import { DocumentSigningRejectDialog } from './document-signing-reject-dialog'; import { useRequiredEnvelopeSigningContext } from './envelope-signing-provider'; -const EnvelopeSignerPageRenderer = lazy( - async () => import('~/components/general/envelope-signing/envelope-signer-page-renderer'), -); - export const DocumentSigningPageViewV2 = () => { const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender(); + const scrollableContainerRef = useRef(null); + const { isDirectTemplate, envelope, @@ -199,7 +199,10 @@ export const DocumentSigningPageViewV2 = () => {
-
+
{/* Horizontal envelope item selector */} {envelopeItems.length > 1 && ( @@ -228,15 +231,16 @@ export const DocumentSigningPageViewV2 = () => { {/* Document View */}
{currentEnvelopeItem ? ( - ) : (

- No documents found + No document selected

)} diff --git a/apps/remix/app/components/general/document/document-certificate-qr-view.tsx b/apps/remix/app/components/general/document/document-certificate-qr-view.tsx index a01e315cf..a874c61ed 100644 --- a/apps/remix/app/components/general/document/document-certificate-qr-view.tsx +++ b/apps/remix/app/components/general/document/document-certificate-qr-view.tsx @@ -1,4 +1,4 @@ -import { lazy, useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Trans } from '@lingui/react/macro'; import { type DocumentData, DocumentStatus, type EnvelopeItem, EnvelopeType } from '@prisma/client'; @@ -9,9 +9,10 @@ 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 { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { trpc } from '@documenso/trpc/react'; -import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy'; import { Button } from '@documenso/ui/primitives/button'; import { Dialog, @@ -21,15 +22,13 @@ import { DialogHeader, DialogTitle, } from '@documenso/ui/primitives/dialog'; -import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy'; import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog'; +import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer'; +import { PDFViewer } from '~/components/general/pdf-viewer/pdf-viewer'; import { EnvelopeRendererFileSelector } from '../envelope-editor/envelope-file-selector'; - -const EnvelopeGenericPageRenderer = lazy( - async () => import('~/components/general/envelope-editor/envelope-generic-page-renderer'), -); +import { EnvelopeGenericPageRenderer } from '../envelope-editor/envelope-generic-page-renderer'; export type DocumentCertificateQRViewProps = { documentId: number; @@ -104,11 +103,13 @@ export const DocumentCertificateQRView = ({ {internalVersion === 2 ? (
-
@@ -175,7 +182,9 @@ const DocumentCertificateQrV2 = ({ formattedDate, token, }: DocumentCertificateQrV2Props) => { - const { currentEnvelopeItem, envelopeItems } = useCurrentEnvelopeRender(); + const { envelopeItems } = useCurrentEnvelopeRender(); + + const scrollableContainerRef = useRef(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;