feat: add pdf image renderer

This commit is contained in:
David Nguyen
2026-01-27 14:39:16 +11:00
parent 7d38e18f93
commit 4fb3c2cb0f
73 changed files with 2045 additions and 1105 deletions
@@ -5,6 +5,7 @@ import { useNavigate } from 'react-router';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc as trpcReact } from '@documenso/trpc/react'; 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 { Button } from '@documenso/ui/primitives/button';
import { import {
Dialog, Dialog,
@@ -13,7 +14,6 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@documenso/ui/primitives/dialog'; } from '@documenso/ui/primitives/dialog';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team'; import { useCurrentTeam } from '~/providers/team';
@@ -96,11 +96,11 @@ export const DocumentDuplicateDialog = ({
</div> </div>
) : ( ) : (
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll"> <div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll">
<PDFViewerLazy <PDFViewer
key={envelopeItems[0].id} key={envelopeItems[0].id}
envelopeItem={envelopeItems[0]} envelopeItem={envelopeItems[0]}
token={undefined} token={undefined}
version="original" version="initial"
/> />
</div> </div>
)} )}
@@ -8,6 +8,8 @@ import { useDropzone } from 'react-dropzone';
import { useFormContext } from 'react-hook-form'; import { useFormContext } from 'react-hook-form';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; 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 { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
@@ -52,12 +54,21 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
const arrayBuffer = await file.arrayBuffer(); const arrayBuffer = await file.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer); const uint8Array = new Uint8Array(arrayBuffer);
const pdfImages = await pdfToImages(uint8Array, {
scale: PDF_IMAGE_RENDER_SCALE,
});
// Store file metadata and UInt8Array in form data // Store file metadata and UInt8Array in form data
form.setValue('documentData', { form.setValue('documentData', {
name: file.name, name: file.name,
type: file.type, type: file.type,
size: file.size, size: file.size,
data: uint8Array, // Store as UInt8Array 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 // Auto-populate title if it's empty
@@ -144,7 +155,7 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
<div <div
{...getRootProps()} {...getRootProps()}
className={cn( className={cn(
'border-border bg-background relative flex min-h-[160px] cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed transition', 'relative flex min-h-[160px] cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-border bg-background transition',
{ {
'border-primary/50 bg-primary/5': isDragActive, 'border-primary/50 bg-primary/5': isDragActive,
'hover:bg-muted/30': 'hover:bg-muted/30':
@@ -193,21 +204,21 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
</FormControl> </FormControl>
{isLoading && ( {isLoading && (
<div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg"> <div className="absolute inset-0 flex items-center justify-center rounded-lg bg-background/50">
<Loader className="text-muted-foreground h-10 w-10 animate-spin" /> <Loader className="h-10 w-10 animate-spin text-muted-foreground" />
</div> </div>
)} )}
</div> </div>
) : ( ) : (
<div className="mt-2 rounded-lg border p-4"> <div className="mt-2 rounded-lg border p-4">
<div className="flex items-center gap-x-4"> <div className="flex items-center gap-x-4">
<div className="bg-primary/10 text-primary flex h-12 w-12 items-center justify-center rounded-md"> <div className="flex h-12 w-12 items-center justify-center rounded-md bg-primary/10 text-primary">
<FileText className="h-6 w-6" /> <FileText className="h-6 w-6" />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<div className="text-sm font-medium">{documentData.name}</div> <div className="text-sm font-medium">{documentData.name}</div>
<div className="text-muted-foreground text-xs"> <div className="text-xs text-muted-foreground">
{formatFileSize(documentData.size)} {formatFileSize(documentData.size)}
</div> </div>
</div> </div>
@@ -46,6 +46,13 @@ export const ZConfigureEmbedFormSchema = z.object({
type: z.string(), type: z.string(),
size: z.number(), size: z.number(),
data: z.instanceof(Uint8Array), // UInt8Array can't be directly validated by zod 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(), .optional(),
}); });
@@ -5,7 +5,6 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { EnvelopeItem, FieldType } from '@prisma/client'; import type { EnvelopeItem, FieldType } from '@prisma/client';
import { ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client'; import { ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client';
import { base64 } from '@scure/base';
import { ChevronsUpDown } from 'lucide-react'; import { ChevronsUpDown } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { useHotkeys } from 'react-hotkeys-hook'; 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 { type TFieldMetaSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { nanoid } from '@documenso/lib/universal/id'; import { nanoid } from '@documenso/lib/universal/id';
import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers'; 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 { useRecipientColors } from '@documenso/ui/lib/recipient-colors';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; 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 { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { FieldSelector } from '@documenso/ui/primitives/field-selector'; import { FieldSelector } from '@documenso/ui/primitives/field-selector';
import { Form } from '@documenso/ui/primitives/form/form'; 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 { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
import { Sheet, SheetContent, SheetTrigger } from '@documenso/ui/primitives/sheet'; import { Sheet, SheetContent, SheetTrigger } from '@documenso/ui/primitives/sheet';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -84,7 +83,7 @@ export const ConfigureFieldsView = ({
}; };
}, []); }, []);
const normalizedDocumentData = useMemo(() => { const overrideImages = useMemo(() => {
if (envelopeItem) { if (envelopeItem) {
return undefined; return undefined;
} }
@@ -93,7 +92,7 @@ export const ConfigureFieldsView = ({
return undefined; return undefined;
} }
return base64.encode(configData.documentData.data); return configData.documentData.images;
}, [configData.documentData]); }, [configData.documentData]);
const normalizedEnvelopeItem = useMemo(() => { const normalizedEnvelopeItem = useMemo(() => {
@@ -546,12 +545,12 @@ export const ConfigureFieldsView = ({
<Form {...form}> <Form {...form}>
<div> <div>
<PDFViewerLazy <PDFViewer
presignToken={presignToken} presignToken={presignToken}
overrideData={normalizedDocumentData} overrideImages={overrideImages}
envelopeItem={normalizedEnvelopeItem} envelopeItem={normalizedEnvelopeItem}
token={undefined} token={undefined}
version="signed" version="current"
/> />
<ElementVisible <ElementVisible
@@ -31,11 +31,11 @@ import type {
TSignFieldWithTokenMutationSchema, TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema'; } from '@documenso/trpc/server/field-router/schema';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; 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 { Button } from '@documenso/ui/primitives/button';
import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label'; 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 { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -341,10 +341,10 @@ export const EmbedDirectTemplateClientPage = ({
<div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row"> <div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
{/* Viewer */} {/* Viewer */}
<div className="flex-1"> <div className="flex-1">
<PDFViewerLazy <PDFViewer
envelopeItem={envelopeItems[0]} envelopeItem={envelopeItems[0]}
token={recipient.token} token={recipient.token}
version="signed" version="current"
onDocumentLoad={() => setHasDocumentLoaded(true)} onDocumentLoad={() => setHasDocumentLoaded(true)}
/> />
</div> </div>
@@ -19,11 +19,11 @@ import {
DocumentReadOnlyFields, DocumentReadOnlyFields,
} from '@documenso/ui/components/document/document-read-only-fields'; } from '@documenso/ui/components/document/document-read-only-fields';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; 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 { Button } from '@documenso/ui/primitives/button';
import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label'; 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 { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog'; import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -287,10 +287,10 @@ export const EmbedSignDocumentV1ClientPage = ({
<div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row"> <div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
{/* Viewer */} {/* Viewer */}
<div className="embed--DocumentViewer flex-1"> <div className="embed--DocumentViewer flex-1">
<PDFViewerLazy <PDFViewer
envelopeItem={envelopeItems[0]} envelopeItem={envelopeItems[0]}
token={token} token={token}
version="signed" version="current"
onDocumentLoad={() => setHasDocumentLoaded(true)} onDocumentLoad={() => setHasDocumentLoaded(true)}
/> />
</div> </div>
@@ -17,12 +17,12 @@ import type {
} from '@documenso/trpc/server/field-router/schema'; } from '@documenso/trpc/server/field-router/schema';
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields'; import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; 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 { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label'; 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 { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
import { useToast } from '@documenso/ui/primitives/use-toast'; 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, 'md:mx-auto md:max-w-2xl': document.status === DocumentStatus.COMPLETED,
})} })}
> >
<PDFViewerLazy <PDFViewer
envelopeItem={document.envelopeItems[0]} envelopeItem={document.envelopeItems[0]}
token={token} token={token}
version="signed" version="current"
onDocumentLoad={() => { onDocumentLoad={() => {
setHasDocumentLoaded(true); setHasDocumentLoaded(true);
onDocumentReady?.(); onDocumentReady?.();
@@ -10,10 +10,10 @@ import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-
import type { TTemplate } from '@documenso/lib/types/template'; import type { TTemplate } from '@documenso/lib/types/template';
import { isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers'; import { isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import { trpc } from '@documenso/trpc/react'; 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 { Card, CardContent } from '@documenso/ui/primitives/card';
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root'; import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; 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 { Stepper } from '@documenso/ui/primitives/stepper';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -151,11 +151,11 @@ export const DirectTemplatePageView = ({
gradient gradient
> >
<CardContent className="p-2"> <CardContent className="p-2">
<PDFViewerLazy <PDFViewer
key={template.id} key={template.id}
envelopeItem={template.envelopeItems[0]} envelopeItem={template.envelopeItems[0]}
token={directTemplateRecipient.token} token={directTemplateRecipient.token}
version="signed" version="current"
onDocumentLoad={() => setIsDocumentPdfLoaded(true)} onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
/> />
</CardContent> </CardContent>
@@ -27,10 +27,10 @@ import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/fie
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields'; import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields'; 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 { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible'; 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 { DocumentSigningAttachmentsPopover } from '~/components/general/document-signing/document-signing-attachments-popover';
import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign'; import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign';
@@ -274,11 +274,11 @@ export const DocumentSigningPageViewV1 = ({
<div className="flex-1"> <div className="flex-1">
<Card className="rounded-xl before:rounded-xl" gradient> <Card className="rounded-xl before:rounded-xl" gradient>
<CardContent className="p-2"> <CardContent className="p-2">
<PDFViewerLazy <PDFViewer
key={document.envelopeItems[0].id} key={document.envelopeItems[0].id}
envelopeItem={document.envelopeItems[0]} envelopeItem={document.envelopeItems[0]}
token={recipient.token} token={recipient.token}
version="signed" version="current"
/> />
</CardContent> </CardContent>
</Card> </Card>
@@ -8,8 +8,9 @@ import { Link } from 'react-router';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-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 { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope'; 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 { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator'; import { Separator } from '@documenso/ui/primitives/separator';
@@ -228,15 +229,15 @@ export const DocumentSigningPageViewV2 = () => {
{/* Document View */} {/* Document View */}
<div className="embed--DocumentViewer flex flex-col items-center justify-center p-2 sm:mt-4 sm:p-4"> <div className="embed--DocumentViewer flex flex-col items-center justify-center p-2 sm:mt-4 sm:p-4">
{currentEnvelopeItem ? ( {currentEnvelopeItem ? (
<PDFViewerKonvaLazy <EnvelopePdfViewer
renderer="signing"
key={currentEnvelopeItem.id} key={currentEnvelopeItem.id}
customPageRenderer={EnvelopeSignerPageRenderer} customPageRenderer={EnvelopeSignerPageRenderer}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.signing}
/> />
) : ( ) : (
<div className="flex flex-col items-center justify-center py-32"> <div className="flex flex-col items-center justify-center py-32">
<p className="text-sm text-foreground"> <p className="text-sm text-foreground">
<Trans>No documents found</Trans> <Trans>No document selected</Trans>
</p> </p>
</div> </div>
)} )}
@@ -9,9 +9,11 @@ import {
EnvelopeRenderProvider, EnvelopeRenderProvider,
useCurrentEnvelopeRender, useCurrentEnvelopeRender,
} from '@documenso/lib/client-only/providers/envelope-render-provider'; } 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 { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react'; 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 { Button } from '@documenso/ui/primitives/button';
import { import {
Dialog, Dialog,
@@ -21,7 +23,6 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@documenso/ui/primitives/dialog'; } from '@documenso/ui/primitives/dialog';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog'; import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
@@ -35,7 +36,7 @@ export type DocumentCertificateQRViewProps = {
documentId: number; documentId: number;
title: string; title: string;
internalVersion: number; internalVersion: number;
envelopeItems: (EnvelopeItem & { documentData: DocumentData })[]; envelopeItems: (EnvelopeItem & { documentData: Omit<DocumentData, 'metadata'> })[];
documentTeamUrl: string; documentTeamUrl: string;
recipientCount?: number; recipientCount?: number;
completedDate?: Date; completedDate?: Date;
@@ -104,7 +105,9 @@ export const DocumentCertificateQRView = ({
{internalVersion === 2 ? ( {internalVersion === 2 ? (
<EnvelopeRenderProvider <EnvelopeRenderProvider
version="current"
envelope={{ envelope={{
id: envelopeItems[0].envelopeId,
envelopeItems, envelopeItems,
status: DocumentStatus.COMPLETED, status: DocumentStatus.COMPLETED,
type: EnvelopeType.DOCUMENT, type: EnvelopeType.DOCUMENT,
@@ -149,11 +152,11 @@ export const DocumentCertificateQRView = ({
</div> </div>
<div className="mt-12 w-full"> <div className="mt-12 w-full">
<PDFViewerLazy <PDFViewer
key={envelopeItems[0].id} key={envelopeItems[0].id}
envelopeItem={envelopeItems[0]} envelopeItem={envelopeItems[0]}
token={token} token={token}
version="signed" version="current"
/> />
</div> </div>
</> </>
@@ -175,7 +178,7 @@ const DocumentCertificateQrV2 = ({
formattedDate, formattedDate,
token, token,
}: DocumentCertificateQrV2Props) => { }: DocumentCertificateQrV2Props) => {
const { currentEnvelopeItem, envelopeItems } = useCurrentEnvelopeRender(); const { envelopeItems } = useCurrentEnvelopeRender();
return ( return (
<div className="flex min-h-screen flex-col items-start"> <div className="flex min-h-screen flex-col items-start">
@@ -210,7 +213,10 @@ const DocumentCertificateQrV2 = ({
<div className="mt-12 w-full"> <div className="mt-12 w-full">
<EnvelopeRendererFileSelector className="mb-4 p-0" fields={[]} secondaryOverride={''} /> <EnvelopeRendererFileSelector className="mb-4 p-0" fields={[]} secondaryOverride={''} />
<PDFViewerKonvaLazy renderer="preview" customPageRenderer={EnvelopeGenericPageRenderer} /> <EnvelopePdfViewer
customPageRenderer={EnvelopeGenericPageRenderer}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.preview}
/>
</div> </div>
</div> </div>
); );
@@ -15,6 +15,7 @@ import {
import type { TDocument } from '@documenso/lib/types/document'; import type { TDocument } from '@documenso/lib/types/document';
import { ZDocumentAccessAuthTypesSchema } from '@documenso/lib/types/document-auth'; import { ZDocumentAccessAuthTypesSchema } from '@documenso/lib/types/document-auth';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields'; 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 type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root'; import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; 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 { Stepper } from '@documenso/ui/primitives/stepper';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -440,11 +440,11 @@ export const DocumentEditForm = ({
gradient gradient
> >
<CardContent className="p-2"> <CardContent className="p-2">
<PDFViewerLazy <PDFViewer
key={document.envelopeItems[0].id} key={document.envelopeItems[0].id}
envelopeItem={document.envelopeItems[0]} envelopeItem={document.envelopeItems[0]}
token={undefined} token={undefined}
version="signed" version="current"
onDocumentLoad={() => setIsDocumentPdfLoaded(true)} onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
/> />
</CardContent> </CardContent>
@@ -175,15 +175,6 @@ export const EnvelopeEditorFieldDragDrop = ({
const { top, left, height, width } = getBoundingClientRect($page); 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); const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10);
// Calculate x and y as a percentage of the page width and height // Calculate x and y as a percentage of the page width and height
@@ -278,13 +269,13 @@ export const EnvelopeEditorFieldDragDrop = ({
onMouseDown={() => setSelectedField(field.type)} onMouseDown={() => setSelectedField(field.type)}
data-selected={selectedField === field.type ? true : undefined} data-selected={selectedField === field.type ? true : undefined}
className={cn( 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, RECIPIENT_COLOR_STYLES[selectedRecipientColor].fieldButton,
)} )}
> >
<p <p
className={cn( className={cn(
'text-muted-foreground font-noto group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal', 'flex items-center justify-center gap-x-1.5 font-noto text-sm font-normal text-muted-foreground group-data-[selected]:text-foreground',
field.className, field.className,
{ {
'group-hover:text-recipient-green': selectedRecipientColor === 'green', 'group-hover:text-recipient-green': selectedRecipientColor === 'green',
@@ -306,7 +297,7 @@ export const EnvelopeEditorFieldDragDrop = ({
{selectedField && ( {selectedField && (
<div <div
className={cn( className={cn(
'text-muted-foreground dark:text-muted-background font-noto pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white ring-2 transition duration-200 [container-type:size]', 'dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white font-noto text-muted-foreground ring-2 transition duration-200 [container-type:size]',
RECIPIENT_COLOR_STYLES[selectedRecipientColor].base, RECIPIENT_COLOR_STYLES[selectedRecipientColor].base,
selectedField === FieldType.SIGNATURE && 'font-signature', selectedField === FieldType.SIGNATURE && 'font-signature',
{ {
@@ -10,7 +10,10 @@ import { CopyPlusIcon, SquareStackIcon, TrashIcon, UserCircleIcon } from 'lucide
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields'; import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer'; import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
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 { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta'; import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
import { import {
MIN_FIELD_HEIGHT_PX, MIN_FIELD_HEIGHT_PX,
@@ -22,10 +25,15 @@ import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients'; import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { CommandDialog } from '@documenso/ui/primitives/command'; import { CommandDialog } from '@documenso/ui/primitives/command';
import { EnvelopePageImage } from '../envelope/envelope-page-image';
import { fieldButtonList } from './envelope-editor-fields-drag-drop'; import { fieldButtonList } from './envelope-editor-fields-drag-drop';
import { EnvelopeRecipientSelectorCommand } from './envelope-recipient-selector'; import { EnvelopeRecipientSelectorCommand } from './envelope-recipient-selector';
export default function EnvelopeEditorFieldsPageRenderer() { export default function EnvelopeEditorFieldsPageRenderer({
pageData,
}: {
pageData: PageRenderData;
}) {
const { t, i18n } = useLingui(); const { t, i18n } = useLingui();
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor(); const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender(); const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
@@ -40,31 +48,24 @@ export default function EnvelopeEditorFieldsPageRenderer() {
const { const {
stage, stage,
pageLayer, pageLayer,
canvasElement,
konvaContainer, konvaContainer,
pageContext,
scaledViewport, scaledViewport,
unscaledViewport, unscaledViewport,
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer)); renderStatus,
imageProps,
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer), pageData);
const { _className, scale } = pageContext; const { scale, pageNumber } = pageData;
const localPageFields = useMemo( const localPageFields = useMemo(
() => () =>
editorFields.localFields.filter( editorFields.localFields.filter(
(field) => (field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
), ),
[editorFields.localFields, pageContext.pageNumber], [editorFields.localFields, pageNumber],
); );
const handleResizeOrMove = (event: KonvaEventObject<Event>) => { const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
const { current: container } = canvasElement;
if (!container) {
return;
}
const isDragEvent = event.type === 'dragend'; const isDragEvent = event.type === 'dragend';
const fieldGroup = event.target as Konva.Group; 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. // Create a field if no items are selected or the size is too small.
if ( if (
selectedFieldGroups.length === 0 && selectedFieldGroups.length === 0 &&
canvasElement.current &&
unscaledBoxWidth > MIN_FIELD_WIDTH_PX && unscaledBoxWidth > MIN_FIELD_WIDTH_PX &&
unscaledBoxHeight > MIN_FIELD_HEIGHT_PX && unscaledBoxHeight > MIN_FIELD_HEIGHT_PX &&
editorFields.selectedRecipient && editorFields.selectedRecipient &&
@@ -515,7 +515,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
removePendingField(); removePendingField();
if (!canvasElement.current || !currentEnvelopeItem || !editorFields.selectedRecipient) { if (!currentEnvelopeItem || !editorFields.selectedRecipient) {
return; return;
} }
@@ -530,7 +530,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
editorFields.addField({ editorFields.addField({
envelopeItemId: currentEnvelopeItem.id, envelopeItemId: currentEnvelopeItem.id,
page: pageContext.pageNumber, page: pageNumber,
type, type,
positionX: fieldX, positionX: fieldX,
positionY: fieldY, positionY: fieldY,
@@ -559,10 +559,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
} }
return ( return (
<div <div className="relative w-full" key={`${currentEnvelopeItem.id}-renderer-${pageNumber}`}>
className="relative w-full"
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
>
{selectedKonvaFieldGroups.length > 0 && {selectedKonvaFieldGroups.length > 0 &&
interactiveTransformer.current && interactiveTransformer.current &&
!isFieldChanging && ( !isFieldChanging && (
@@ -625,13 +622,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
{/* The element Konva will inject it's canvas into. */} {/* The element Konva will inject it's canvas into. */}
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div> <div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
{/* Canvas the PDF will be rendered on. */} <EnvelopePageImage renderStatus={renderStatus} imageProps={imageProps} />
<canvas
className={`${_className}__canvas z-0`}
ref={canvasElement}
height={scaledViewport.height}
width={scaledViewport.width}
/>
</div> </div>
); );
} }
@@ -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 type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro'; 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 { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-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 type { NormalizedFieldWithContext } from '@documenso/lib/server-only/ai/envelope/detect-fields/types';
import { import {
FIELD_META_DEFAULT_VALUES, FIELD_META_DEFAULT_VALUES,
@@ -29,7 +30,7 @@ import {
} from '@documenso/lib/types/field-meta'; } from '@documenso/lib/types/field-meta';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients'; import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; 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 { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator'; 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 { useCurrentTeam } from '~/providers/team';
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop'; import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
import EnvelopeEditorFieldsPageRenderer from './envelope-editor-fields-page-renderer';
import { EnvelopeRendererFileSelector } from './envelope-file-selector'; import { EnvelopeRendererFileSelector } from './envelope-file-selector';
import { EnvelopeRecipientSelector } from './envelope-recipient-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, MessageDescriptor> = { const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
[FieldType.SIGNATURE]: msg`Signature Settings`, [FieldType.SIGNATURE]: msg`Signature Settings`,
[FieldType.FREE_SIGNATURE]: msg`Free Signature Settings`, [FieldType.FREE_SIGNATURE]: msg`Free Signature Settings`,
@@ -75,6 +73,8 @@ export const EnvelopeEditorFieldsPage = () => {
const team = useCurrentTeam(); const team = useCurrentTeam();
const scrollableContainerRef = useRef<HTMLDivElement>(null);
const { envelope, editorFields, relativePath } = useCurrentEnvelopeEditor(); const { envelope, editorFields, relativePath } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender(); const { currentEnvelopeItem } = useCurrentEnvelopeRender();
@@ -156,12 +156,12 @@ export const EnvelopeEditorFieldsPage = () => {
return ( return (
<div className="relative flex h-full"> <div className="relative flex h-full">
<div className="flex w-full flex-col overflow-y-auto"> <div className="flex h-full w-full flex-col overflow-y-auto" ref={scrollableContainerRef}>
{/* Horizontal envelope item selector */} {/* Horizontal envelope item selector */}
<EnvelopeRendererFileSelector fields={editorFields.localFields} /> <EnvelopeRendererFileSelector fields={editorFields.localFields} />
{/* Document View */} {/* Document View */}
<div className="mt-4 flex flex-col items-center justify-center"> <div className="mt-4 flex h-full flex-col items-center justify-center">
{envelope.recipients.length === 0 && ( {envelope.recipients.length === 0 && (
<Alert <Alert
variant="neutral" variant="neutral"
@@ -185,9 +185,10 @@ export const EnvelopeEditorFieldsPage = () => {
)} )}
{currentEnvelopeItem !== null ? ( {currentEnvelopeItem !== null ? (
<PDFViewerKonvaLazy <EnvelopePdfViewer
renderer="editor"
customPageRenderer={EnvelopeEditorFieldsPageRenderer} customPageRenderer={EnvelopeEditorFieldsPageRenderer}
scrollParentRef={scrollableContainerRef}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.editor}
/> />
) : ( ) : (
<div className="flex flex-col items-center justify-center py-32"> <div className="flex flex-col items-center justify-center py-32">
@@ -11,12 +11,13 @@ import {
EnvelopeRenderProvider, EnvelopeRenderProvider,
useCurrentEnvelopeRender, useCurrentEnvelopeRender,
} from '@documenso/lib/client-only/providers/envelope-render-provider'; } 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 { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing'; import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing';
import { toCheckboxCustomText } from '@documenso/lib/utils/fields'; import { toCheckboxCustomText } from '@documenso/lib/utils/fields';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; 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 { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector'; import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
import { Separator } from '@documenso/ui/primitives/separator'; 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. // Override the parent renderer provider so we can inject custom fields.
return ( return (
<EnvelopeRenderProvider <EnvelopeRenderProvider
version="current"
envelope={envelope} envelope={envelope}
token={undefined} token={undefined}
fields={fieldsWithPlaceholders} fields={fieldsWithPlaceholders}
@@ -217,7 +219,7 @@ export const EnvelopeEditorPreviewPage = () => {
<EnvelopeRendererFileSelector fields={editorFields.localFields} /> <EnvelopeRendererFileSelector fields={editorFields.localFields} />
{/* Document View */} {/* Document View */}
<div className="mt-4 flex flex-col items-center justify-center"> <div className="mt-4 flex h-full flex-col items-center justify-center">
<Alert variant="warning" className="mb-4 max-w-[800px]"> <Alert variant="warning" className="mb-4 max-w-[800px]">
<AlertTitle> <AlertTitle>
<Trans>Preview Mode</Trans> <Trans>Preview Mode</Trans>
@@ -228,9 +230,9 @@ export const EnvelopeEditorPreviewPage = () => {
</Alert> </Alert>
{currentEnvelopeItem !== null ? ( {currentEnvelopeItem !== null ? (
<PDFViewerKonvaLazy <EnvelopePdfViewer
renderer="editor"
customPageRenderer={EnvelopeGenericPageRenderer} customPageRenderer={EnvelopeGenericPageRenderer}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.preview}
/> />
) : ( ) : (
<div className="flex flex-col items-center justify-center py-32"> <div className="flex flex-col items-center justify-center py-32">
@@ -5,17 +5,22 @@ import { DocumentStatus, type Recipient, SigningStatus } from '@prisma/client';
import type Konva from 'konva'; import type Konva from 'konva';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer'; 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 type { TEnvelope } from '@documenso/lib/types/envelope';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field'; import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields'; import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
import { EnvelopeRecipientFieldTooltip } from '@documenso/ui/components/document/envelope-recipient-field-tooltip'; import { EnvelopeRecipientFieldTooltip } from '@documenso/ui/components/document/envelope-recipient-field-tooltip';
import { EnvelopePageImage } from '../envelope/envelope-page-image';
type GenericLocalField = TEnvelope['fields'][number] & { type GenericLocalField = TEnvelope['fields'][number] & {
recipient: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>; recipient: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>;
}; };
export default function EnvelopeGenericPageRenderer() { export default function EnvelopeGenericPageRenderer({ pageData }: { pageData: PageRenderData }) {
const { i18n } = useLingui(); const { i18n } = useLingui();
const { const {
@@ -28,19 +33,12 @@ export default function EnvelopeGenericPageRenderer() {
overrideSettings, overrideSettings,
} = useCurrentEnvelopeRender(); } = useCurrentEnvelopeRender();
const { const { stage, pageLayer, imageProps, konvaContainer, unscaledViewport, renderStatus } =
stage, usePageRenderer(({ stage, pageLayer }) => {
pageLayer, createPageCanvas(stage, pageLayer);
canvasElement, }, pageData);
konvaContainer,
pageContext,
scaledViewport,
unscaledViewport,
} = usePageRenderer(({ stage, pageLayer }) => {
createPageCanvas(stage, pageLayer);
});
const { _className, scale } = pageContext; const { scale, pageNumber } = pageData;
const localPageFields = useMemo((): GenericLocalField[] => { const localPageFields = useMemo((): GenericLocalField[] => {
if (envelopeStatus === DocumentStatus.COMPLETED) { if (envelopeStatus === DocumentStatus.COMPLETED) {
@@ -49,8 +47,7 @@ export default function EnvelopeGenericPageRenderer() {
return fields return fields
.filter( .filter(
(field) => (field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
) )
.map((field) => { .map((field) => {
const recipient = recipients.find((recipient) => recipient.id === field.recipientId); const recipient = recipients.find((recipient) => recipient.id === field.recipientId);
@@ -73,7 +70,7 @@ export default function EnvelopeGenericPageRenderer() {
(recipient.signingStatus === SigningStatus.SIGNED ? inserted : true) || (recipient.signingStatus === SigningStatus.SIGNED ? inserted : true) ||
fieldMeta?.readOnly, fieldMeta?.readOnly,
); );
}, [fields, pageContext.pageNumber, currentEnvelopeItem?.id, recipients]); }, [fields, pageNumber, currentEnvelopeItem?.id, recipients]);
const unsafeRenderFieldOnLayer = (field: GenericLocalField) => { const unsafeRenderFieldOnLayer = (field: GenericLocalField) => {
if (!pageLayer.current) { if (!pageLayer.current) {
@@ -160,10 +157,7 @@ export default function EnvelopeGenericPageRenderer() {
} }
return ( return (
<div <div className="relative w-full" key={`${currentEnvelopeItem.id}-renderer-${pageNumber}`}>
className="relative w-full"
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
>
{overrideSettings?.showRecipientTooltip && {overrideSettings?.showRecipientTooltip &&
localPageFields.map((field) => ( localPageFields.map((field) => (
<EnvelopeRecipientFieldTooltip <EnvelopeRecipientFieldTooltip
@@ -177,13 +171,7 @@ export default function EnvelopeGenericPageRenderer() {
{/* The element Konva will inject it's canvas into. */} {/* The element Konva will inject it's canvas into. */}
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div> <div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
{/* Canvas the PDF will be rendered on. */} <EnvelopePageImage renderStatus={renderStatus} imageProps={imageProps} />
<canvas
className={`${_className}__canvas z-0`}
ref={canvasElement}
height={scaledViewport.height}
width={scaledViewport.width}
/>
</div> </div>
); );
} }
@@ -14,7 +14,10 @@ import type { KonvaEventObject } from 'konva/lib/Node';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer'; 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 { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates'; import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates';
import { isBase64Image } from '@documenso/lib/constants/signatures'; 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 { useRequiredDocumentSigningAuthContext } from '../document-signing/document-signing-auth-provider';
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider'; import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
import { EnvelopePageImage } from '../envelope/envelope-page-image';
type GenericLocalField = TEnvelope['fields'][number] & { type GenericLocalField = TEnvelope['fields'][number] & {
recipient: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>; recipient: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>;
}; };
export default function EnvelopeSignerPageRenderer() { export default function EnvelopeSignerPageRenderer({ pageData }: { pageData: PageRenderData }) {
const { t, i18n } = useLingui(); const { t, i18n } = useLingui();
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender(); const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
const { sessionData } = useOptionalSession(); const { sessionData } = useOptionalSession();
@@ -77,17 +81,10 @@ export default function EnvelopeSignerPageRenderer() {
const { onFieldSigned, onFieldUnsigned } = useEmbedSigningContext() || {}; const { onFieldSigned, onFieldUnsigned } = useEmbedSigningContext() || {};
const { const { stage, pageLayer, imageProps, konvaContainer, unscaledViewport, renderStatus } =
stage, usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer), pageData);
pageLayer,
canvasElement,
konvaContainer,
pageContext,
scaledViewport,
unscaledViewport,
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer));
const { _className, scale } = pageContext; const { scale, pageNumber } = pageData;
const { envelope } = envelopeData; const { envelope } = envelopeData;
@@ -99,10 +96,9 @@ export default function EnvelopeSignerPageRenderer() {
} }
return fieldsToRender.filter( return fieldsToRender.filter(
(field) => (field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
field.page === pageContext.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 * Returns fields that have been fully signed by other recipients for this specific
@@ -117,7 +113,7 @@ export default function EnvelopeSignerPageRenderer() {
return recipient.fields return recipient.fields
.filter( .filter(
(field) => (field) =>
field.page === pageContext.pageNumber && field.page === pageNumber &&
field.envelopeItemId === currentEnvelopeItem?.id && field.envelopeItemId === currentEnvelopeItem?.id &&
(field.inserted || field.fieldMeta?.readOnly), (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 }) => { const unsafeRenderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
if (!pageLayer.current) { if (!pageLayer.current) {
@@ -534,14 +530,11 @@ export default function EnvelopeSignerPageRenderer() {
} }
return ( return (
<div <div className="relative w-full" key={`${currentEnvelopeItem.id}-renderer-${pageNumber}`}>
className="relative w-full"
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
>
{showPendingFieldTooltip && {showPendingFieldTooltip &&
recipientFieldsRemaining.length > 0 && recipientFieldsRemaining.length > 0 &&
recipientFieldsRemaining[0]?.envelopeItemId === currentEnvelopeItem?.id && recipientFieldsRemaining[0]?.envelopeItemId === currentEnvelopeItem?.id &&
recipientFieldsRemaining[0]?.page === pageContext.pageNumber && ( recipientFieldsRemaining[0]?.page === pageNumber && (
<EnvelopeFieldToolTip <EnvelopeFieldToolTip
key={recipientFieldsRemaining[0].id} key={recipientFieldsRemaining[0].id}
field={recipientFieldsRemaining[0]} field={recipientFieldsRemaining[0]}
@@ -563,13 +556,7 @@ export default function EnvelopeSignerPageRenderer() {
{/* The element Konva will inject it's canvas into. */} {/* The element Konva will inject it's canvas into. */}
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div> <div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
{/* Canvas the PDF will be rendered on. */} <EnvelopePageImage renderStatus={renderStatus} imageProps={imageProps} />
<canvas
className={`${_className}__canvas z-0`}
ref={canvasElement}
height={scaledViewport.height}
width={scaledViewport.width}
/>
</div> </div>
); );
} }
@@ -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<HTMLImageElement> & Record<string, unknown> & { alt: '' };
};
export const EnvelopePageImage = ({ renderStatus, imageProps }: EnvelopePageImageProps) => {
return (
<>
{/* Loading State */}
{renderStatus === 'loading' && (
<div className="absolute inset-0 z-10 flex items-center justify-center">
<Spinner />
</div>
)}
{renderStatus === 'error' && (
<div className="absolute inset-0 z-10 flex items-center justify-center">
<p>
<Trans>Error loading page</Trans>
</p>
</div>
)}
{/* The PDF image. */}
<img {...imageProps} alt="" />
</>
);
};
@@ -14,11 +14,11 @@ import {
import { ZDocumentAccessAuthTypesSchema } from '@documenso/lib/types/document-auth'; import { ZDocumentAccessAuthTypesSchema } from '@documenso/lib/types/document-auth';
import type { TTemplate } from '@documenso/lib/types/template'; import type { TTemplate } from '@documenso/lib/types/template';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root'; import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; 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 { Stepper } from '@documenso/ui/primitives/stepper';
import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-fields'; import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-fields';
import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types'; import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types';
@@ -312,11 +312,11 @@ export const TemplateEditForm = ({
gradient gradient
> >
<CardContent className="p-2"> <CardContent className="p-2">
<PDFViewerLazy <PDFViewer
key={template.envelopeItems[0].id} key={template.envelopeItems[0].id}
envelopeItem={template.envelopeItems[0]} envelopeItem={template.envelopeItems[0]}
token={undefined} token={undefined}
version="signed" version="current"
onDocumentLoad={() => setIsDocumentPdfLoaded(true)} onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
/> />
</CardContent> </CardContent>
@@ -9,6 +9,7 @@ import { match } from 'ts-pattern';
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider'; import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { useSession } from '@documenso/lib/client-only/providers/session'; 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 { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
@@ -16,12 +17,12 @@ import {
DocumentReadOnlyFields, DocumentReadOnlyFields,
mapFieldsWithRecipients, mapFieldsWithRecipients,
} from '@documenso/ui/components/document/document-read-only-fields'; } 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 { cn } from '@documenso/ui/lib/utils';
import { Badge } from '@documenso/ui/primitives/badge'; import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card'; 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 { Spinner } from '@documenso/ui/primitives/spinner';
import { DocumentPageViewButton } from '~/components/general/document/document-page-view-button'; import { DocumentPageViewButton } from '~/components/general/document/document-page-view-button';
@@ -154,6 +155,7 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
{envelope.internalVersion === 2 ? ( {envelope.internalVersion === 2 ? (
<div className="relative col-span-12 lg:col-span-6 xl:col-span-7"> <div className="relative col-span-12 lg:col-span-6 xl:col-span-7">
<EnvelopeRenderProvider <EnvelopeRenderProvider
version="current"
envelope={envelope} envelope={envelope}
token={undefined} token={undefined}
fields={envelope.fields} fields={envelope.fields}
@@ -169,9 +171,9 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
<Card className="rounded-xl before:rounded-xl" gradient> <Card className="rounded-xl before:rounded-xl" gradient>
<CardContent className="p-2"> <CardContent className="p-2">
<PDFViewerKonvaLazy <EnvelopePdfViewer
renderer="preview"
customPageRenderer={EnvelopeGenericPageRenderer} customPageRenderer={EnvelopeGenericPageRenderer}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.preview}
/> />
</CardContent> </CardContent>
</Card> </Card>
@@ -193,11 +195,11 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
/> />
)} )}
<PDFViewerLazy <PDFViewer
envelopeItem={envelope.envelopeItems[0]} envelopeItem={envelope.envelopeItems[0]}
token={undefined} token={undefined}
key={envelope.envelopeItems[0].id} key={envelope.envelopeItems[0].id}
version="signed" version="current"
/> />
</CardContent> </CardContent>
</Card> </Card>
@@ -58,7 +58,7 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
if (envelope && (envelope.teamId !== team.id || envelope.internalVersion !== 2)) { if (envelope && (envelope.teamId !== team.id || envelope.internalVersion !== 2)) {
return ( return (
<div className="text-foreground flex h-screen w-screen flex-col items-center justify-center gap-2"> <div className="flex h-screen w-screen flex-col items-center justify-center gap-2 text-foreground">
<Spinner /> <Spinner />
<Trans>Redirecting</Trans> <Trans>Redirecting</Trans>
</div> </div>
@@ -67,7 +67,7 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
if (isLoadingEnvelope) { if (isLoadingEnvelope) {
return ( return (
<div className="text-foreground flex h-screen w-screen flex-col items-center justify-center gap-2"> <div className="flex h-screen w-screen flex-col items-center justify-center gap-2 text-foreground">
<Spinner /> <Spinner />
<Trans>Loading</Trans> <Trans>Loading</Trans>
</div> </div>
@@ -99,6 +99,7 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
return ( return (
<EnvelopeEditorProvider initialEnvelope={envelope}> <EnvelopeEditorProvider initialEnvelope={envelope}>
<EnvelopeRenderProvider <EnvelopeRenderProvider
version="current"
envelope={envelope} envelope={envelope}
token={undefined} token={undefined}
fields={envelope.fields} fields={envelope.fields}
@@ -8,15 +8,16 @@ import { Link, useNavigate } from 'react-router';
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider'; import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope'; import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields'; import { DocumentReadOnlyFields } 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 { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card'; 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 { Spinner } from '@documenso/ui/primitives/spinner';
import { TemplateBulkSendDialog } from '~/components/dialogs/template-bulk-send-dialog'; import { TemplateBulkSendDialog } from '~/components/dialogs/template-bulk-send-dialog';
@@ -173,6 +174,7 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
{envelope.internalVersion === 2 ? ( {envelope.internalVersion === 2 ? (
<div className="relative col-span-12 lg:col-span-6 xl:col-span-7"> <div className="relative col-span-12 lg:col-span-6 xl:col-span-7">
<EnvelopeRenderProvider <EnvelopeRenderProvider
version="current"
envelope={envelope} envelope={envelope}
token={undefined} token={undefined}
fields={envelope.fields} fields={envelope.fields}
@@ -187,9 +189,9 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
<Card className="rounded-xl before:rounded-xl" gradient> <Card className="rounded-xl before:rounded-xl" gradient>
<CardContent className="p-2"> <CardContent className="p-2">
<PDFViewerKonvaLazy <EnvelopePdfViewer
renderer="preview"
customPageRenderer={EnvelopeGenericPageRenderer} customPageRenderer={EnvelopeGenericPageRenderer}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.preview}
/> />
</CardContent> </CardContent>
</Card> </Card>
@@ -210,10 +212,10 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
documentMeta={mockedDocumentMeta} documentMeta={mockedDocumentMeta}
/> />
<PDFViewerLazy <PDFViewer
envelopeItem={envelope.envelopeItems[0]} envelopeItem={envelope.envelopeItems[0]}
token={undefined} token={undefined}
version="signed" version="current"
key={envelope.envelopeItems[0].id} key={envelope.envelopeItems[0].id}
/> />
</CardContent> </CardContent>
@@ -198,7 +198,7 @@ const DirectSigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV
{template.title} {template.title}
</h1> </h1>
<div className="text-muted-foreground mb-8 mt-2.5 flex items-center gap-x-2"> <div className="mb-8 mt-2.5 flex items-center gap-x-2 text-muted-foreground">
<UsersIcon className="h-4 w-4" /> <UsersIcon className="h-4 w-4" />
<p className="text-muted-foreground/80"> <p className="text-muted-foreground/80">
<Plural value={template.recipients.length} one="# recipient" other="# recipients" /> <Plural value={template.recipients.length} one="# recipient" other="# recipients" />
@@ -246,7 +246,7 @@ const DirectSigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV
recipient={recipient} recipient={recipient}
user={user} user={user}
> >
<EnvelopeRenderProvider envelope={envelope} token={recipient.token}> <EnvelopeRenderProvider version="current" envelope={envelope} token={recipient.token}>
<DocumentSigningPageViewV2 /> <DocumentSigningPageViewV2 />
</EnvelopeRenderProvider> </EnvelopeRenderProvider>
</DocumentSigningAuthProvider> </DocumentSigningAuthProvider>
@@ -494,7 +494,7 @@ const SigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV2Loade
recipient={recipient} recipient={recipient}
user={user} user={user}
> >
<EnvelopeRenderProvider envelope={envelope} token={recipient.token}> <EnvelopeRenderProvider version="current" envelope={envelope} token={recipient.token}>
<DocumentSigningPageViewV2 /> <DocumentSigningPageViewV2 />
</EnvelopeRenderProvider> </EnvelopeRenderProvider>
</DocumentSigningAuthProvider> </DocumentSigningAuthProvider>
@@ -320,7 +320,7 @@ const EmbedDirectTemplatePageV2 = ({
user={user} user={user}
isDirectTemplate={true} isDirectTemplate={true}
> >
<EnvelopeRenderProvider envelope={envelope} token={recipient.token}> <EnvelopeRenderProvider version="current" envelope={envelope} token={recipient.token}>
<EmbedSignDocumentV2ClientPage <EmbedSignDocumentV2ClientPage
hidePoweredBy={hidePoweredBy} hidePoweredBy={hidePoweredBy}
allowWhitelabelling={allowEmbedSigningWhitelabel} allowWhitelabelling={allowEmbedSigningWhitelabel}
@@ -382,7 +382,7 @@ const EmbedSignDocumentPageV2 = ({
recipient={recipient} recipient={recipient}
user={user} user={user}
> >
<EnvelopeRenderProvider envelope={envelope} token={token}> <EnvelopeRenderProvider version="current" envelope={envelope} token={token}>
<EmbedSignDocumentV2ClientPage <EmbedSignDocumentV2ClientPage
hidePoweredBy={hidePoweredBy} hidePoweredBy={hidePoweredBy}
allowWhitelabelling={allowEmbedSigningWhitelabel} allowWhitelabelling={allowEmbedSigningWhitelabel}
+12
View File
@@ -23,6 +23,10 @@ import {
ZGetPresignedPostUrlRequestSchema, ZGetPresignedPostUrlRequestSchema,
ZUploadPdfRequestSchema, ZUploadPdfRequestSchema,
} from './files.types'; } from './files.types';
import getEnvelopeItemImageRoute from './routes/get-envelope-item-image';
import getEnvelopeItemImageByTokenRoute from './routes/get-envelope-item-image-by-token';
import getEnvelopeItemMetaRoute from './routes/get-envelope-item-meta';
import getEnvelopeItemMetaByTokenRoute from './routes/get-envelope-item-meta-by-token';
export const filesRoute = new Hono<HonoEnv>() export const filesRoute = new Hono<HonoEnv>()
/** /**
@@ -319,3 +323,11 @@ export const filesRoute = new Hono<HonoEnv>()
}); });
}, },
); );
// 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);
@@ -72,3 +72,23 @@ export const ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema = z.object({
export type TGetEnvelopeItemFileTokenDownloadRequestParams = z.infer< export type TGetEnvelopeItemFileTokenDownloadRequestParams = z.infer<
typeof ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema 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<typeof ZGetEnvelopeItemsMetaResponseSchema>;
@@ -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<HonoEnv>();
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;
@@ -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<HonoEnv>();
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<HonoEnv>;
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;
@@ -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<HonoEnv>();
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;
@@ -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<HonoEnv>();
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<HonoEnv>;
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;
-84
View File
@@ -27380,24 +27380,6 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@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": { "node_modules/map-stream": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz", "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz",
@@ -27844,23 +27826,6 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/merge-stream": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "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" "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": { "node_modules/react-promise-suspense": {
"version": "0.3.4", "version": "0.3.4",
"resolved": "https://registry.npmjs.org/react-promise-suspense/-/react-promise-suspense-0.3.4.tgz", "resolved": "https://registry.npmjs.org/react-promise-suspense/-/react-promise-suspense-0.3.4.tgz",
@@ -36561,15 +36488,6 @@
"node": ">=20.0.0" "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": { "node_modules/wcwidth": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
@@ -37513,7 +37431,6 @@
"posthog-js": "^1.297.2", "posthog-js": "^1.297.2",
"posthog-node": "4.18.0", "posthog-node": "4.18.0",
"react": "^18", "react": "^18",
"react-pdf": "^10.3.0",
"remeda": "^2.32.0", "remeda": "^2.32.0",
"sharp": "0.34.5", "sharp": "0.34.5",
"skia-canvas": "^3.0.8", "skia-canvas": "^3.0.8",
@@ -37671,7 +37588,6 @@
"react-day-picker": "^8.10.1", "react-day-picker": "^8.10.1",
"react-dom": "^18", "react-dom": "^18",
"react-hook-form": "^7.66.1", "react-hook-form": "^7.66.1",
"react-pdf": "^10.3.0",
"react-rnd": "^10.5.2", "react-rnd": "^10.5.2",
"remeda": "^2.32.0", "remeda": "^2.32.0",
"tailwind-merge": "^1.14.0", "tailwind-merge": "^1.14.0",
@@ -1,6 +1,7 @@
import type { Page } from '@playwright/test'; import type { Page } from '@playwright/test';
import { expect, test } 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 { prisma } from '@documenso/prisma';
import { seedBlankDocument } from '@documenso/prisma/seed/documents'; import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users'; 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 expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 100, y: 100,
@@ -54,7 +55,7 @@ test.describe('AutoSave Fields Step', () => {
}); });
await page.getByRole('button', { name: 'Text' }).click(); await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 200, 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('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 500, y: 500,
@@ -100,7 +101,7 @@ test.describe('AutoSave Fields Step', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 100, y: 100,
@@ -108,7 +109,7 @@ test.describe('AutoSave Fields Step', () => {
}); });
await page.getByRole('button', { name: 'Text' }).click(); await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 200, 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('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 500, y: 500,
@@ -162,7 +163,7 @@ test.describe('AutoSave Fields Step', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 100, y: 100,
@@ -170,7 +171,7 @@ test.describe('AutoSave Fields Step', () => {
}); });
await page.getByRole('button', { name: 'Text' }).click(); await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 200, 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('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 500, y: 500,
@@ -224,7 +225,7 @@ test.describe('AutoSave Fields Step', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 100, y: 100,
@@ -232,7 +233,7 @@ test.describe('AutoSave Fields Step', () => {
}); });
await page.getByRole('button', { name: 'Text' }).click(); await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 200, y: 200,
@@ -2,6 +2,7 @@ import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { EnvelopeType } from '@prisma/client'; 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 { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { seedBlankDocument } from '@documenso/prisma/seed/documents'; import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users'; 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: 'Continue' }).click();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 100, y: 100,
@@ -1,5 +1,6 @@
import { expect, test } from '@playwright/test'; 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 { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users'; import { seedUser } from '@documenso/prisma/seed/users';
@@ -33,14 +34,14 @@ test('[DOCUMENT_FLOW]: Simple duplicate recipients test', async ({ page }) => {
// Step 3: Add fields // Step 3: Add fields
await page.getByRole('button', { name: 'Signature' }).click(); 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.getByRole('combobox').first().click();
// Switch to second duplicate and add field // Switch to second duplicate and add field
await page.getByText('Duplicate 2 (duplicate@example.com)').first().click(); await page.getByText('Duplicate 2 (duplicate@example.com)').first().click();
await page.getByRole('button', { name: 'Signature' }).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 // Continue to send
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
@@ -44,21 +44,21 @@ const completeDocumentFlowWithDuplicateRecipients = async (options: {
// Step 3: Add fields for each recipient // Step 3: Add fields for each recipient
// Add signature field for first duplicate recipient // Add signature field for first duplicate recipient
await page.getByRole('button', { name: 'Signature' }).click(); 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(); await page.getByText('Duplicate Recipient 1 (duplicate@example.com)').click();
// Switch to second duplicate recipient and add their field // Switch to second duplicate recipient and add their field
await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click(); await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click();
await page.getByRole('button', { name: 'Signature' }).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(); await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click();
// Switch to unique recipient and add their field // Switch to unique recipient and add their field
await page.getByText('Unique Recipient (unique@example.com)').click(); await page.getByText('Unique Recipient (unique@example.com)').click();
await page.getByRole('button', { name: 'Signature' }).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 // Continue to subject
await page.getByRole('button', { name: 'Continue' }).click(); 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 expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click(); 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 // Save the document by going to subject
await page.getByRole('button', { name: 'Continue' }).click(); 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.getByText('Test Recipient Duplicate (test@example.com)').first().click();
await page.getByRole('button', { name: 'Signature' }).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 // Complete the flow
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
@@ -270,24 +270,24 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
// Add signature for first recipient // Add signature for first recipient
await page.getByRole('button', { name: 'Signature' }).click(); 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 // Add name field for second recipient
await page.getByRole('combobox').first().click(); await page.getByRole('combobox').first().click();
await page.getByText('Approver Role (signer@example.com)').first().click(); await page.getByText('Approver Role (signer@example.com)').first().click();
await page.getByRole('button', { name: 'Name' }).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 // Add date field for second recipient
await page.getByRole('button', { name: 'Date' }).click(); 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), // If second recipient is still a SIGNER (role change wasn't available),
// add a signature field for them to pass validation // add a signature field for them to pass validation
if (!secondRecipientIsApprover) { if (!secondRecipientIsApprover) {
await page.getByRole('button', { name: 'Signature' }).click(); 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 // Complete the document
@@ -349,7 +349,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
// Add another field to the second duplicate // Add another field to the second duplicate
await page.getByRole('button', { name: 'Name' }).click(); 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 // Save changes
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
@@ -9,6 +9,7 @@ import {
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import path from 'node:path'; import path from 'node:path';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { import {
seedBlankDocument, 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 expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 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.getByRole('button', { name: 'Email' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 200, 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 expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 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.getByRole('button', { name: 'Email' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 200, 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.getByText('User 2 (user2@example.com)').click();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 500, x: 500,
y: 100, 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.getByRole('button', { name: 'Email' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 500, x: 500,
y: 200, 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('option', { name: 'User 1 (user1@example.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 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.getByRole('button', { name: 'Email' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 200, 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('option', { name: 'User 3 (user3@example.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 500, x: 500,
y: 100, 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.getByRole('button', { name: 'Email' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 500, x: 500,
y: 200, 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.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 100 * i, y: 100 * i,
@@ -1,13 +1,12 @@
import { createCanvas } from '@napi-rs/canvas';
import type { TestInfo } from '@playwright/test'; import type { TestInfo } from '@playwright/test';
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { DocumentStatus, EnvelopeType } from '@prisma/client'; import { DocumentStatus, EnvelopeType } from '@prisma/client';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
import pixelMatch from 'pixelmatch'; import pixelMatch from 'pixelmatch';
import { PNG } from 'pngjs'; import { PNG } from 'pngjs';
import { pdfToImages } from '@documenso/lib/server-only/ai/pdf-to-images';
import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download'; import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { seedAlignmentTestDocument } from '@documenso/prisma/seed/initial-seed'; import { seedAlignmentTestDocument } from '@documenso/prisma/seed/initial-seed';
@@ -378,39 +377,14 @@ test.skip('download envelope images', async ({ page }) => {
}); });
async function renderPdfToImage(pdfBytes: Uint8Array) { async function renderPdfToImage(pdfBytes: Uint8Array) {
const loadingTask = pdfjsLib.getDocument({ data: pdfBytes });
const pdf = await loadingTask.promise;
// Increase for higher resolution // Increase for higher resolution
const scale = 4; const scale = 4;
return await Promise.all( return (await pdfToImages(pdfBytes, { scale, imageFormat: 'png' })).map((image) => ({
Array.from({ length: pdf.numPages }, async (_, index) => { image: image.image,
const page = await pdf.getPage(index + 1); width: Math.floor(image.scaledWidth),
height: Math.floor(image.scaledHeight),
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),
};
}),
);
} }
type CompareSignedPdfWithImagesOptions = { type CompareSignedPdfWithImagesOptions = {
@@ -42,21 +42,21 @@ const completeTemplateFlowWithDuplicateRecipients = async (options: {
// Step 3: Add fields for each recipient instance // Step 3: Add fields for each recipient instance
// Add signature field for first instance // Add signature field for first instance
await page.getByRole('button', { name: 'Signature' }).click(); 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 // Switch to second instance and add their field
await page.getByRole('combobox').first().click(); await page.getByRole('combobox').first().click();
await page.getByText('Second Instance').first().click(); await page.getByText('Second Instance').first().click();
await page.getByRole('button', { name: 'Signature' }).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 // Switch to different recipient and add their fields
await page.getByRole('combobox').first().click(); await page.getByRole('combobox').first().click();
await page.getByText('Different Recipient').first().click(); await page.getByText('Different Recipient').first().click();
await page.getByRole('button', { name: 'Signature' }).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.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 // Save template
await page.getByRole('button', { name: 'Save Template' }).click(); await page.getByRole('button', { name: 'Save Template' }).click();
@@ -209,17 +209,17 @@ test.describe('[TEMPLATE_FLOW]: Duplicate Recipients', () => {
// Add fields for each recipient // Add fields for each recipient
await page.getByRole('button', { name: 'Signature' }).click(); 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.getByRole('combobox').first().click();
await page.getByText('Duplicate Recipient 2').first().click(); await page.getByText('Duplicate Recipient 2').first().click();
await page.getByRole('button', { name: 'Date' }).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.getByRole('combobox').first().click();
await page.getByText('Different Recipient').first().click(); await page.getByText('Different Recipient').first().click();
await page.getByRole('button', { name: 'Name' }).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 // Save template
await page.getByRole('button', { name: 'Save Template' }).click(); 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('combobox').first().click();
await page.getByRole('option', { name: 'First Instance' }).first().click(); await page.getByRole('option', { name: 'First Instance' }).first().click();
await page.getByRole('button', { name: 'Name' }).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); await page.waitForTimeout(2500);
@@ -1,6 +1,7 @@
import type { Page } from '@playwright/test'; import type { Page } from '@playwright/test';
import { expect, test } 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 { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope'; import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates'; 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 expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 100, y: 100,
@@ -55,7 +56,7 @@ test.describe('AutoSave Fields Step', () => {
}); });
await page.getByRole('button', { name: 'Text' }).click(); await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 200, 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('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 500, y: 500,
@@ -110,7 +111,7 @@ test.describe('AutoSave Fields Step', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 100, y: 100,
@@ -118,7 +119,7 @@ test.describe('AutoSave Fields Step', () => {
}); });
await page.getByRole('button', { name: 'Text' }).click(); await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 200, 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('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 500, y: 500,
@@ -179,7 +180,7 @@ test.describe('AutoSave Fields Step', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 100, y: 100,
@@ -187,7 +188,7 @@ test.describe('AutoSave Fields Step', () => {
}); });
await page.getByRole('button', { name: 'Text' }).click(); await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 200, 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('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 500, y: 500,
@@ -250,7 +251,7 @@ test.describe('AutoSave Fields Step', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click(); await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 100, y: 100,
@@ -258,7 +259,7 @@ test.describe('AutoSave Fields Step', () => {
}); });
await page.getByRole('button', { name: 'Text' }).click(); await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({ await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: { position: {
x: 100, x: 100,
y: 200, y: 200,
@@ -1,4 +1,4 @@
export const getBoundingClientRect = (element: HTMLElement) => { export const getBoundingClientRect = (element: HTMLElement | Element) => {
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
const { width, height } = rect; const { width, height } = rect;
@@ -14,7 +14,10 @@ export const useDocumentElement = () => {
const target = event.target; const target = event.target;
const $page = const $page =
target.closest<HTMLElement>(pageSelector) ?? target.querySelector<HTMLElement>(pageSelector); target.closest<HTMLElement>(pageSelector) ??
document
.elementsFromPoint(event.clientX, event.clientY)
.find((el) => el.matches(pageSelector));
if (!$page) { if (!$page) {
return null; return null;
@@ -1,46 +1,45 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import Konva from 'konva'; 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; type RenderFunction = (props: { stage: Konva.Stage; pageLayer: Konva.Layer }) => void;
export function usePageRenderer(renderFunction: RenderFunction) { export function usePageRenderer(renderFunction: RenderFunction, pageData: PageRenderData) {
const pageContext = usePageContext(); 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<HTMLCanvasElement>(null);
const konvaContainer = useRef<HTMLDivElement>(null); const konvaContainer = useRef<HTMLDivElement>(null);
const stage = useRef<Konva.Stage | null>(null); const stage = useRef<Konva.Stage | null>(null);
const pageLayer = useRef<Konva.Layer | null>(null); const pageLayer = useRef<Konva.Layer | null>(null);
const [renderError, setRenderError] = useState<boolean>(false); const [renderStatus, setRenderStatus] = useState<'loading' | 'loaded' | 'error'>('loading');
/** /**
* The raw viewport with no scaling. Basically the actual PDF size. * The raw viewport with no scaling. Basically the actual PDF size.
*/ */
const unscaledViewport = useMemo( 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. * The viewport scaled according to page width.
*/ */
const scaledViewport = useMemo( 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. * in a higher resolution.
*/ */
const renderViewport = useMemo( 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( const imageProps = useMemo(
function drawPageOnCanvas() { (): React.ImgHTMLAttributes<HTMLImageElement> & Record<string, unknown> & { alt: '' } => ({
if (!page) { className: PDF_VIEWER_PAGE_CLASSNAME,
return; width: `${Math.floor(scaledViewport.width)}px`,
} height: `${Math.floor(scaledViewport.height)}px`,
alt: '',
const { current: canvas } = canvasElement; onLoad: () => setRenderStatus('loaded'),
const { current: kContainer } = konvaContainer; // 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,
if (!canvas || !kContainer) { src: imageUrl,
return; 'data-page-number': pageNumber,
} }),
[renderViewport, scaledViewport],
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],
); );
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 { return {
canvasElement,
konvaContainer, konvaContainer,
imageProps,
stage, stage,
pageLayer, pageLayer,
unscaledViewport, unscaledViewport,
scaledViewport, scaledViewport,
pageContext, renderStatus,
renderError, setRenderStatus,
setRenderError,
}; };
} }
@@ -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 React from 'react';
import type { Field, Recipient } from '@prisma/client'; 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 type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
import { AVAILABLE_RECIPIENT_COLORS } 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 { TEnvelope } from '../../types/envelope';
import type { FieldRenderMode } from '../../universal/field-renderer/render-field'; import type { FieldRenderMode } from '../../universal/field-renderer/render-field';
import { getEnvelopeItemPdfUrl } from '../../utils/envelope-download'; import { getEnvelopeItemMetaUrl, getEnvelopeItemPageImageUrl } from '../../utils/envelope-images';
type FileData = /**
| { * Number of pages to load eagerly on initial render.
status: 'loading' | 'error'; * Pages beyond this threshold will be loaded lazily when they enter the viewport.
} */
| { export const EAGER_LOAD_PAGE_COUNT = 5;
file: Uint8Array;
status: 'loaded'; 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 = { type EnvelopeRenderOverrideSettings = {
mode?: FieldRenderMode; mode?: FieldRenderMode;
@@ -28,8 +51,10 @@ type EnvelopeRenderOverrideSettings = {
type EnvelopeRenderItem = TEnvelope['envelopeItems'][number]; type EnvelopeRenderItem = TEnvelope['envelopeItems'][number];
type EnvelopeRenderProviderValue = { type EnvelopeRenderProviderValue = {
getPdfBuffer: (envelopeItemId: string) => FileData | null; version: DocumentDataVersion;
envelopeItems: EnvelopeRenderItem[]; envelopeItems: EnvelopeRenderItem[];
envelopeItemsMeta: Record<string, BasePageRenderData[]>;
envelopeItemsMetaLoadingState: ImageLoadingState;
envelopeStatus: TEnvelope['status']; envelopeStatus: TEnvelope['status'];
envelopeType: TEnvelope['type']; envelopeType: TEnvelope['type'];
currentEnvelopeItem: EnvelopeRenderItem | null; currentEnvelopeItem: EnvelopeRenderItem | null;
@@ -46,7 +71,12 @@ type EnvelopeRenderProviderValue = {
interface EnvelopeRenderProviderProps { interface EnvelopeRenderProviderProps {
children: React.ReactNode; children: React.ReactNode;
envelope: Pick<TEnvelope, 'envelopeItems' | 'status' | 'type'>; /**
* The envelope item version to render.
*/
version: DocumentDataVersion;
envelope: Pick<TEnvelope, 'id' | 'envelopeItems' | 'status' | 'type'>;
/** /**
* Optional fields which are passed down to renderers for custom rendering needs. * 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 = ({ export const EnvelopeRenderProvider = ({
children, children,
@@ -97,71 +127,107 @@ export const EnvelopeRenderProvider = ({
fields, fields,
token, token,
recipients = [], recipients = [],
version,
overrideSettings, overrideSettings,
}: EnvelopeRenderProviderProps) => { }: EnvelopeRenderProviderProps) => {
// Indexed by documentDataId. // Indexed by envelope item ID.
const [files, setFiles] = useState<Record<string, FileData>>({}); const [envelopeItemsMeta, setEnvelopeItemsMeta] = useState<Record<string, BasePageRenderData[]>>(
{},
);
const [currentItem, setCurrentItem] = useState<EnvelopeRenderItem | null>(null); const [envelopeItemsMetaLoadingState, setEnvelopeItemsMetaLoadingState] =
useState<ImageLoadingState>('loading');
const [renderError, setRenderError] = useState<boolean>(false); const [renderError, setRenderError] = useState<boolean>(false);
// Track the timestamp of the most recent fetch to prevent race conditions
const fetchStartedAtRef = useRef<number>(0);
const envelopeItems = useMemo( const envelopeItems = useMemo(
() => envelope.envelopeItems.sort((a, b) => a.order - b.order), () => envelope.envelopeItems.sort((a, b) => a.order - b.order),
[envelope.envelopeItems], [envelope.envelopeItems],
); );
const loadEnvelopeItemPdfFile = async (envelopeItem: EnvelopeRenderItem) => { const [currentItem, setCurrentItem] = useState<EnvelopeRenderItem | null>(
if (files[envelopeItem.id]?.status === 'loading') { 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; return;
} }
if (!files[envelopeItem.id]) { // Record when this fetch started to detect stale responses
setFiles((prev) => ({ const fetchStartedAt = Date.now();
...prev, fetchStartedAtRef.current = fetchStartedAt;
[envelopeItem.id]: {
status: 'loading', setEnvelopeItemsMetaLoadingState('loading');
},
}));
}
try { try {
const downloadUrl = getEnvelopeItemPdfUrl({ // Fetch metadata for all envelope items
type: 'view', const metaUrl = getEnvelopeItemMetaUrl({
envelopeItem: envelopeItem, envelopeId: envelope.id,
token, 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) => ({ const data: TGetEnvelopeItemsMetaResponse = await response.json();
...prev,
[envelopeItem.id]: { // Check again after parsing JSON in case a newer fetch started
file: new Uint8Array(file), if (fetchStartedAtRef.current !== fetchStartedAt) {
status: 'loaded', return;
}, }
}));
// Build a map of envelope items by ID
const metaMap: Record<string, BasePageRenderData[]> = {};
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) { } catch (error) {
console.error(error); // Only set error state if this is still the most recent fetch
if (fetchStartedAtRef.current === fetchStartedAt) {
setFiles((prev) => ({ console.error('Failed to load envelope data:', error);
...prev, setEnvelopeItemsMetaLoadingState('error');
[envelopeItem.id]: { }
status: 'error',
},
}));
} }
}; };
const getPdfBuffer = useCallback(
(envelopeItemId: string) => {
return files[envelopeItemId] || null;
},
[files],
);
const setCurrentEnvelopeItem = (envelopeItemId: string) => { const setCurrentEnvelopeItem = (envelopeItemId: string) => {
const foundItem = envelope.envelopeItems.find((item) => item.id === envelopeItemId); const foundItem = envelope.envelopeItems.find((item) => item.id === envelopeItemId);
@@ -179,15 +245,6 @@ export const EnvelopeRenderProvider = ({
} }
}, [currentItem, envelopeItems]); }, [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( const recipientIds = useMemo(
() => recipients.map((recipient) => recipient.id).sort(), () => recipients.map((recipient) => recipient.id).sort(),
[recipients], [recipients],
@@ -207,7 +264,9 @@ export const EnvelopeRenderProvider = ({
return ( return (
<EnvelopeRenderContext.Provider <EnvelopeRenderContext.Provider
value={{ value={{
getPdfBuffer, version,
envelopeItemsMeta,
envelopeItemsMetaLoadingState,
envelopeItems, envelopeItems,
envelopeStatus: envelope.status, envelopeStatus: envelope.status,
envelopeType: envelope.type, envelopeType: envelope.type,
+22
View File
@@ -0,0 +1,22 @@
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
// This is separate from the pdf-viewer.ts constant file due to parsing issues during testing.
export const PDF_VIEWER_ERROR_MESSAGES = {
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.`,
},
default: {
title: msg`Configuration Error`,
description: msg`Something went wrong while rendering the document, please try again or contact our support.`,
},
} satisfies Record<string, { title: MessageDescriptor; description: MessageDescriptor }>;
+7 -1
View File
@@ -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_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;
@@ -242,18 +242,13 @@ export const run = async ({
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
for (const { oldDocumentDataId, newDocumentDataId } of newDocumentData) { for (const { oldDocumentDataId, newDocumentDataId } of newDocumentData) {
const newData = await tx.documentData.findFirstOrThrow({ await tx.envelopeItem.update({
where: { where: {
id: newDocumentDataId, envelopeId: envelope.id,
}, documentDataId: oldDocumentDataId,
});
await tx.documentData.update({
where: {
id: oldDocumentDataId,
}, },
data: { data: {
data: newData.data, documentDataId: newDocumentDataId,
}, },
}); });
} }
-1
View File
@@ -55,7 +55,6 @@
"posthog-js": "^1.297.2", "posthog-js": "^1.297.2",
"posthog-node": "4.18.0", "posthog-node": "4.18.0",
"react": "^18", "react": "^18",
"react-pdf": "^10.3.0",
"remeda": "^2.32.0", "remeda": "^2.32.0",
"sharp": "0.34.5", "sharp": "0.34.5",
"skia-canvas": "^3.0.8", "skia-canvas": "^3.0.8",
@@ -162,8 +162,8 @@ export const detectFieldsFromPdf = async ({
// Mask existing fields on the image // Mask existing fields on the image
const maskedImage = await maskFieldsOnImage({ const maskedImage = await maskFieldsOnImage({
image: page.image, image: page.image,
width: page.width, width: page.scaledWidth,
height: page.height, height: page.scaledHeight,
fields: fieldsOnPage, fields: fieldsOnPage,
}); });
+95 -32
View File
@@ -1,7 +1,11 @@
import pMap from 'p-map'; import pMap from 'p-map';
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs'; 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 { Canvas, Image, Path2D } from 'skia-canvas';
import { PDF_IMAGE_RENDER_SCALE } from '../../constants/pdf-viewer';
// @ts-expect-error napi-rs/canvas satisfies the requirements // @ts-expect-error napi-rs/canvas satisfies the requirements
globalThis.Path2D = Path2D; globalThis.Path2D = Path2D;
// @ts-expect-error napi-rs/canvas satisfies the requirements // @ts-expect-error napi-rs/canvas satisfies the requirements
@@ -42,10 +46,17 @@ class SkiaCanvasFactory {
export type PdfToImagesOptions = { export type PdfToImagesOptions = {
scale?: number; scale?: number;
/**
* The format of the images to return.
*
* Defaults to 'jpeg'.
*/
imageFormat?: ExportFormat;
}; };
export const pdfToImages = async (pdfBytes: Uint8Array, options: PdfToImagesOptions = {}) => { 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({ const task = await pdfjsLib.getDocument({
data: pdfBytes, data: pdfBytes,
@@ -56,37 +67,7 @@ export const pdfToImages = async (pdfBytes: Uint8Array, options: PdfToImagesOpti
const images = await pMap( const images = await pMap(
Array.from({ length: pdf.numPages }), Array.from({ length: pdf.numPages }),
async (_, index) => { async (_, pageIndex) => getPdfPageAsImage({ pdf, pageIndex, scale, imageFormat }),
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;
},
{ concurrency: 10 }, { concurrency: 10 },
); );
@@ -95,3 +76,85 @@ export const pdfToImages = async (pdfBytes: Uint8Array, options: PdfToImagesOpti
return images; 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;
};
@@ -310,7 +310,7 @@ export const sendDocument = async ({
const injectFormValuesIntoDocument = async ( const injectFormValuesIntoDocument = async (
envelope: Envelope, envelope: Envelope,
envelopeItem: Pick<EnvelopeItem, 'id'> & { documentData: DocumentData }, envelopeItem: Pick<EnvelopeItem, 'id'> & { documentData: Omit<DocumentData, 'metadata'> },
) => { ) => {
const file = await getFileServerSide(envelopeItem.documentData); const file = await getFileServerSide(envelopeItem.documentData);
+18
View File
@@ -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<typeof ZDocumentDataMetaSchema>;
export type DocumentDataVersion = 'initial' | 'current';
@@ -1,13 +1,19 @@
import { PDF } from '@libpdf/core'; import { PDF } from '@libpdf/core';
import { DocumentDataType } from '@prisma/client'; import { DocumentDataType } from '@prisma/client';
import { base64 } from '@scure/base'; import { base64 } from '@scure/base';
import pMap from 'p-map';
import { match } from 'ts-pattern'; 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 { env } from '@documenso/lib/utils/env';
import { prisma } from '@documenso/prisma';
import { AppError } from '../../errors/app-error'; 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 { createDocumentData } from '../../server-only/document-data/create-document-data';
import { normalizePdf } from '../../server-only/pdf/normalize-pdf'; import { normalizePdf } from '../../server-only/pdf/normalize-pdf';
import { getEnvelopeItemPageImageS3Key } from '../../utils/envelope-images';
import { uploadS3File } from './server-actions'; import { uploadS3File } from './server-actions';
type File = { type File = {
@@ -41,7 +47,67 @@ export const putPdfFileServerSide = async (file: File) => {
const { type, data } = await putFileServerSide(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), arrayBuffer: async () => Promise.resolve(normalized),
}); });
return await createDocumentData({ const newDocumentData = await createDocumentData({
type: documentData.type, type: documentData.type,
data: documentData.data, data: documentData.data,
}); });
void extractAndStorePdfImages(normalized, newDocumentData.id);
return newDocumentData;
}; };
/** /**
@@ -91,13 +91,13 @@ export const getPresignGetUrl = async (key: string) => {
/** /**
* Uploads a file to S3. * Uploads a file to S3.
*/ */
export const uploadS3File = async (file: File) => { export const uploadS3File = async (file: File, keyOverride?: string) => {
const client = getS3Client(); const client = getS3Client();
// Get the basename and extension for the file // Get the basename and extension for the file
const { name, ext } = path.parse(file.name); 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(); 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 getS3Client = () => {
const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT'); const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT');
+77
View File
@@ -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`;
};
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "DocumentData" ADD COLUMN "metadata" JSONB;
+2
View File
@@ -487,11 +487,13 @@ enum DocumentSigningOrder {
SEQUENTIAL SEQUENTIAL
} }
/// @zod.import(["import { ZDocumentDataMetaSchema } from '@documenso/lib/types/document-data';"])
model DocumentData { model DocumentData {
id String @id @default(cuid()) id String @id @default(cuid())
type DocumentDataType type DocumentDataType
data String data String
initialData String initialData String
metadata Json? /// [DocumentDataMeta] @zod.custom.use(ZDocumentDataMetaSchema)
envelopeItem EnvelopeItem? envelopeItem EnvelopeItem?
} }
+2
View File
@@ -4,6 +4,7 @@ import type {
TDocumentAuthOptions, TDocumentAuthOptions,
TRecipientAuthOptions, TRecipientAuthOptions,
} from '@documenso/lib/types/document-auth'; } 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 { TDocumentEmailSettings } from '@documenso/lib/types/document-email';
import type { TDocumentFormValues } from '@documenso/lib/types/document-form-values'; import type { TDocumentFormValues } from '@documenso/lib/types/document-form-values';
import type { TEnvelopeAttachmentType } from '@documenso/lib/types/envelope-attachment'; import type { TEnvelopeAttachmentType } from '@documenso/lib/types/envelope-attachment';
@@ -29,6 +30,7 @@ declare global {
type EnvelopeAttachmentType = TEnvelopeAttachmentType; type EnvelopeAttachmentType = TEnvelopeAttachmentType;
type DefaultRecipient = TDefaultRecipient; type DefaultRecipient = TDefaultRecipient;
type DocumentDataMeta = TDocumentDataMeta;
} }
} }
@@ -28,7 +28,14 @@ export const getDocumentByTokenRoute = authenticatedProcedure
include: { include: {
envelopeItems: { envelopeItems: {
include: { include: {
documentData: true, documentData: {
select: {
id: true,
type: true,
data: true,
initialData: true,
},
},
}, },
}, },
}, },
@@ -7,7 +7,12 @@ export const ZGetDocumentByTokenRequestSchema = z.object({
}); });
export const ZGetDocumentByTokenResponseSchema = 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<typeof ZGetDocumentByTokenRequestSchema>; export type TGetDocumentByTokenRequest = z.infer<typeof ZGetDocumentByTokenRequestSchema>;
@@ -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<HTMLDivElement>;
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<HTMLDivElement>;
export const EnvelopePdfViewer = ({
className,
scrollParentRef,
onDocumentLoad,
customPageRenderer: CustomPageRenderer,
errorMessage,
...props
}: EnvelopePdfViewerProps) => {
const { t } = useLingui();
const $el = useRef<HTMLDivElement>(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 (
<div ref={$el} className={cn('h-full w-full max-w-[800px]', className)} {...props}>
{renderError && (
<Alert variant="destructive" className="mb-4 max-w-[800px]">
<AlertTitle>
{t(errorMessage?.title || PDF_VIEWER_ERROR_MESSAGES.default.title)}
</AlertTitle>
<AlertDescription>
{t(errorMessage?.description || PDF_VIEWER_ERROR_MESSAGES.default.description)}
</AlertDescription>
</Alert>
)}
{/* Loading State */}
{isLoading && <PdfViewerLoadingState />}
{/* Error State */}
{hasError && <PdfViewerErrorState />}
{/* Render pages in a virtualized list. */}
{envelopeItemsMetaLoadingState === 'loaded' &&
currentEnvelopeItem &&
currentItemMeta &&
numPages > 0 && (
<VirtualizedPageList
scrollParentRef={scrollParentRef ?? $el}
constraintRef={$el}
pages={currentItemMeta}
envelopeItemId={currentEnvelopeItem.id}
numPages={numPages}
CustomPageRenderer={CustomPageRenderer}
/>
)}
{/* No current item selected */}
{envelopeItemsMetaLoadingState === 'loaded' && !currentEnvelopeItem && (
<div
className={cn(
'flex h-[80vh] max-h-[60rem] w-full flex-col items-center justify-center overflow-hidden rounded',
)}
>
<p className="text-sm text-muted-foreground">
<Trans>No document selected</Trans>
</p>
</div>
)}
</div>
);
};
type VirtualizedPageListProps = {
scrollParentRef: React.RefObject<HTMLDivElement>;
constraintRef: React.RefObject<HTMLDivElement>;
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 (
<div
style={{
height: `${totalSize}px`,
width: '100%',
position: 'relative',
}}
>
{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 (
<div
key={envelopeItemId + '-' + virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: constraintWidth,
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
<div className="rounded border border-border">
{CustomPageRenderer ? (
<CustomPageRenderer pageData={pageData} />
) : (
<ImagePageRenderer pageData={pageData} />
)}
</div>
<p className="my-2 text-center text-[11px] text-muted-foreground/80">
<Trans>
Page {pageNumber} of {numPages}
</Trans>
</p>
</div>
);
})}
</div>
);
};
const ImagePageRenderer = ({ pageData }: { pageData: PageRenderData }) => {
const { pageWidth, pageHeight, scale, imageUrl, pageNumber } = pageData;
const scaledWidth = pageWidth * scale;
const scaledHeight = pageHeight * scale;
return (
<div className="relative w-full" style={{ width: scaledWidth, height: scaledHeight }}>
<img
data-page-number={pageNumber}
src={imageUrl}
alt=""
className={cn(PDF_VIEWER_PAGE_CLASSNAME, 'absolute inset-0 z-0 block')}
style={{
width: scaledWidth,
height: scaledHeight,
}}
draggable={false}
loading="lazy"
/>
</div>
);
};
export default EnvelopePdfViewer;
@@ -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<React.HTMLAttributes<HTMLDivElement>, 'onPageClick'>;
const EnvelopePdfViewer = lazy(async () => import('./pdf-viewer-konva'));
export const PDFViewerKonvaLazy = (props: PDFViewerProps) => {
return (
<Suspense fallback={<div>Loading...</div>}>
<EnvelopePdfViewer {...props} />
</Suspense>
);
};
export default PDFViewerKonvaLazy;
@@ -1,213 +0,0 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import Konva from 'konva';
import { Loader } from 'lucide-react';
import { type PDFDocumentProxy } from 'pdfjs-dist';
import { Document as PDFDocument, Page as PDFPage, pdfjs } from 'react-pdf';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
export type LoadedPDFDocument = PDFDocumentProxy;
/**
* This imports the worker from the `pdfjs-dist` package.
*/
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/legacy/build/pdf.worker.min.mjs',
import.meta.url,
).toString();
const pdfViewerOptions = {
cMapUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/static/cmaps/`,
};
const PDFLoader = () => (
<>
<Loader className="h-12 w-12 animate-spin text-documenso" />
<p className="mt-4 text-muted-foreground">
<Trans>Loading document...</Trans>
</p>
</>
);
export type PdfViewerRendererMode = 'editor' | 'preview' | 'signing';
const RendererErrorMessages: Record<
PdfViewerRendererMode,
{ title: MessageDescriptor; description: MessageDescriptor }
> = {
editor: {
title: msg`Configuration Error`,
description: msg`There was an issue rendering some fields, please review the fields and try again.`,
},
preview: {
title: msg`Configuration Error`,
description: msg`Something went wrong while rendering the document, some fields may be missing or corrupted.`,
},
signing: {
title: msg`Configuration Error`,
description: msg`Something went wrong while rendering the document, some fields may be missing or corrupted.`,
},
};
export type PdfViewerKonvaProps = {
className?: string;
onDocumentLoad?: () => void;
customPageRenderer?: React.FunctionComponent;
renderer: PdfViewerRendererMode;
[key: string]: unknown;
} & Omit<React.HTMLAttributes<HTMLDivElement>, 'onPageClick'>;
export const PdfViewerKonva = ({
className,
onDocumentLoad,
customPageRenderer,
renderer,
...props
}: PdfViewerKonvaProps) => {
const { t } = useLingui();
const $el = useRef<HTMLDivElement>(null);
const { getPdfBuffer, currentEnvelopeItem, renderError } = useCurrentEnvelopeRender();
const [width, setWidth] = useState(0);
const [numPages, setNumPages] = useState(0);
const [pdfError, setPdfError] = useState(false);
const envelopeItemFile = useMemo(() => {
const data = getPdfBuffer(currentEnvelopeItem?.id || '');
if (!data || data.status !== 'loaded') {
return null;
}
return {
data: new Uint8Array(data.file),
};
}, [currentEnvelopeItem?.id, getPdfBuffer]);
const onDocumentLoaded = useCallback(
(doc: PDFDocumentProxy) => {
setNumPages(doc.numPages);
},
[onDocumentLoad],
);
useEffect(() => {
if ($el.current) {
const $current = $el.current;
const { width } = $current.getBoundingClientRect();
setWidth(width);
const onResize = () => {
const { width } = $current.getBoundingClientRect();
setWidth(width);
};
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
};
}
}, []);
return (
<div ref={$el} className={cn('w-full max-w-[800px]', className)} {...props}>
{renderError && (
<Alert variant="destructive" className="mb-4 max-w-[800px]">
<AlertTitle>{t(RendererErrorMessages[renderer].title)}</AlertTitle>
<AlertDescription>{t(RendererErrorMessages[renderer].description)}</AlertDescription>
</Alert>
)}
{envelopeItemFile && Konva ? (
<PDFDocument
file={envelopeItemFile}
className={cn('w-full rounded', {
'h-[80vh] max-h-[60rem]': numPages === 0,
})}
onLoadSuccess={(d) => onDocumentLoaded(d)}
// Uploading a invalid document causes an error which doesn't appear to be handled by the `error` prop.
// Therefore we add some additional custom error handling.
onSourceError={() => {
setPdfError(true);
}}
externalLinkTarget="_blank"
loading={
<div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center bg-white/50 dark:bg-background">
{pdfError ? (
<div className="text-center text-muted-foreground">
<p>
<Trans>Something went wrong while loading the document.</Trans>
</p>
<p className="mt-1 text-sm">
<Trans>Please try again or contact our support.</Trans>
</p>
</div>
) : (
<PDFLoader />
)}
</div>
}
error={
<div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center bg-white/50 dark:bg-background">
<div className="text-center text-muted-foreground">
<p>
<Trans>Something went wrong while loading the document.</Trans>
</p>
<p className="mt-1 text-sm">
<Trans>Please try again or contact our support.</Trans>
</p>
</div>
</div>
}
options={pdfViewerOptions}
>
{Array(numPages)
.fill(null)
.map((_, i) => (
<div key={i} className="last:-mb-2">
<div className="rounded border border-border will-change-transform">
<PDFPage
pageNumber={i + 1}
width={width}
renderAnnotationLayer={false}
renderTextLayer={false}
loading={() => ''}
renderMode={customPageRenderer ? 'custom' : 'canvas'}
customRenderer={customPageRenderer}
/>
</div>
<p className="my-2 text-center text-[11px] text-muted-foreground/80">
<Trans>
Page {i + 1} of {numPages}
</Trans>
</p>
</div>
))}
</PDFDocument>
) : (
<div
className={cn(
'flex h-[80vh] max-h-[60rem] w-full flex-col items-center justify-center overflow-hidden rounded',
)}
>
<PDFLoader />
</div>
)}
</div>
);
};
export default PdfViewerKonva;
@@ -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 (
<div
className={cn(
'flex h-[80vh] max-h-[60rem] w-full flex-col items-center justify-center overflow-hidden',
)}
>
<Spinner />
</div>
);
};
export const PdfViewerErrorState = () => {
return (
<div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center bg-white/50 dark:bg-background">
<div className="text-center text-muted-foreground">
<p>
<Trans>Something went wrong while loading the document.</Trans>
</p>
<p className="mt-1 text-sm">
<Trans>Please try again or contact our support.</Trans>
</p>
</div>
</div>
);
};
@@ -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<HTMLDivElement, MouseEvent>;
pageHeight: number;
pageWidth: number;
pageX: number;
pageY: number;
}) => void | Promise<void>;
type PageMeta = {
imageUrl: string;
width: number;
height: number;
documentDataId: string;
};
type LoadingState = 'loading' | 'loaded' | 'error';
export type PDFViewerProps = {
className?: string;
envelopeItem: Pick<EnvelopeItem, 'id' | 'envelopeId'>;
token: string | undefined;
presignToken?: string | undefined;
version: DocumentDataVersion;
onDocumentLoad?: () => void;
overrideImages?: OverrideImage[];
} & React.HTMLAttributes<HTMLDivElement>;
export const PDFViewer = ({
className,
envelopeItem,
token,
presignToken,
version,
onDocumentLoad,
overrideImages,
...props
}: PDFViewerProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const $el = useRef<HTMLDivElement>(null);
const [loadingState, setLoadingState] = useState<LoadingState>(
overrideImages ? 'loaded' : 'loading',
);
const [pages, setPages] = useState<PageMeta[]>([]);
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 (
<div ref={$el} className={cn('h-full w-full overflow-hidden', className)} {...props}>
{/* Loading State */}
{isLoading && <PdfViewerLoadingState />}
{/* Error State */}
{hasError && <PdfViewerErrorState />}
{/* Loaded State */}
{loadingState === 'loaded' && numPages > 0 && (
<VirtualizedPageList
scrollParentRef={$el}
constraintRef={$el}
numPages={numPages}
pages={derivedPages}
/>
)}
</div>
);
};
type VirtualizedPageListProps = {
scrollParentRef: React.RefObject<HTMLDivElement>;
constraintRef: React.RefObject<HTMLDivElement>;
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 (
<div
style={{
height: `${totalSize}px`,
width: '100%',
position: 'relative',
}}
>
{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 (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: constraintWidth,
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
<div className="overflow-hidden rounded border border-border">
<div className="relative w-full" style={{ width: scaledWidth, height: scaledHeight }}>
<img
data-page-number={pageNumber}
src={pageMeta.imageUrl}
alt=""
className={cn(PDF_VIEWER_PAGE_CLASSNAME, 'absolute inset-0 z-0 block')}
style={{
width: scaledWidth,
height: scaledHeight,
}}
draggable={false}
loading="lazy"
/>
</div>
</div>
<p className="my-2 text-center text-[11px] text-muted-foreground/80">
<Trans>
Page {pageNumber} of {numPages}
</Trans>
</p>
</div>
);
})}
</div>
);
};
export default PDFViewer;
@@ -0,0 +1,211 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
export type VirtualListOptions = {
scrollRef: React.RefObject<HTMLElement | null>;
constraintRef?: React.RefObject<HTMLElement | null>;
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,
};
};
+1 -2
View File
@@ -66,14 +66,13 @@
"framer-motion": "^12.23.24", "framer-motion": "^12.23.24",
"lucide-react": "^0.554.0", "lucide-react": "^0.554.0",
"luxon": "^3.7.2", "luxon": "^3.7.2",
"perfect-freehand": "^1.2.2",
"pdfjs-dist": "5.4.296", "pdfjs-dist": "5.4.296",
"perfect-freehand": "^1.2.2",
"react": "^18", "react": "^18",
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",
"react-day-picker": "^8.10.1", "react-day-picker": "^8.10.1",
"react-dom": "^18", "react-dom": "^18",
"react-hook-form": "^7.66.1", "react-hook-form": "^7.66.1",
"react-pdf": "^10.3.0",
"react-rnd": "^10.5.2", "react-rnd": "^10.5.2",
"remeda": "^2.32.0", "remeda": "^2.32.0",
"tailwind-merge": "^1.14.0", "tailwind-merge": "^1.14.0",
@@ -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;
-290
View File
@@ -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<HTMLDivElement, MouseEvent>;
pageHeight: number;
pageWidth: number;
pageX: number;
pageY: number;
}) => void | Promise<void>;
const PDFLoader = () => (
<>
<Loader className="h-12 w-12 animate-spin text-documenso" />
<p className="mt-4 text-muted-foreground">
<Trans>Loading document...</Trans>
</p>
</>
);
export type PDFViewerProps = {
className?: string;
envelopeItem: Pick<EnvelopeItem, 'id' | 'envelopeId'>;
token: string | undefined;
presignToken?: string | undefined;
version: 'original' | 'signed';
onDocumentLoad?: (_doc: LoadedPDFDocument) => void;
onPageClick?: OnPDFViewerPageClick;
overrideData?: string;
customPageRenderer?: React.FunctionComponent;
[key: string]: unknown;
} & Omit<React.HTMLAttributes<HTMLDivElement>, 'onPageClick'>;
export const PDFViewer = ({
className,
envelopeItem,
token,
presignToken,
version,
onDocumentLoad,
onPageClick,
overrideData,
customPageRenderer,
...props
}: PDFViewerProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const $el = useRef<HTMLDivElement>(null);
const [isDocumentBytesLoading, setIsDocumentBytesLoading] = useState(false);
const [documentBytes, setDocumentBytes] = useState<Uint8Array | null>(
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<HTMLDivElement, 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 (
<div ref={$el} className={cn('overflow-hidden', className)} {...props}>
{isLoading ? (
<div
className={cn(
'flex h-[80vh] max-h-[60rem] w-full flex-col items-center justify-center overflow-hidden rounded',
)}
>
<PDFLoader />
</div>
) : (
<>
<PDFDocument
file={envelopeItemFile}
className={cn('w-full overflow-hidden rounded', {
'h-[80vh] max-h-[60rem]': numPages === 0,
})}
onLoadSuccess={(d) => onDocumentLoaded(d)}
// Uploading a invalid document causes an error which doesn't appear to be handled by the `error` prop.
// Therefore we add some additional custom error handling.
onSourceError={() => {
setPdfError(true);
}}
externalLinkTarget="_blank"
loading={
<div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center bg-white/50 dark:bg-background">
{pdfError ? (
<div className="text-center text-muted-foreground">
<p>
<Trans>Something went wrong while loading the document.</Trans>
</p>
<p className="mt-1 text-sm">
<Trans>Please try again or contact our support.</Trans>
</p>
</div>
) : (
<PDFLoader />
)}
</div>
}
error={
<div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center bg-white/50 dark:bg-background">
<div className="text-center text-muted-foreground">
<p>
<Trans>Something went wrong while loading the document.</Trans>
</p>
<p className="mt-1 text-sm">
<Trans>Please try again or contact our support.</Trans>
</p>
</div>
</div>
}
options={pdfViewerOptions}
>
{Array(numPages)
.fill(null)
.map((_, i) => (
<div key={i} className="last:-mb-2">
<div className="overflow-hidden rounded border border-border will-change-transform">
<PDFPage
pageNumber={i + 1}
width={width}
renderAnnotationLayer={false}
renderTextLayer={false}
loading={() => ''}
renderMode={customPageRenderer ? 'custom' : 'canvas'}
customRenderer={customPageRenderer}
onClick={(e) => onDocumentPageClick(e, i + 1)}
/>
</div>
<p className="my-2 text-center text-[11px] text-muted-foreground/80">
<Trans>
Page {i + 1} of {numPages}
</Trans>
</p>
</div>
))}
</PDFDocument>
</>
)}
</div>
);
};
export default PDFViewer;
@@ -1 +0,0 @@
export * from './base';
@@ -1,7 +0,0 @@
import { ClientOnly } from '../../components/client-only';
import { PDFViewer, type PDFViewerProps } from './base.client';
export const PDFViewerLazy = (props: PDFViewerProps) => {
return <ClientOnly fallback={<div>Loading...</div>}>{() => <PDFViewer {...props} />}</ClientOnly>;
};