From 4fb3c2cb0fbfae5365a09cdc294eece57a06b189 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 27 Jan 2026 14:39:16 +1100 Subject: [PATCH] feat: add pdf image renderer --- .../dialogs/document-duplicate-dialog.tsx | 6 +- .../authoring/configure-document-upload.tsx | 21 +- .../configure-document-view.types.ts | 7 + .../embed/authoring/configure-fields-view.tsx | 13 +- .../embed-direct-template-client-page.tsx | 6 +- .../embed/embed-document-signing-page-v1.tsx | 6 +- .../multi-sign-document-signing-view.tsx | 6 +- .../direct-template/direct-template-page.tsx | 6 +- .../document-signing-page-view-v1.tsx | 6 +- .../document-signing-page-view-v2.tsx | 9 +- .../document/document-certificate-qr-view.tsx | 20 +- .../general/document/document-edit-form.tsx | 6 +- .../envelope-editor-fields-drag-drop.tsx | 15 +- .../envelope-editor-fields-page-renderer.tsx | 49 ++- .../envelope-editor-fields-page.tsx | 21 +- .../envelope-editor-preview-page.tsx | 10 +- .../envelope-generic-page-renderer.tsx | 44 +-- .../envelope-signer-page-renderer.tsx | 45 +-- .../general/envelope/envelope-page-image.tsx | 32 ++ .../general/template/template-edit-form.tsx | 6 +- .../t.$teamUrl+/documents.$id._index.tsx | 14 +- .../t.$teamUrl+/documents.$id.edit.tsx | 5 +- .../t.$teamUrl+/templates.$id._index.tsx | 14 +- .../routes/_recipient+/d.$token+/_index.tsx | 4 +- .../_recipient+/sign.$token+/_index.tsx | 2 +- .../app/routes/embed+/_v0+/direct.$token.tsx | 2 +- .../app/routes/embed+/_v0+/sign.$token.tsx | 2 +- apps/remix/server/api/files/files.ts | 12 + apps/remix/server/api/files/files.types.ts | 20 ++ .../get-envelope-item-image-by-token.ts | 67 ++++ .../files/routes/get-envelope-item-image.ts | 175 +++++++++++ .../routes/get-envelope-item-meta-by-token.ts | 54 ++++ .../files/routes/get-envelope-item-meta.ts | 138 +++++++++ package-lock.json | 84 ----- .../autosave-fields-step.spec.ts | 23 +- .../autosave-subject-step.spec.ts | 3 +- .../duplicate-recipients-simple.spec.ts | 5 +- .../duplicate-recipients.spec.ts | 20 +- .../document-flow/stepper-component.spec.ts | 23 +- .../e2e/envelopes/envelope-alignment.spec.ts | 38 +-- .../duplicate-recipients.spec.ts | 16 +- .../template-autosave-fields-step.spec.ts | 23 +- .../client-only/get-bounding-client-rect.ts | 2 +- .../client-only/hooks/use-document-element.ts | 5 +- .../client-only/hooks/use-page-renderer.ts | 170 +++++----- .../providers/envelope-render-provider.tsx | 185 +++++++---- packages/lib/constants/pdf-viewer-i18n.ts | 22 ++ packages/lib/constants/pdf-viewer.ts | 8 +- .../internal/seal-document.handler.ts | 13 +- packages/lib/package.json | 1 - .../ai/envelope/detect-fields/index.ts | 4 +- packages/lib/server-only/ai/pdf-to-images.ts | 127 ++++++-- .../lib/server-only/document/send-document.ts | 2 +- packages/lib/types/document-data.ts | 18 ++ .../lib/universal/upload/put-file.server.ts | 74 ++++- .../lib/universal/upload/server-actions.ts | 26 +- packages/lib/utils/envelope-images.ts | 77 +++++ .../migration.sql | 2 + packages/prisma/schema.prisma | 2 + packages/prisma/types/types.d.ts | 2 + .../document-router/get-document-by-token.ts | 9 +- .../get-document-by-token.types.ts | 7 +- .../pdf-viewer/envelope-pdf-viewer.tsx | 241 +++++++++++++++ .../pdf-viewer/pdf-viewer-konva-lazy.tsx | 26 -- .../pdf-viewer/pdf-viewer-konva.tsx | 213 ------------- .../pdf-viewer/pdf-viewer-states.tsx | 31 ++ .../ui/components/pdf-viewer/pdf-viewer.tsx | 283 +++++++++++++++++ .../virtual-list/use-virtual-list.ts | 211 +++++++++++++ packages/ui/package.json | 3 +- .../ui/primitives/pdf-viewer/base.client.tsx | 10 - packages/ui/primitives/pdf-viewer/base.tsx | 290 ------------------ packages/ui/primitives/pdf-viewer/index.ts | 1 - packages/ui/primitives/pdf-viewer/lazy.tsx | 7 - 73 files changed, 2045 insertions(+), 1105 deletions(-) create mode 100644 apps/remix/app/components/general/envelope/envelope-page-image.tsx create mode 100644 apps/remix/server/api/files/routes/get-envelope-item-image-by-token.ts create mode 100644 apps/remix/server/api/files/routes/get-envelope-item-image.ts create mode 100644 apps/remix/server/api/files/routes/get-envelope-item-meta-by-token.ts create mode 100644 apps/remix/server/api/files/routes/get-envelope-item-meta.ts create mode 100644 packages/lib/constants/pdf-viewer-i18n.ts create mode 100644 packages/lib/types/document-data.ts create mode 100644 packages/lib/utils/envelope-images.ts create mode 100644 packages/prisma/migrations/20260118064000_add_document_data_meta/migration.sql create mode 100644 packages/ui/components/pdf-viewer/envelope-pdf-viewer.tsx delete mode 100644 packages/ui/components/pdf-viewer/pdf-viewer-konva-lazy.tsx delete mode 100644 packages/ui/components/pdf-viewer/pdf-viewer-konva.tsx create mode 100644 packages/ui/components/pdf-viewer/pdf-viewer-states.tsx create mode 100644 packages/ui/components/pdf-viewer/pdf-viewer.tsx create mode 100644 packages/ui/components/virtual-list/use-virtual-list.ts delete mode 100644 packages/ui/primitives/pdf-viewer/base.client.tsx delete mode 100644 packages/ui/primitives/pdf-viewer/base.tsx delete mode 100644 packages/ui/primitives/pdf-viewer/index.ts delete mode 100644 packages/ui/primitives/pdf-viewer/lazy.tsx diff --git a/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx b/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx index 4ca68ea77..103c3af1e 100644 --- a/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx @@ -5,6 +5,7 @@ import { useNavigate } from 'react-router'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { trpc as trpcReact } from '@documenso/trpc/react'; +import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer'; import { Button } from '@documenso/ui/primitives/button'; import { Dialog, @@ -13,7 +14,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'; @@ -96,11 +96,11 @@ export const DocumentDuplicateDialog = ({ ) : (
-
)} diff --git a/apps/remix/app/components/embed/authoring/configure-document-upload.tsx b/apps/remix/app/components/embed/authoring/configure-document-upload.tsx index a86ffbcaf..d1c12b989 100644 --- a/apps/remix/app/components/embed/authoring/configure-document-upload.tsx +++ b/apps/remix/app/components/embed/authoring/configure-document-upload.tsx @@ -8,6 +8,8 @@ import { useDropzone } from 'react-dropzone'; import { useFormContext } from 'react-hook-form'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; +import { PDF_IMAGE_RENDER_SCALE } from '@documenso/lib/constants/pdf-viewer'; +import { pdfToImages } from '@documenso/lib/server-only/ai/pdf-to-images'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -52,12 +54,21 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum const arrayBuffer = await file.arrayBuffer(); const uint8Array = new Uint8Array(arrayBuffer); + const pdfImages = await pdfToImages(uint8Array, { + scale: PDF_IMAGE_RENDER_SCALE, + }); + // Store file metadata and UInt8Array in form data form.setValue('documentData', { name: file.name, type: file.type, size: file.size, data: uint8Array, // Store as UInt8Array + images: pdfImages.map((image) => ({ + width: image.originalWidth, + height: image.originalHeight, + image: image.image, + })), }); // Auto-populate title if it's empty @@ -144,7 +155,7 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
{isLoading && ( -
- +
+
)}
) : (
-
+
{documentData.name}
-
+
{formatFileSize(documentData.size)}
diff --git a/apps/remix/app/components/embed/authoring/configure-document-view.types.ts b/apps/remix/app/components/embed/authoring/configure-document-view.types.ts index bb634a4de..f661887e2 100644 --- a/apps/remix/app/components/embed/authoring/configure-document-view.types.ts +++ b/apps/remix/app/components/embed/authoring/configure-document-view.types.ts @@ -46,6 +46,13 @@ export const ZConfigureEmbedFormSchema = z.object({ type: z.string(), size: z.number(), data: z.instanceof(Uint8Array), // UInt8Array can't be directly validated by zod + images: z + .object({ + width: z.number(), + height: z.number(), + image: z.instanceof(Buffer), + }) + .array(), }) .optional(), }); 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 915c40b8b..6f13d713a 100644 --- a/apps/remix/app/components/embed/authoring/configure-fields-view.tsx +++ b/apps/remix/app/components/embed/authoring/configure-fields-view.tsx @@ -5,7 +5,6 @@ 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'; @@ -16,6 +15,7 @@ import { PDF_VIEWER_PAGE_SELECTOR } 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 { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer'; import { useRecipientColors } from '@documenso/ui/lib/recipient-colors'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; @@ -24,7 +24,6 @@ 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'; @@ -84,7 +83,7 @@ export const ConfigureFieldsView = ({ }; }, []); - const normalizedDocumentData = useMemo(() => { + const overrideImages = useMemo(() => { if (envelopeItem) { return undefined; } @@ -93,7 +92,7 @@ export const ConfigureFieldsView = ({ return undefined; } - return base64.encode(configData.documentData.data); + return configData.documentData.images; }, [configData.documentData]); const normalizedEnvelopeItem = useMemo(() => { @@ -546,12 +545,12 @@ export const ConfigureFieldsView = ({
- {/* Viewer */}
- setHasDocumentLoaded(true)} />
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 35af66e60..00924988e 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 @@ -19,11 +19,11 @@ import { DocumentReadOnlyFields, } from '@documenso/ui/components/document/document-read-only-fields'; import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; +import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer'; 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'; @@ -287,10 +287,10 @@ export const EmbedSignDocumentV1ClientPage = ({
{/* Viewer */}
- setHasDocumentLoaded(true)} />
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..34832620b 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 @@ -17,12 +17,12 @@ import type { } from '@documenso/trpc/server/field-router/schema'; import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields'; import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; +import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer'; import { cn } from '@documenso/ui/lib/utils'; 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'; @@ -226,10 +226,10 @@ export const MultiSignDocumentSigningView = ({ 'md:mx-auto md:max-w-2xl': document.status === DocumentStatus.COMPLETED, })} > - { setHasDocumentLoaded(true); onDocumentReady?.(); 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..3a6ade955 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 @@ -10,10 +10,10 @@ import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient- import type { TTemplate } from '@documenso/lib/types/template'; import { isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers'; import { trpc } from '@documenso/trpc/react'; +import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer'; 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'; @@ -151,11 +151,11 @@ export const DirectTemplatePageView = ({ gradient > - setIsDocumentPdfLoaded(true)} /> 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..55f926e16 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 @@ -27,10 +27,10 @@ import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/fie import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields'; import { trpc } from '@documenso/trpc/react'; import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields'; +import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer'; 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'; @@ -274,11 +274,11 @@ export const DocumentSigningPageViewV1 = ({
- 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 a4cb4a59c..6f10f1f76 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 @@ -8,8 +8,9 @@ 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 { EnvelopePdfViewer } from '@documenso/ui/components/pdf-viewer/envelope-pdf-viewer'; import { Button } from '@documenso/ui/primitives/button'; import { Separator } from '@documenso/ui/primitives/separator'; @@ -228,15 +229,15 @@ 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..7365797ce 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 @@ -9,9 +9,11 @@ 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 { 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 { EnvelopePdfViewer } from '@documenso/ui/components/pdf-viewer/envelope-pdf-viewer'; +import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer'; import { Button } from '@documenso/ui/primitives/button'; import { Dialog, @@ -21,7 +23,6 @@ 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'; @@ -35,7 +36,7 @@ export type DocumentCertificateQRViewProps = { documentId: number; title: string; internalVersion: number; - envelopeItems: (EnvelopeItem & { documentData: DocumentData })[]; + envelopeItems: (EnvelopeItem & { documentData: Omit })[]; documentTeamUrl: string; recipientCount?: number; completedDate?: Date; @@ -104,7 +105,9 @@ export const DocumentCertificateQRView = ({ {internalVersion === 2 ? (
-
@@ -175,7 +178,7 @@ const DocumentCertificateQrV2 = ({ formattedDate, token, }: DocumentCertificateQrV2Props) => { - const { currentEnvelopeItem, envelopeItems } = useCurrentEnvelopeRender(); + const { envelopeItems } = useCurrentEnvelopeRender(); return (
@@ -210,7 +213,10 @@ const DocumentCertificateQrV2 = ({
- +
); diff --git a/apps/remix/app/components/general/document/document-edit-form.tsx b/apps/remix/app/components/general/document/document-edit-form.tsx index 835e0c292..faacbcf39 100644 --- a/apps/remix/app/components/general/document/document-edit-form.tsx +++ b/apps/remix/app/components/general/document/document-edit-form.tsx @@ -15,6 +15,7 @@ import { import type { TDocument } from '@documenso/lib/types/document'; import { ZDocumentAccessAuthTypesSchema } from '@documenso/lib/types/document-auth'; import { trpc } from '@documenso/trpc/react'; +import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer'; import { cn } from '@documenso/ui/lib/utils'; import { Card, CardContent } from '@documenso/ui/primitives/card'; import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields'; @@ -27,7 +28,6 @@ import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/ad import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types'; import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root'; import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; -import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy'; import { Stepper } from '@documenso/ui/primitives/stepper'; import { useToast } from '@documenso/ui/primitives/use-toast'; @@ -440,11 +440,11 @@ export const DocumentEditForm = ({ gradient > - setIsDocumentPdfLoaded(true)} /> diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-drag-drop.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-drag-drop.tsx index bca1db219..2de39668b 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-drag-drop.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-drag-drop.tsx @@ -175,15 +175,6 @@ export const EnvelopeEditorFieldDragDrop = ({ const { top, left, height, width } = getBoundingClientRect($page); - console.log({ - top, - left, - height, - width, - rawPageX: event.pageX, - rawPageY: event.pageY, - }); - const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10); // Calculate x and y as a percentage of the page width and height @@ -278,13 +269,13 @@ export const EnvelopeEditorFieldDragDrop = ({ onMouseDown={() => setSelectedField(field.type)} data-selected={selectedField === field.type ? true : undefined} className={cn( - 'border-border group flex h-12 cursor-pointer items-center justify-center rounded-lg border px-4 transition-colors', + 'group flex h-12 cursor-pointer items-center justify-center rounded-lg border border-border px-4 transition-colors', RECIPIENT_COLOR_STYLES[selectedRecipientColor].fieldButton, )} >

createPageCanvas(stage, pageLayer)); + renderStatus, + imageProps, + } = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer), pageData); - const { _className, scale } = pageContext; + const { scale, pageNumber } = pageData; const localPageFields = useMemo( () => editorFields.localFields.filter( - (field) => - field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id, + (field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id, ), - [editorFields.localFields, pageContext.pageNumber], + [editorFields.localFields, pageNumber], ); const handleResizeOrMove = (event: KonvaEventObject) => { - const { current: container } = canvasElement; - - if (!container) { - return; - } - const isDragEvent = event.type === 'dragend'; const fieldGroup = event.target as Konva.Group; @@ -344,7 +345,6 @@ export default function EnvelopeEditorFieldsPageRenderer() { // Create a field if no items are selected or the size is too small. if ( selectedFieldGroups.length === 0 && - canvasElement.current && unscaledBoxWidth > MIN_FIELD_WIDTH_PX && unscaledBoxHeight > MIN_FIELD_HEIGHT_PX && editorFields.selectedRecipient && @@ -515,7 +515,7 @@ export default function EnvelopeEditorFieldsPageRenderer() { removePendingField(); - if (!canvasElement.current || !currentEnvelopeItem || !editorFields.selectedRecipient) { + if (!currentEnvelopeItem || !editorFields.selectedRecipient) { return; } @@ -530,7 +530,7 @@ export default function EnvelopeEditorFieldsPageRenderer() { editorFields.addField({ envelopeItemId: currentEnvelopeItem.id, - page: pageContext.pageNumber, + page: pageNumber, type, positionX: fieldX, positionY: fieldY, @@ -559,10 +559,7 @@ export default function EnvelopeEditorFieldsPageRenderer() { } return ( -

+
{selectedKonvaFieldGroups.length > 0 && interactiveTransformer.current && !isFieldChanging && ( @@ -625,13 +622,7 @@ export default function EnvelopeEditorFieldsPageRenderer() { {/* The element Konva will inject it's canvas into. */}
- {/* Canvas the PDF will be rendered on. */} - +
); } diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx index 548ec7420..70172a1dd 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx @@ -1,4 +1,4 @@ -import { lazy, useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import type { MessageDescriptor } from '@lingui/core'; import { msg } from '@lingui/core/macro'; @@ -12,6 +12,7 @@ import { match } from 'ts-pattern'; import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider'; +import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n'; import type { NormalizedFieldWithContext } from '@documenso/lib/server-only/ai/envelope/detect-fields/types'; import { FIELD_META_DEFAULT_VALUES, @@ -29,7 +30,7 @@ import { } from '@documenso/lib/types/field-meta'; import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients'; import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; -import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy'; +import { EnvelopePdfViewer } from '@documenso/ui/components/pdf-viewer/envelope-pdf-viewer'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; import { Separator } from '@documenso/ui/primitives/separator'; @@ -49,13 +50,10 @@ import { EditorFieldTextForm } from '~/components/forms/editor/editor-field-text import { useCurrentTeam } from '~/providers/team'; import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop'; +import EnvelopeEditorFieldsPageRenderer from './envelope-editor-fields-page-renderer'; import { EnvelopeRendererFileSelector } from './envelope-file-selector'; import { EnvelopeRecipientSelector } from './envelope-recipient-selector'; -const EnvelopeEditorFieldsPageRenderer = lazy( - async () => import('~/components/general/envelope-editor/envelope-editor-fields-page-renderer'), -); - const FieldSettingsTypeTranslations: Record = { [FieldType.SIGNATURE]: msg`Signature Settings`, [FieldType.FREE_SIGNATURE]: msg`Free Signature Settings`, @@ -75,6 +73,8 @@ export const EnvelopeEditorFieldsPage = () => { const team = useCurrentTeam(); + const scrollableContainerRef = useRef(null); + const { envelope, editorFields, relativePath } = useCurrentEnvelopeEditor(); const { currentEnvelopeItem } = useCurrentEnvelopeRender(); @@ -156,12 +156,12 @@ export const EnvelopeEditorFieldsPage = () => { return (
-
+
{/* Horizontal envelope item selector */} {/* Document View */} -
+
{envelope.recipients.length === 0 && ( { )} {currentEnvelopeItem !== null ? ( - ) : (
diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx index 3fc331d72..89ee0c785 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx @@ -11,12 +11,13 @@ import { EnvelopeRenderProvider, useCurrentEnvelopeRender, } from '@documenso/lib/client-only/providers/envelope-render-provider'; +import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n'; import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta'; import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing'; import { toCheckboxCustomText } from '@documenso/lib/utils/fields'; import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; -import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy'; +import { EnvelopePdfViewer } from '@documenso/ui/components/pdf-viewer/envelope-pdf-viewer'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector'; import { Separator } from '@documenso/ui/primitives/separator'; @@ -200,6 +201,7 @@ export const EnvelopeEditorPreviewPage = () => { // Override the parent renderer provider so we can inject custom fields. return ( { {/* Document View */} -
+
Preview Mode @@ -228,9 +230,9 @@ export const EnvelopeEditorPreviewPage = () => { {currentEnvelopeItem !== null ? ( - ) : (
diff --git a/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx b/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx index 370d35240..1aecef120 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx @@ -5,17 +5,22 @@ import { DocumentStatus, type Recipient, SigningStatus } from '@prisma/client'; import type Konva from 'konva'; import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer'; -import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider'; +import { + type PageRenderData, + useCurrentEnvelopeRender, +} from '@documenso/lib/client-only/providers/envelope-render-provider'; import type { TEnvelope } from '@documenso/lib/types/envelope'; import { renderField } from '@documenso/lib/universal/field-renderer/render-field'; import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields'; import { EnvelopeRecipientFieldTooltip } from '@documenso/ui/components/document/envelope-recipient-field-tooltip'; +import { EnvelopePageImage } from '../envelope/envelope-page-image'; + type GenericLocalField = TEnvelope['fields'][number] & { recipient: Pick; }; -export default function EnvelopeGenericPageRenderer() { +export default function EnvelopeGenericPageRenderer({ pageData }: { pageData: PageRenderData }) { const { i18n } = useLingui(); const { @@ -28,19 +33,12 @@ export default function EnvelopeGenericPageRenderer() { overrideSettings, } = useCurrentEnvelopeRender(); - const { - stage, - pageLayer, - canvasElement, - konvaContainer, - pageContext, - scaledViewport, - unscaledViewport, - } = usePageRenderer(({ stage, pageLayer }) => { - createPageCanvas(stage, pageLayer); - }); + const { stage, pageLayer, imageProps, konvaContainer, unscaledViewport, renderStatus } = + usePageRenderer(({ stage, pageLayer }) => { + createPageCanvas(stage, pageLayer); + }, pageData); - const { _className, scale } = pageContext; + const { scale, pageNumber } = pageData; const localPageFields = useMemo((): GenericLocalField[] => { if (envelopeStatus === DocumentStatus.COMPLETED) { @@ -49,8 +47,7 @@ export default function EnvelopeGenericPageRenderer() { return fields .filter( - (field) => - field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id, + (field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id, ) .map((field) => { const recipient = recipients.find((recipient) => recipient.id === field.recipientId); @@ -73,7 +70,7 @@ export default function EnvelopeGenericPageRenderer() { (recipient.signingStatus === SigningStatus.SIGNED ? inserted : true) || fieldMeta?.readOnly, ); - }, [fields, pageContext.pageNumber, currentEnvelopeItem?.id, recipients]); + }, [fields, pageNumber, currentEnvelopeItem?.id, recipients]); const unsafeRenderFieldOnLayer = (field: GenericLocalField) => { if (!pageLayer.current) { @@ -160,10 +157,7 @@ export default function EnvelopeGenericPageRenderer() { } return ( -
+
{overrideSettings?.showRecipientTooltip && localPageFields.map((field) => (
- {/* Canvas the PDF will be rendered on. */} - +
); } diff --git a/apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx b/apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx index b47e22cea..5dfc4b462 100644 --- a/apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx +++ b/apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx @@ -14,7 +14,10 @@ import type { KonvaEventObject } from 'konva/lib/Node'; import { match } from 'ts-pattern'; import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer'; -import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider'; +import { + type PageRenderData, + useCurrentEnvelopeRender, +} from '@documenso/lib/client-only/providers/envelope-render-provider'; import { useOptionalSession } from '@documenso/lib/client-only/providers/session'; import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates'; import { isBase64Image } from '@documenso/lib/constants/signatures'; @@ -44,12 +47,13 @@ import { handleTextFieldClick } from '~/utils/field-signing/text-field'; import { useRequiredDocumentSigningAuthContext } from '../document-signing/document-signing-auth-provider'; import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider'; +import { EnvelopePageImage } from '../envelope/envelope-page-image'; type GenericLocalField = TEnvelope['fields'][number] & { recipient: Pick; }; -export default function EnvelopeSignerPageRenderer() { +export default function EnvelopeSignerPageRenderer({ pageData }: { pageData: PageRenderData }) { const { t, i18n } = useLingui(); const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender(); const { sessionData } = useOptionalSession(); @@ -77,17 +81,10 @@ export default function EnvelopeSignerPageRenderer() { const { onFieldSigned, onFieldUnsigned } = useEmbedSigningContext() || {}; - const { - stage, - pageLayer, - canvasElement, - konvaContainer, - pageContext, - scaledViewport, - unscaledViewport, - } = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer)); + const { stage, pageLayer, imageProps, konvaContainer, unscaledViewport, renderStatus } = + usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer), pageData); - const { _className, scale } = pageContext; + const { scale, pageNumber } = pageData; const { envelope } = envelopeData; @@ -99,10 +96,9 @@ export default function EnvelopeSignerPageRenderer() { } return fieldsToRender.filter( - (field) => - field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id, + (field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id, ); - }, [recipientFields, selectedAssistantRecipientFields, pageContext.pageNumber]); + }, [recipientFields, selectedAssistantRecipientFields, pageNumber]); /** * Returns fields that have been fully signed by other recipients for this specific @@ -117,7 +113,7 @@ export default function EnvelopeSignerPageRenderer() { return recipient.fields .filter( (field) => - field.page === pageContext.pageNumber && + field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id && (field.inserted || field.fieldMeta?.readOnly), ) @@ -132,7 +128,7 @@ export default function EnvelopeSignerPageRenderer() { }, })); }); - }, [envelope.recipients, pageContext.pageNumber]); + }, [envelope.recipients, pageNumber]); const unsafeRenderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => { if (!pageLayer.current) { @@ -534,14 +530,11 @@ export default function EnvelopeSignerPageRenderer() { } return ( -
+
{showPendingFieldTooltip && recipientFieldsRemaining.length > 0 && recipientFieldsRemaining[0]?.envelopeItemId === currentEnvelopeItem?.id && - recipientFieldsRemaining[0]?.page === pageContext.pageNumber && ( + recipientFieldsRemaining[0]?.page === pageNumber && (
- {/* Canvas the PDF will be rendered on. */} - +
); } diff --git a/apps/remix/app/components/general/envelope/envelope-page-image.tsx b/apps/remix/app/components/general/envelope/envelope-page-image.tsx new file mode 100644 index 000000000..707d3a552 --- /dev/null +++ b/apps/remix/app/components/general/envelope/envelope-page-image.tsx @@ -0,0 +1,32 @@ +import { Trans } from '@lingui/react/macro'; + +import { Spinner } from '@documenso/ui/primitives/spinner'; + +type EnvelopePageImageProps = { + renderStatus: 'loading' | 'loaded' | 'error'; + imageProps: React.ImgHTMLAttributes & Record & { alt: '' }; +}; + +export const EnvelopePageImage = ({ renderStatus, imageProps }: EnvelopePageImageProps) => { + return ( + <> + {/* Loading State */} + {renderStatus === 'loading' && ( +
+ +
+ )} + + {renderStatus === 'error' && ( +
+

+ Error loading page +

+
+ )} + + {/* The PDF image. */} + + + ); +}; diff --git a/apps/remix/app/components/general/template/template-edit-form.tsx b/apps/remix/app/components/general/template/template-edit-form.tsx index 254694117..18d9551b2 100644 --- a/apps/remix/app/components/general/template/template-edit-form.tsx +++ b/apps/remix/app/components/general/template/template-edit-form.tsx @@ -14,11 +14,11 @@ import { import { ZDocumentAccessAuthTypesSchema } from '@documenso/lib/types/document-auth'; import type { TTemplate } from '@documenso/lib/types/template'; import { trpc } from '@documenso/trpc/react'; +import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer'; import { cn } from '@documenso/ui/lib/utils'; import { Card, CardContent } from '@documenso/ui/primitives/card'; import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root'; import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; -import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy'; import { Stepper } from '@documenso/ui/primitives/stepper'; import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-fields'; import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types'; @@ -312,11 +312,11 @@ export const TemplateEditForm = ({ gradient > - setIsDocumentPdfLoaded(true)} /> diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx index fc9646811..75956777d 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx @@ -9,6 +9,7 @@ import { match } from 'ts-pattern'; import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider'; import { useSession } from '@documenso/lib/client-only/providers/session'; +import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n'; import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { trpc } from '@documenso/trpc/react'; @@ -16,12 +17,12 @@ import { DocumentReadOnlyFields, mapFieldsWithRecipients, } from '@documenso/ui/components/document/document-read-only-fields'; -import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy'; +import { EnvelopePdfViewer } from '@documenso/ui/components/pdf-viewer/envelope-pdf-viewer'; +import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer'; import { cn } from '@documenso/ui/lib/utils'; import { Badge } from '@documenso/ui/primitives/badge'; import { Button } from '@documenso/ui/primitives/button'; import { Card, CardContent } from '@documenso/ui/primitives/card'; -import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy'; import { Spinner } from '@documenso/ui/primitives/spinner'; import { DocumentPageViewButton } from '~/components/general/document/document-page-view-button'; @@ -154,6 +155,7 @@ export default function DocumentPage({ params }: Route.ComponentProps) { {envelope.internalVersion === 2 ? (
- @@ -193,11 +195,11 @@ export default function DocumentPage({ params }: Route.ComponentProps) { /> )} - diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.edit.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.edit.tsx index c85cfb796..bb675a7c9 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.edit.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.edit.tsx @@ -58,7 +58,7 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) { if (envelope && (envelope.teamId !== team.id || envelope.internalVersion !== 2)) { return ( -
+
Redirecting
@@ -67,7 +67,7 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) { if (isLoadingEnvelope) { return ( -
+
Loading
@@ -99,6 +99,7 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) { return ( - @@ -210,10 +212,10 @@ export default function TemplatePage({ params }: Route.ComponentProps) { documentMeta={mockedDocumentMeta} /> - diff --git a/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx b/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx index 1484e70e7..d73d8357e 100644 --- a/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx +++ b/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx @@ -198,7 +198,7 @@ const DirectSigningPageV1 = ({ data }: { data: Awaited -
+

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

+ {renderError && ( + + + {t(errorMessage?.title || PDF_VIEWER_ERROR_MESSAGES.default.title)} + + + {t(errorMessage?.description || PDF_VIEWER_ERROR_MESSAGES.default.description)} + + + )} + + {/* Loading State */} + {isLoading && } + + {/* Error State */} + {hasError && } + + {/* Render pages in a virtualized list. */} + {envelopeItemsMetaLoadingState === 'loaded' && + currentEnvelopeItem && + currentItemMeta && + numPages > 0 && ( + + )} + + {/* No current item selected */} + {envelopeItemsMetaLoadingState === 'loaded' && !currentEnvelopeItem && ( +
+

+ No document selected +

+
+ )} +
+ ); +}; + +type VirtualizedPageListProps = { + scrollParentRef: React.RefObject; + constraintRef: React.RefObject; + pages: BasePageRenderData[]; + envelopeItemId: string; + numPages: number; + CustomPageRenderer?: React.FunctionComponent<{ pageData: PageRenderData }>; +}; + +// Note: There is a duplicate of this component in `PDFViewer`. +const VirtualizedPageList = ({ + scrollParentRef, + constraintRef, + pages, + envelopeItemId, + numPages, + CustomPageRenderer, +}: VirtualizedPageListProps) => { + const { virtualItems, totalSize, constraintWidth } = useVirtualList({ + scrollRef: scrollParentRef, + constraintRef, + itemCount: numPages, + itemSize: (index, width) => { + const pageMeta = pages[index]; + + // Calculate height based on aspect ratio and available width + const aspectRatio = pageMeta.pageHeight / pageMeta.pageWidth; + const scaledHeight = width * aspectRatio; + + // Add 32px for the page number text and margins (my-2 = 8px * 2 + text height ~16px) + return scaledHeight + 32; + }, + overscan: 10, + }); + + return ( +
+ {virtualItems.map((virtualItem) => { + const index = virtualItem.index; + const pageMeta = pages[index]; + const pageNumber = index + 1; + + // Calculate scale based on constraint width + const scale = constraintWidth / pageMeta.pageWidth; + + const pageData: PageRenderData = { + ...pageMeta, + scale, + }; + + return ( +
+
+ {CustomPageRenderer ? ( + + ) : ( + + )} +
+ +

+ + Page {pageNumber} of {numPages} + +

+
+ ); + })} +
+ ); +}; + +const ImagePageRenderer = ({ pageData }: { pageData: PageRenderData }) => { + const { pageWidth, pageHeight, scale, imageUrl, pageNumber } = pageData; + + const scaledWidth = pageWidth * scale; + const scaledHeight = pageHeight * scale; + + return ( +
+ +
+ ); +}; + +export default EnvelopePdfViewer; diff --git a/packages/ui/components/pdf-viewer/pdf-viewer-konva-lazy.tsx b/packages/ui/components/pdf-viewer/pdf-viewer-konva-lazy.tsx deleted file mode 100644 index ab85b329d..000000000 --- a/packages/ui/components/pdf-viewer/pdf-viewer-konva-lazy.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import React, { Suspense, lazy } from 'react'; - -import { type PDFDocumentProxy } from 'pdfjs-dist'; - -import type { PdfViewerRendererMode } from './pdf-viewer-konva'; - -export type LoadedPDFDocument = PDFDocumentProxy; - -export type PDFViewerProps = { - className?: string; - onDocumentLoad?: () => void; - renderer: PdfViewerRendererMode; - [key: string]: unknown; -} & Omit, 'onPageClick'>; - -const EnvelopePdfViewer = lazy(async () => import('./pdf-viewer-konva')); - -export const PDFViewerKonvaLazy = (props: PDFViewerProps) => { - return ( - Loading...
}> - - - ); -}; - -export default PDFViewerKonvaLazy; diff --git a/packages/ui/components/pdf-viewer/pdf-viewer-konva.tsx b/packages/ui/components/pdf-viewer/pdf-viewer-konva.tsx deleted file mode 100644 index 098fd5876..000000000 --- a/packages/ui/components/pdf-viewer/pdf-viewer-konva.tsx +++ /dev/null @@ -1,213 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; - -import type { MessageDescriptor } from '@lingui/core'; -import { msg } from '@lingui/core/macro'; -import { Trans, useLingui } from '@lingui/react/macro'; -import Konva from 'konva'; -import { Loader } from 'lucide-react'; -import { type PDFDocumentProxy } from 'pdfjs-dist'; -import { Document as PDFDocument, Page as PDFPage, pdfjs } from 'react-pdf'; - -import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider'; -import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; -import { cn } from '@documenso/ui/lib/utils'; -import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; - -export type LoadedPDFDocument = PDFDocumentProxy; - -/** - * This imports the worker from the `pdfjs-dist` package. - */ -pdfjs.GlobalWorkerOptions.workerSrc = new URL( - 'pdfjs-dist/legacy/build/pdf.worker.min.mjs', - import.meta.url, -).toString(); - -const pdfViewerOptions = { - cMapUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/static/cmaps/`, -}; - -const PDFLoader = () => ( - <> - - -

- Loading document... -

- -); - -export type PdfViewerRendererMode = 'editor' | 'preview' | 'signing'; - -const RendererErrorMessages: Record< - PdfViewerRendererMode, - { title: MessageDescriptor; description: MessageDescriptor } -> = { - editor: { - title: msg`Configuration Error`, - description: msg`There was an issue rendering some fields, please review the fields and try again.`, - }, - preview: { - title: msg`Configuration Error`, - description: msg`Something went wrong while rendering the document, some fields may be missing or corrupted.`, - }, - signing: { - title: msg`Configuration Error`, - description: msg`Something went wrong while rendering the document, some fields may be missing or corrupted.`, - }, -}; - -export type PdfViewerKonvaProps = { - className?: string; - onDocumentLoad?: () => void; - customPageRenderer?: React.FunctionComponent; - renderer: PdfViewerRendererMode; - [key: string]: unknown; -} & Omit, 'onPageClick'>; - -export const PdfViewerKonva = ({ - className, - onDocumentLoad, - customPageRenderer, - renderer, - ...props -}: PdfViewerKonvaProps) => { - const { t } = useLingui(); - - const $el = useRef(null); - - const { getPdfBuffer, currentEnvelopeItem, renderError } = useCurrentEnvelopeRender(); - - const [width, setWidth] = useState(0); - const [numPages, setNumPages] = useState(0); - const [pdfError, setPdfError] = useState(false); - - const envelopeItemFile = useMemo(() => { - const data = getPdfBuffer(currentEnvelopeItem?.id || ''); - - if (!data || data.status !== 'loaded') { - return null; - } - - return { - data: new Uint8Array(data.file), - }; - }, [currentEnvelopeItem?.id, getPdfBuffer]); - - const onDocumentLoaded = useCallback( - (doc: PDFDocumentProxy) => { - setNumPages(doc.numPages); - }, - [onDocumentLoad], - ); - - useEffect(() => { - if ($el.current) { - const $current = $el.current; - - const { width } = $current.getBoundingClientRect(); - - setWidth(width); - - const onResize = () => { - const { width } = $current.getBoundingClientRect(); - setWidth(width); - }; - - window.addEventListener('resize', onResize); - - return () => { - window.removeEventListener('resize', onResize); - }; - } - }, []); - - return ( -
- {renderError && ( - - {t(RendererErrorMessages[renderer].title)} - {t(RendererErrorMessages[renderer].description)} - - )} - - {envelopeItemFile && Konva ? ( - onDocumentLoaded(d)} - // Uploading a invalid document causes an error which doesn't appear to be handled by the `error` prop. - // Therefore we add some additional custom error handling. - onSourceError={() => { - setPdfError(true); - }} - externalLinkTarget="_blank" - loading={ -
- {pdfError ? ( -
-

- Something went wrong while loading the document. -

-

- Please try again or contact our support. -

-
- ) : ( - - )} -
- } - error={ -
-
-

- Something went wrong while loading the document. -

-

- Please try again or contact our support. -

-
-
- } - options={pdfViewerOptions} - > - {Array(numPages) - .fill(null) - .map((_, i) => ( -
-
- ''} - renderMode={customPageRenderer ? 'custom' : 'canvas'} - customRenderer={customPageRenderer} - /> -
-

- - Page {i + 1} of {numPages} - -

-
- ))} -
- ) : ( -
- -
- )} -
- ); -}; - -export default PdfViewerKonva; diff --git a/packages/ui/components/pdf-viewer/pdf-viewer-states.tsx b/packages/ui/components/pdf-viewer/pdf-viewer-states.tsx new file mode 100644 index 000000000..fc84f21fa --- /dev/null +++ b/packages/ui/components/pdf-viewer/pdf-viewer-states.tsx @@ -0,0 +1,31 @@ +import { Trans } from '@lingui/react/macro'; + +import { cn } from '@documenso/ui/lib/utils'; +import { Spinner } from '@documenso/ui/primitives/spinner'; + +export const PdfViewerLoadingState = () => { + return ( +
+ +
+ ); +}; + +export const PdfViewerErrorState = () => { + return ( +
+
+

+ Something went wrong while loading the document. +

+

+ Please try again or contact our support. +

+
+
+ ); +}; diff --git a/packages/ui/components/pdf-viewer/pdf-viewer.tsx b/packages/ui/components/pdf-viewer/pdf-viewer.tsx new file mode 100644 index 000000000..3fcfba3d9 --- /dev/null +++ b/packages/ui/components/pdf-viewer/pdf-viewer.tsx @@ -0,0 +1,283 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; + +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import type { EnvelopeItem } from '@prisma/client'; + +import { PDF_VIEWER_PAGE_CLASSNAME } from '@documenso/lib/constants/pdf-viewer'; +import type { DocumentDataVersion } from '@documenso/lib/types/document-data'; +import { + getEnvelopeItemMetaUrl, + getEnvelopeItemPageImageUrl, +} from '@documenso/lib/utils/envelope-images'; +import type { TGetEnvelopeItemsMetaResponse } from '@documenso/remix/server/api/files/files.types'; + +import { cn } from '../../lib/utils'; +import { useToast } from '../../primitives/use-toast'; +import { useVirtualList } from '../virtual-list/use-virtual-list'; +import { PdfViewerErrorState, PdfViewerLoadingState } from './pdf-viewer-states'; + +export type OverrideImage = { + image: Buffer; + width: number; + height: number; +}; + +export type OnPDFViewerPageClick = (_event: { + pageNumber: number; + numPages: number; + originalEvent: React.MouseEvent; + pageHeight: number; + pageWidth: number; + pageX: number; + pageY: number; +}) => void | Promise; + +type PageMeta = { + imageUrl: string; + width: number; + height: number; + documentDataId: string; +}; + +type LoadingState = 'loading' | 'loaded' | 'error'; + +export type PDFViewerProps = { + className?: string; + envelopeItem: Pick; + token: string | undefined; + presignToken?: string | undefined; + version: DocumentDataVersion; + onDocumentLoad?: () => void; + overrideImages?: OverrideImage[]; +} & React.HTMLAttributes; + +export const PDFViewer = ({ + className, + envelopeItem, + token, + presignToken, + version, + onDocumentLoad, + overrideImages, + ...props +}: PDFViewerProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const $el = useRef(null); + + const [loadingState, setLoadingState] = useState( + overrideImages ? 'loaded' : 'loading', + ); + const [pages, setPages] = useState([]); + + const numPages = overrideImages ? overrideImages.length : pages.length; + + const derivedPages = useMemo((): PageMeta[] => { + if (overrideImages) { + return overrideImages.map((image) => ({ + imageUrl: `data:image/jpeg;base64,${image.image.toString('base64')}`, + width: image.width, + height: image.height, + documentDataId: '', + })); + } + + return pages; + }, [overrideImages, pages]); + + // Fetch metadata when not using override images + useEffect(() => { + if (overrideImages) { + setLoadingState('loaded'); + return; + } + + const fetchMetadata = async () => { + try { + setLoadingState('loading'); + + const metaUrl = getEnvelopeItemMetaUrl({ + envelopeId: envelopeItem.envelopeId, + token, + presignToken, + }); + + const response = await fetch(metaUrl); + + if (!response.ok) { + throw new Error(`Failed to fetch envelope meta: ${response.status}`); + } + + const data: TGetEnvelopeItemsMetaResponse = await response.json(); + + // Find the specific envelope item + const itemMeta = data.envelopeItems.find((item) => item.envelopeItemId === envelopeItem.id); + + if (!itemMeta) { + throw new Error('Envelope item not found in metadata'); + } + + // Map pages to our internal format + const mappedPages: PageMeta[] = itemMeta.pages.map((page, pageIndex) => { + const imageUrl = getEnvelopeItemPageImageUrl({ + envelopeId: envelopeItem.envelopeId, + envelopeItemId: envelopeItem.id, + documentDataId: itemMeta.documentDataId, + pageIndex, + token, + presignToken, + version, + }); + + return { + imageUrl, + width: page.originalWidth, + height: page.originalHeight, + documentDataId: itemMeta.documentDataId, + }; + }); + + setPages(mappedPages); + setLoadingState('loaded'); + } catch (err) { + console.error(err); + setLoadingState('error'); + + toast({ + title: _(msg`Error`), + description: _(msg`An error occurred while loading the document.`), + variant: 'destructive', + }); + } + }; + + void fetchMetadata(); + }, [envelopeItem.envelopeId, envelopeItem.id, token, presignToken, version, overrideImages]); + + // Notify when document is loaded + useEffect(() => { + if (loadingState === 'loaded' && onDocumentLoad) { + onDocumentLoad(); + } + }, [loadingState, onDocumentLoad]); + + const isLoading = loadingState === 'loading'; + const hasError = loadingState === 'error'; + + return ( +
+ {/* Loading State */} + {isLoading && } + + {/* Error State */} + {hasError && } + + {/* Loaded State */} + {loadingState === 'loaded' && numPages > 0 && ( + + )} +
+ ); +}; + +type VirtualizedPageListProps = { + scrollParentRef: React.RefObject; + constraintRef: React.RefObject; + pages: PageMeta[]; + numPages: number; +}; + +// Note: There is a duplicate of this component in `EnvelopePdfViewer`. +// This current component is for V1 and legacy use cases. +const VirtualizedPageList = ({ + scrollParentRef, + constraintRef, + pages, + numPages, +}: VirtualizedPageListProps) => { + const { virtualItems, totalSize, constraintWidth } = useVirtualList({ + scrollRef: scrollParentRef, + constraintRef, + itemCount: numPages, + itemSize: (index, width) => { + const pageMeta = pages[index]; + + // Calculate height based on aspect ratio and available width + const aspectRatio = pageMeta.height / pageMeta.width; + const scaledHeight = width * aspectRatio; + + // Add 32px for the page number text and margins (my-2 = 8px * 2 + text height ~16px) + return scaledHeight + 32; + }, + overscan: 5, + }); + + return ( +
+ {virtualItems.map((virtualItem) => { + const index = virtualItem.index; + const pageMeta = pages[index]; + const pageNumber = index + 1; + + // Calculate scale based on constraint width + const scale = constraintWidth / pageMeta.width; + + const scaledWidth = pageMeta.width * scale; + const scaledHeight = pageMeta.height * scale; + + return ( +
+
+
+ +
+
+ +

+ + Page {pageNumber} of {numPages} + +

+
+ ); + })} +
+ ); +}; + +export default PDFViewer; diff --git a/packages/ui/components/virtual-list/use-virtual-list.ts b/packages/ui/components/virtual-list/use-virtual-list.ts new file mode 100644 index 000000000..9fb746f12 --- /dev/null +++ b/packages/ui/components/virtual-list/use-virtual-list.ts @@ -0,0 +1,211 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; + +export type VirtualListOptions = { + scrollRef: React.RefObject; + constraintRef?: React.RefObject; + itemCount: number; + itemSize: number | ((index: number, constraintWidth: number) => number); + overscan?: number; +}; + +export type VirtualItem = { + index: number; + start: number; + size: number; + key: string; +}; + +export type VirtualListResult = { + virtualItems: VirtualItem[]; + totalSize: number; + constraintWidth: number; +}; + +/** + * A minimal list virtualizer hook that supports fixed item sizes and external scroll containers. + * + * @param options - Configuration options for the virtual list + * @returns Virtual items to render, total size, and constraint width + */ +export const useVirtualList = (options: VirtualListOptions): VirtualListResult => { + const { scrollRef, constraintRef, itemCount, itemSize, overscan = 3 } = options; + + const [scrollTop, setScrollTop] = useState(0); + const [viewportHeight, setViewportHeight] = useState(0); + const [constraintWidth, setConstraintWidth] = useState(0); + + // Track constraint element width with ResizeObserver + useEffect(() => { + const el = constraintRef?.current; + + if (!el) { + return; + } + + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + + if (entry) { + setConstraintWidth(entry.contentRect.width); + } + }); + + observer.observe(el); + + // Set initial width + setConstraintWidth(el.getBoundingClientRect().width); + + return () => observer.disconnect(); + }, [constraintRef]); + + // Track scroll container dimensions with ResizeObserver + useEffect(() => { + const el = scrollRef.current; + + if (!el) { + return; + } + + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + + if (entry) { + setViewportHeight(entry.contentRect.height); + } + }); + + observer.observe(el); + + // Set initial height + setViewportHeight(el.getBoundingClientRect().height); + + return () => observer.disconnect(); + }, [scrollRef]); + + // Handle scroll events + useEffect(() => { + const el = scrollRef.current; + + if (!el) { + return; + } + + const handleScroll = () => { + setScrollTop(el.scrollTop); + }; + + el.addEventListener('scroll', handleScroll, { passive: true }); + + // Set initial scroll position + setScrollTop(el.scrollTop); + + return () => el.removeEventListener('scroll', handleScroll); + }, [scrollRef]); + + // Get item size helper + const getItemSize = useCallback( + (index: number): number => { + if (typeof itemSize === 'function') { + return itemSize(index, constraintWidth); + } + + return itemSize; + }, + [itemSize, constraintWidth], + ); + + // Precompute item offsets for O(1) lookup + const { offsets, totalSize } = useMemo(() => { + const result: number[] = []; + let offset = 0; + + for (let i = 0; i < itemCount; i++) { + result.push(offset); + offset += getItemSize(i); + } + + return { offsets: result, totalSize: offset }; + }, [itemCount, getItemSize]); + + // Binary search to find the first visible item + const findStartIndex = useCallback( + (scrollTop: number): number => { + let low = 0; + let high = itemCount - 1; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const offset = offsets[mid]; + + if (offset < scrollTop) { + low = mid + 1; + } else { + high = mid - 1; + } + } + + return Math.max(0, low - 1); + }, + [offsets, itemCount], + ); + + // Calculate virtual items to render + const virtualItems = useMemo((): VirtualItem[] => { + if (itemCount === 0 || constraintWidth === 0) { + return []; + } + + const startIndex = findStartIndex(scrollTop); + const items: VirtualItem[] = []; + + // Apply overscan before visible area + const overscanStart = Math.max(0, startIndex - overscan); + + // Find items within the visible area + overscan + for (let i = overscanStart; i < itemCount; i++) { + const start = offsets[i]; + const size = getItemSize(i); + + // Stop if we've gone past the visible area + overscan + if (start > scrollTop + viewportHeight) { + // Add overscan items after visible area + const overscanEnd = Math.min(itemCount, i + overscan); + + for (let j = i; j < overscanEnd; j++) { + items.push({ + index: j, + start: offsets[j], + size: getItemSize(j), + key: `virtual-item-${j}`, + }); + } + + break; + } + + items.push({ + index: i, + start, + size, + key: `virtual-item-${i}`, + }); + } + + return items; + }, [ + itemCount, + constraintWidth, + scrollTop, + viewportHeight, + overscan, + offsets, + getItemSize, + findStartIndex, + ]); + + return { + virtualItems, + totalSize, + constraintWidth, + }; +}; diff --git a/packages/ui/package.json b/packages/ui/package.json index 8c8f28c65..42595248f 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -66,14 +66,13 @@ "framer-motion": "^12.23.24", "lucide-react": "^0.554.0", "luxon": "^3.7.2", - "perfect-freehand": "^1.2.2", "pdfjs-dist": "5.4.296", + "perfect-freehand": "^1.2.2", "react": "^18", "react-colorful": "^5.6.1", "react-day-picker": "^8.10.1", "react-dom": "^18", "react-hook-form": "^7.66.1", - "react-pdf": "^10.3.0", "react-rnd": "^10.5.2", "remeda": "^2.32.0", "tailwind-merge": "^1.14.0", diff --git a/packages/ui/primitives/pdf-viewer/base.client.tsx b/packages/ui/primitives/pdf-viewer/base.client.tsx deleted file mode 100644 index 496ea706d..000000000 --- a/packages/ui/primitives/pdf-viewer/base.client.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { - type LoadedPDFDocument, - type OnPDFViewerPageClick, - PDFViewer, - type PDFViewerProps, -} from './base'; - -export { PDFViewer, type LoadedPDFDocument, type OnPDFViewerPageClick, type PDFViewerProps }; - -export default PDFViewer; diff --git a/packages/ui/primitives/pdf-viewer/base.tsx b/packages/ui/primitives/pdf-viewer/base.tsx deleted file mode 100644 index 4b150421b..000000000 --- a/packages/ui/primitives/pdf-viewer/base.tsx +++ /dev/null @@ -1,290 +0,0 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; - -import { msg } from '@lingui/core/macro'; -import { useLingui } from '@lingui/react'; -import { Trans } from '@lingui/react/macro'; -import type { EnvelopeItem } from '@prisma/client'; -import { base64 } from '@scure/base'; -import { Loader } from 'lucide-react'; -import { type PDFDocumentProxy } from 'pdfjs-dist'; -import { Document as PDFDocument, Page as PDFPage, pdfjs } from 'react-pdf'; - -import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; -import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; -import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download'; - -import { cn } from '../../lib/utils'; -import { useToast } from '../use-toast'; - -export type LoadedPDFDocument = PDFDocumentProxy; - -/** - * This imports the worker from the `pdfjs-dist` package. - * Wrapped in typeof window check to prevent SSR evaluation. - */ -if (typeof window !== 'undefined') { - pdfjs.GlobalWorkerOptions.workerSrc = new URL( - 'pdfjs-dist/legacy/build/pdf.worker.min.mjs', - import.meta.url, - ).toString(); -} - -const pdfViewerOptions = { - cMapUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/static/cmaps/`, -}; - -export type OnPDFViewerPageClick = (_event: { - pageNumber: number; - numPages: number; - originalEvent: React.MouseEvent; - pageHeight: number; - pageWidth: number; - pageX: number; - pageY: number; -}) => void | Promise; - -const PDFLoader = () => ( - <> - - -

- Loading document... -

- -); - -export type PDFViewerProps = { - className?: string; - envelopeItem: Pick; - token: string | undefined; - presignToken?: string | undefined; - version: 'original' | 'signed'; - onDocumentLoad?: (_doc: LoadedPDFDocument) => void; - onPageClick?: OnPDFViewerPageClick; - overrideData?: string; - customPageRenderer?: React.FunctionComponent; - [key: string]: unknown; -} & Omit, 'onPageClick'>; - -export const PDFViewer = ({ - className, - envelopeItem, - token, - presignToken, - version, - onDocumentLoad, - onPageClick, - overrideData, - customPageRenderer, - ...props -}: PDFViewerProps) => { - const { _ } = useLingui(); - const { toast } = useToast(); - - const $el = useRef(null); - - const [isDocumentBytesLoading, setIsDocumentBytesLoading] = useState(false); - const [documentBytes, setDocumentBytes] = useState( - overrideData ? base64.decode(overrideData) : null, - ); - - const [width, setWidth] = useState(0); - const [numPages, setNumPages] = useState(0); - const [pdfError, setPdfError] = useState(false); - - const isLoading = isDocumentBytesLoading || !documentBytes; - - const envelopeItemFile = useMemo(() => { - if (!documentBytes) { - return null; - } - - return { - data: documentBytes, - }; - }, [documentBytes]); - - const onDocumentLoaded = (doc: LoadedPDFDocument) => { - setNumPages(doc.numPages); - onDocumentLoad?.(doc); - }; - - const onDocumentPageClick = ( - event: React.MouseEvent, - pageNumber: number, - ) => { - const $el = event.target instanceof HTMLElement ? event.target : null; - - if (!$el) { - return; - } - - const $page = $el.closest(PDF_VIEWER_PAGE_SELECTOR); - - if (!$page) { - return; - } - - const { height, width, top, left } = $page.getBoundingClientRect(); - - const pageX = event.clientX - left; - const pageY = event.clientY - top; - - if (onPageClick) { - void onPageClick({ - pageNumber, - numPages, - originalEvent: event, - pageHeight: height, - pageWidth: width, - pageX, - pageY, - }); - } - }; - - useEffect(() => { - if ($el.current) { - const $current = $el.current; - - const { width } = $current.getBoundingClientRect(); - - setWidth(width); - - const onResize = () => { - const { width } = $current.getBoundingClientRect(); - - setWidth(width); - }; - - window.addEventListener('resize', onResize); - - return () => { - window.removeEventListener('resize', onResize); - }; - } - }, []); - - useEffect(() => { - if (overrideData) { - const bytes = base64.decode(overrideData); - - setDocumentBytes(bytes); - return; - } - - const fetchDocumentBytes = async () => { - try { - setIsDocumentBytesLoading(true); - - const documentUrl = getEnvelopeItemPdfUrl({ - type: 'view', - envelopeItem: envelopeItem, - token, - presignToken, - }); - - const bytes = await fetch(documentUrl).then(async (res) => await res.arrayBuffer()); - - setDocumentBytes(new Uint8Array(bytes)); - - setIsDocumentBytesLoading(false); - } catch (err) { - console.error(err); - - toast({ - title: _(msg`Error`), - description: _(msg`An error occurred while loading the document.`), - variant: 'destructive', - }); - } - }; - - void fetchDocumentBytes(); - }, [envelopeItem.envelopeId, envelopeItem.id, token, version, toast, overrideData]); - - return ( -
- {isLoading ? ( -
- -
- ) : ( - <> - onDocumentLoaded(d)} - // Uploading a invalid document causes an error which doesn't appear to be handled by the `error` prop. - // Therefore we add some additional custom error handling. - onSourceError={() => { - setPdfError(true); - }} - externalLinkTarget="_blank" - loading={ -
- {pdfError ? ( -
-

- Something went wrong while loading the document. -

-

- Please try again or contact our support. -

-
- ) : ( - - )} -
- } - error={ -
-
-

- Something went wrong while loading the document. -

-

- Please try again or contact our support. -

-
-
- } - options={pdfViewerOptions} - > - {Array(numPages) - .fill(null) - .map((_, i) => ( -
-
- ''} - renderMode={customPageRenderer ? 'custom' : 'canvas'} - customRenderer={customPageRenderer} - onClick={(e) => onDocumentPageClick(e, i + 1)} - /> -
-

- - Page {i + 1} of {numPages} - -

-
- ))} -
- - )} -
- ); -}; - -export default PDFViewer; diff --git a/packages/ui/primitives/pdf-viewer/index.ts b/packages/ui/primitives/pdf-viewer/index.ts deleted file mode 100644 index 8a185aaec..000000000 --- a/packages/ui/primitives/pdf-viewer/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './base'; diff --git a/packages/ui/primitives/pdf-viewer/lazy.tsx b/packages/ui/primitives/pdf-viewer/lazy.tsx deleted file mode 100644 index 30ce49d53..000000000 --- a/packages/ui/primitives/pdf-viewer/lazy.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import { ClientOnly } from '../../components/client-only'; - -import { PDFViewer, type PDFViewerProps } from './base.client'; - -export const PDFViewerLazy = (props: PDFViewerProps) => { - return Loading...
}>{() => }; -};