mirror of
https://github.com/documenso/documenso.git
synced 2026-07-13 22:37:24 +10:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 18d092f415 | |||
| 6425b242f0 | |||
| 8e8f57661c | |||
| 653d340668 | |||
| 9a2f3747db | |||
| 84deea11e4 | |||
| 5fa4c42098 | |||
| ab3e8a4074 | |||
| cb6d6e46d0 | |||
| c20affa286 | |||
| a69fe940b5 | |||
| 8186d2817f | |||
| 4fb3c2cb0f |
@@ -115,7 +115,7 @@ export function AssistantConfirmationDialog({
|
|||||||
<div className="mt-4 flex flex-col gap-4">
|
<div className="mt-4 flex flex-col gap-4">
|
||||||
{!isEditingNextSigner && (
|
{!isEditingNextSigner && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-sm text-muted-foreground">
|
||||||
<Trans>
|
<Trans>
|
||||||
The next recipient to sign this document will be{' '}
|
The next recipient to sign this document will be{' '}
|
||||||
<span className="font-semibold">{form.watch('name')}</span> (
|
<span className="font-semibold">{form.watch('name')}</span> (
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { useRef } from 'react';
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
@@ -5,6 +7,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 +16,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';
|
||||||
@@ -38,6 +40,8 @@ export const DocumentDuplicateDialog = ({
|
|||||||
|
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const { data: envelopeItemsPayload, isLoading: isLoadingEnvelopeItems } =
|
const { data: envelopeItemsPayload, isLoading: isLoadingEnvelopeItems } =
|
||||||
trpcReact.envelope.item.getManyByToken.useQuery(
|
trpcReact.envelope.item.getManyByToken.useQuery(
|
||||||
{
|
{
|
||||||
@@ -95,12 +99,13 @@ export const DocumentDuplicateDialog = ({
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll">
|
<div ref={scrollContainerRef} className="h-[50vh] overflow-y-scroll p-2">
|
||||||
<PDFViewerLazy
|
<PDFViewer
|
||||||
key={envelopeItems[0].id}
|
key={envelopeItems[0].id}
|
||||||
envelopeItem={envelopeItems[0]}
|
envelopeItem={envelopeItems[0]}
|
||||||
token={undefined}
|
token={undefined}
|
||||||
version="original"
|
version="initial"
|
||||||
|
scrollParentRef={scrollContainerRef}
|
||||||
/>
|
/>
|
||||||
</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 { pdfToImagesClientSide } from '@documenso/lib/server-only/ai/pdf-to-images.client';
|
||||||
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,17 @@ 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 pdfToImagesClientSide(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,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-populate title if it's empty
|
// Auto-populate title if it's empty
|
||||||
@@ -144,7 +151,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 +200,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.string(),
|
||||||
|
})
|
||||||
|
.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(() => {
|
||||||
@@ -179,8 +178,6 @@ export const ConfigureFieldsView = ({
|
|||||||
name: 'fields',
|
name: 'fields',
|
||||||
});
|
});
|
||||||
|
|
||||||
const highestPageNumber = Math.max(...localFields.map((field) => field.pageNumber));
|
|
||||||
|
|
||||||
const onFieldCopy = useCallback(
|
const onFieldCopy = useCallback(
|
||||||
(event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => {
|
(event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => {
|
||||||
const { duplicate = false, duplicateAll = false } = options ?? {};
|
const { duplicate = false, duplicateAll = false } = options ?? {};
|
||||||
@@ -548,17 +545,16 @@ 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"
|
||||||
|
scrollParentRef="window"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ElementVisible
|
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
|
|
||||||
>
|
|
||||||
{localFields.map((field, index) => {
|
{localFields.map((field, index) => {
|
||||||
const recipientIndex = recipients.findIndex((r) => r.id === field.recipientId);
|
const recipientIndex = recipients.findIndex((r) => r.id === field.recipientId);
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Loader } from 'lucide-react';
|
|||||||
|
|
||||||
export const EmbedClientLoading = () => {
|
export const EmbedClientLoading = () => {
|
||||||
return (
|
return (
|
||||||
<div className="bg-background fixed left-0 top-0 z-[9999] flex h-full w-full items-center justify-center">
|
<div className="fixed left-0 top-0 z-[9999] flex h-full w-full items-center justify-center bg-background">
|
||||||
<Loader className="mr-2 h-4 w-4 animate-spin" />
|
<Loader className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
|
||||||
<span>
|
<span>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
@@ -101,8 +101,6 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
localFields.filter((field) => field.inserted),
|
localFields.filter((field) => field.inserted),
|
||||||
];
|
];
|
||||||
|
|
||||||
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
|
|
||||||
|
|
||||||
const hasSignatureField = localFields.some((field) => isSignatureFieldType(field.type));
|
const hasSignatureField = localFields.some((field) => isSignatureFieldType(field.type));
|
||||||
|
|
||||||
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
|
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
|
||||||
@@ -341,10 +339,11 @@ 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"
|
||||||
|
scrollParentRef="window"
|
||||||
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -478,9 +477,7 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ElementVisible
|
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPendingPageNumber}"]`}
|
|
||||||
>
|
|
||||||
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
||||||
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
|
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
|
||||||
<Trans>Click to insert field</Trans>
|
<Trans>Click to insert field</Trans>
|
||||||
|
|||||||
@@ -50,10 +50,8 @@ export const EmbedDocumentFields = ({
|
|||||||
onSignField,
|
onSignField,
|
||||||
onUnsignField,
|
onUnsignField,
|
||||||
}: EmbedDocumentFieldsProps) => {
|
}: EmbedDocumentFieldsProps) => {
|
||||||
const highestPageNumber = Math.max(...fields.map((field) => field.page));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ElementVisible target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}>
|
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||||
{fields.map((field) =>
|
{fields.map((field) =>
|
||||||
match(field.type)
|
match(field.type)
|
||||||
.with(FieldType.SIGNATURE, () => (
|
.with(FieldType.SIGNATURE, () => (
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -106,8 +106,6 @@ export const EmbedSignDocumentV1ClientPage = ({
|
|||||||
fields.filter((field) => field.inserted),
|
fields.filter((field) => field.inserted),
|
||||||
];
|
];
|
||||||
|
|
||||||
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
|
|
||||||
|
|
||||||
const { mutateAsync: completeDocumentWithToken, isPending: isSubmitting } =
|
const { mutateAsync: completeDocumentWithToken, isPending: isSubmitting } =
|
||||||
trpc.recipient.completeDocumentWithToken.useMutation();
|
trpc.recipient.completeDocumentWithToken.useMutation();
|
||||||
|
|
||||||
@@ -287,10 +285,11 @@ 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"
|
||||||
|
scrollParentRef="window"
|
||||||
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -491,9 +490,7 @@ export const EmbedSignDocumentV1ClientPage = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ElementVisible
|
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPendingPageNumber}"]`}
|
|
||||||
>
|
|
||||||
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
||||||
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
|
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
|
||||||
<Trans>Click to insert field</Trans>
|
<Trans>Click to insert field</Trans>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
@@ -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';
|
||||||
|
|
||||||
@@ -66,6 +66,8 @@ export const MultiSignDocumentSigningView = ({
|
|||||||
|
|
||||||
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
|
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
|
||||||
|
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
||||||
@@ -93,8 +95,6 @@ export const MultiSignDocumentSigningView = ({
|
|||||||
[],
|
[],
|
||||||
];
|
];
|
||||||
|
|
||||||
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
|
|
||||||
|
|
||||||
const uninsertedFields = document?.fields.filter((field) => !field.inserted) ?? [];
|
const uninsertedFields = document?.fields.filter((field) => !field.inserted) ?? [];
|
||||||
|
|
||||||
const onSignField = async (payload: TSignFieldWithTokenMutationSchema) => {
|
const onSignField = async (payload: TSignFieldWithTokenMutationSchema) => {
|
||||||
@@ -179,7 +179,11 @@ export const MultiSignDocumentSigningView = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen overflow-hidden bg-background">
|
<div className="min-h-screen overflow-hidden bg-background">
|
||||||
<div id="document-field-portal-root" className="relative h-full w-full overflow-y-auto p-8">
|
<div
|
||||||
|
id="document-field-portal-root"
|
||||||
|
ref={scrollContainerRef}
|
||||||
|
className="relative h-full w-full overflow-y-auto p-8"
|
||||||
|
>
|
||||||
{match({ isLoading, document })
|
{match({ isLoading, document })
|
||||||
.with({ isLoading: true }, () => (
|
.with({ isLoading: true }, () => (
|
||||||
<div className="flex min-h-[400px] w-full items-center justify-center">
|
<div className="flex min-h-[400px] w-full items-center justify-center">
|
||||||
@@ -226,10 +230,11 @@ 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"
|
||||||
|
scrollParentRef={scrollContainerRef}
|
||||||
onDocumentLoad={() => {
|
onDocumentLoad={() => {
|
||||||
setHasDocumentLoaded(true);
|
setHasDocumentLoaded(true);
|
||||||
onDocumentReady?.();
|
onDocumentReady?.();
|
||||||
@@ -363,9 +368,7 @@ export const MultiSignDocumentSigningView = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasDocumentLoaded && (
|
{hasDocumentLoaded && (
|
||||||
<ElementVisible
|
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPendingPageNumber}"]`}
|
|
||||||
>
|
|
||||||
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
||||||
<FieldToolTip
|
<FieldToolTip
|
||||||
key={pendingFields[0].id}
|
key={pendingFields[0].id}
|
||||||
|
|||||||
@@ -97,13 +97,13 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
|
|||||||
{menuNavigationLinks.map(({ href, text }) => (
|
{menuNavigationLinks.map(({ href, text }) => (
|
||||||
<Link
|
<Link
|
||||||
key={href}
|
key={href}
|
||||||
className="text-foreground hover:text-foreground/80 flex items-center gap-2 text-2xl font-semibold"
|
className="flex items-center gap-2 text-2xl font-semibold text-foreground hover:text-foreground/80"
|
||||||
to={href}
|
to={href}
|
||||||
onClick={() => handleMenuItemClick()}
|
onClick={() => handleMenuItemClick()}
|
||||||
>
|
>
|
||||||
{text}
|
{text}
|
||||||
{href === '/inbox' && unreadCountData && unreadCountData.count > 0 && (
|
{href === '/inbox' && unreadCountData && unreadCountData.count > 0 && (
|
||||||
<span className="bg-primary text-primary-foreground flex h-6 min-w-[1.5rem] items-center justify-center rounded-full px-1.5 text-xs font-semibold">
|
<span className="flex h-6 min-w-[1.5rem] items-center justify-center rounded-full bg-primary px-1.5 text-xs font-semibold text-primary-foreground">
|
||||||
{unreadCountData.count > 99 ? '99+' : unreadCountData.count}
|
{unreadCountData.count > 99 ? '99+' : unreadCountData.count}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -111,7 +111,7 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="text-foreground hover:text-foreground/80 text-2xl font-semibold"
|
className="text-2xl font-semibold text-foreground hover:text-foreground/80"
|
||||||
onClick={async () => authClient.signOut()}
|
onClick={async () => authClient.signOut()}
|
||||||
>
|
>
|
||||||
<Trans>Sign Out</Trans>
|
<Trans>Sign Out</Trans>
|
||||||
@@ -123,7 +123,7 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
|
|||||||
<ThemeSwitcher />
|
<ThemeSwitcher />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-sm text-muted-foreground">
|
||||||
© {new Date().getFullYear()} Documenso, Inc.
|
© {new Date().getFullYear()} Documenso, Inc.
|
||||||
<br />
|
<br />
|
||||||
<Trans>All rights reserved.</Trans>
|
<Trans>All rights reserved.</Trans>
|
||||||
|
|||||||
@@ -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,12 @@ 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"
|
||||||
|
scrollParentRef="window"
|
||||||
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -82,8 +82,6 @@ export const DirectTemplateSigningForm = ({
|
|||||||
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
const highestPageNumber = Math.max(...localFields.map((field) => field.page));
|
|
||||||
|
|
||||||
const fieldsRequiringValidation = useMemo(() => {
|
const fieldsRequiringValidation = useMemo(() => {
|
||||||
return localFields.filter((field) => isFieldUnsignedAndRequired(field));
|
return localFields.filter((field) => isFieldUnsignedAndRequired(field));
|
||||||
}, [localFields]);
|
}, [localFields]);
|
||||||
@@ -250,9 +248,7 @@ export const DirectTemplateSigningForm = ({
|
|||||||
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
|
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
|
||||||
|
|
||||||
<DocumentFlowFormContainerContent>
|
<DocumentFlowFormContainerContent>
|
||||||
<ElementVisible
|
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
|
|
||||||
>
|
|
||||||
{validateUninsertedFields && uninsertedFields[0] && (
|
{validateUninsertedFields && uninsertedFields[0] && (
|
||||||
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
|
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
|
||||||
<Trans>Click to insert field</Trans>
|
<Trans>Click to insert field</Trans>
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAu
|
|||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="text-muted-foreground max-w-[50ch]">
|
<div className="max-w-[50ch] text-muted-foreground">
|
||||||
<p>
|
<p>
|
||||||
<Trans>
|
<Trans>
|
||||||
When you sign a document, we can automatically fill in and sign the following fields
|
When you sign a document, we can automatically fill in and sign the following fields
|
||||||
|
|||||||
+5
-8
@@ -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';
|
||||||
@@ -162,8 +162,6 @@ export const DocumentSigningPageViewV1 = ({
|
|||||||
: undefined;
|
: undefined;
|
||||||
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
|
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
|
||||||
|
|
||||||
const highestPageNumber = Math.max(...fields.map((field) => field.page));
|
|
||||||
|
|
||||||
const pendingFields = fieldsRequiringValidation.filter((field) => !field.inserted);
|
const pendingFields = fieldsRequiringValidation.filter((field) => !field.inserted);
|
||||||
const hasPendingFields = pendingFields.length > 0;
|
const hasPendingFields = pendingFields.length > 0;
|
||||||
|
|
||||||
@@ -274,11 +272,12 @@ 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"
|
||||||
|
scrollParentRef="window"
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -400,9 +399,7 @@ export const DocumentSigningPageViewV1 = ({
|
|||||||
<DocumentSigningAutoSign recipient={recipient} fields={fields} />
|
<DocumentSigningAutoSign recipient={recipient} fields={fields} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ElementVisible
|
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
|
|
||||||
>
|
|
||||||
{fields
|
{fields
|
||||||
.filter(
|
.filter(
|
||||||
(field) =>
|
(field) =>
|
||||||
|
|||||||
+13
-6
@@ -1,4 +1,4 @@
|
|||||||
import { lazy, useMemo } from 'react';
|
import { lazy, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
import { Plural, Trans } from '@lingui/react/macro';
|
import { Plural, Trans } from '@lingui/react/macro';
|
||||||
import { EnvelopeType, RecipientRole } from '@prisma/client';
|
import { EnvelopeType, RecipientRole } from '@prisma/client';
|
||||||
@@ -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';
|
||||||
|
|
||||||
@@ -40,6 +41,8 @@ const EnvelopeSignerPageRenderer = lazy(
|
|||||||
export const DocumentSigningPageViewV2 = () => {
|
export const DocumentSigningPageViewV2 = () => {
|
||||||
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||||
|
|
||||||
|
const scrollableContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isDirectTemplate,
|
isDirectTemplate,
|
||||||
envelope,
|
envelope,
|
||||||
@@ -199,7 +202,10 @@ export const DocumentSigningPageViewV2 = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="embed--DocumentContainer flex-1 overflow-y-auto">
|
<div
|
||||||
|
className="embed--DocumentContainer flex-1 overflow-y-auto"
|
||||||
|
ref={scrollableContainerRef}
|
||||||
|
>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{/* Horizontal envelope item selector */}
|
{/* Horizontal envelope item selector */}
|
||||||
{envelopeItems.length > 1 && (
|
{envelopeItems.length > 1 && (
|
||||||
@@ -228,15 +234,16 @@ 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}
|
||||||
|
scrollParentRef={scrollableContainerRef}
|
||||||
|
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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { lazy, useEffect, useState } from 'react';
|
import { lazy, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { type DocumentData, DocumentStatus, type EnvelopeItem, EnvelopeType } from '@prisma/client';
|
import { type DocumentData, DocumentStatus, type EnvelopeItem, EnvelopeType } from '@prisma/client';
|
||||||
@@ -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,11 +105,13 @@ export const DocumentCertificateQRView = ({
|
|||||||
|
|
||||||
{internalVersion === 2 ? (
|
{internalVersion === 2 ? (
|
||||||
<EnvelopeRenderProvider
|
<EnvelopeRenderProvider
|
||||||
|
version="current"
|
||||||
envelope={{
|
envelope={{
|
||||||
envelopeItems,
|
id: envelopeItems[0].envelopeId,
|
||||||
status: DocumentStatus.COMPLETED,
|
status: DocumentStatus.COMPLETED,
|
||||||
type: EnvelopeType.DOCUMENT,
|
type: EnvelopeType.DOCUMENT,
|
||||||
}}
|
}}
|
||||||
|
envelopeItems={envelopeItems}
|
||||||
token={token}
|
token={token}
|
||||||
>
|
>
|
||||||
<DocumentCertificateQrV2
|
<DocumentCertificateQrV2
|
||||||
@@ -149,11 +152,12 @@ 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"
|
||||||
|
scrollParentRef="window"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -175,7 +179,9 @@ const DocumentCertificateQrV2 = ({
|
|||||||
formattedDate,
|
formattedDate,
|
||||||
token,
|
token,
|
||||||
}: DocumentCertificateQrV2Props) => {
|
}: DocumentCertificateQrV2Props) => {
|
||||||
const { currentEnvelopeItem, envelopeItems } = useCurrentEnvelopeRender();
|
const { envelopeItems } = useCurrentEnvelopeRender();
|
||||||
|
|
||||||
|
const scrollableContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col items-start">
|
<div className="flex min-h-screen flex-col items-start">
|
||||||
@@ -207,10 +213,14 @@ const DocumentCertificateQrV2 = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-12 w-full">
|
<div className="mt-12 max-h-[80vh] w-full overflow-y-auto" ref={scrollableContainerRef}>
|
||||||
<EnvelopeRendererFileSelector className="mb-4 p-0" fields={[]} secondaryOverride={''} />
|
<EnvelopeRendererFileSelector className="mb-4 p-0" fields={[]} secondaryOverride={''} />
|
||||||
|
|
||||||
<PDFViewerKonvaLazy renderer="preview" customPageRenderer={EnvelopeGenericPageRenderer} />
|
<EnvelopePdfViewer
|
||||||
|
customPageRenderer={EnvelopeGenericPageRenderer}
|
||||||
|
scrollParentRef={scrollableContainerRef}
|
||||||
|
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,12 @@ 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"
|
||||||
|
scrollParentRef="window"
|
||||||
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
+3
-12
@@ -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',
|
||||||
{
|
{
|
||||||
|
|||||||
+20
-29
@@ -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, currentEnvelopeItem?.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
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 &&
|
||||||
@@ -531,7 +531,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
|
|
||||||
removePendingField();
|
removePendingField();
|
||||||
|
|
||||||
if (!canvasElement.current || !currentEnvelopeItem || !editorFields.selectedRecipient) {
|
if (!currentEnvelopeItem || !editorFields.selectedRecipient) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -546,7 +546,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,
|
||||||
@@ -575,10 +575,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 && (
|
||||||
@@ -641,13 +638,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">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { lazy, useEffect, useMemo, useState } from 'react';
|
import { lazy, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { faker } from '@faker-js/faker/locale/en';
|
import { faker } from '@faker-js/faker/locale/en';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
@@ -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';
|
||||||
@@ -33,6 +34,8 @@ export const EnvelopeEditorPreviewPage = () => {
|
|||||||
|
|
||||||
const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender();
|
const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender();
|
||||||
|
|
||||||
|
const scrollableContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const [selectedPreviewMode, setSelectedPreviewMode] = useState<'recipient' | 'signed'>(
|
const [selectedPreviewMode, setSelectedPreviewMode] = useState<'recipient' | 'signed'>(
|
||||||
'recipient',
|
'recipient',
|
||||||
);
|
);
|
||||||
@@ -200,7 +203,9 @@ 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}
|
||||||
|
envelopeItems={envelope.envelopeItems}
|
||||||
token={undefined}
|
token={undefined}
|
||||||
fields={fieldsWithPlaceholders}
|
fields={fieldsWithPlaceholders}
|
||||||
recipients={envelope.recipients.map((recipient) => ({
|
recipients={envelope.recipients.map((recipient) => ({
|
||||||
@@ -212,12 +217,12 @@ export const EnvelopeEditorPreviewPage = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<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 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">
|
||||||
<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 +233,10 @@ export const EnvelopeEditorPreviewPage = () => {
|
|||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
{currentEnvelopeItem !== null ? (
|
{currentEnvelopeItem !== null ? (
|
||||||
<PDFViewerKonvaLazy
|
<EnvelopePdfViewer
|
||||||
renderer="editor"
|
|
||||||
customPageRenderer={EnvelopeGenericPageRenderer}
|
customPageRenderer={EnvelopeGenericPageRenderer}
|
||||||
|
scrollParentRef={scrollableContainerRef}
|
||||||
|
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">
|
||||||
|
|||||||
+16
-28
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-29
@@ -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, currentEnvelopeItem?.id]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+8
@@ -71,6 +71,14 @@ export const EnvelopeSignerCompleteDialog = () => {
|
|||||||
|
|
||||||
if (fieldTooltip) {
|
if (fieldTooltip) {
|
||||||
fieldTooltip.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
fieldTooltip.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
} else {
|
||||||
|
// Tooltip not in DOM (page virtualized away) — signal the PDF viewer
|
||||||
|
// to scroll to the correct page via the data attribute.
|
||||||
|
const pdfContent = document.querySelector('[data-pdf-content]');
|
||||||
|
|
||||||
|
if (pdfContent) {
|
||||||
|
pdfContent.setAttribute('data-scroll-to-page', String(nextField.page));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
isEnvelopeItemSwitch ? 150 : 50,
|
isEnvelopeItemSwitch ? 150 : 50,
|
||||||
|
|||||||
@@ -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,12 @@ 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"
|
||||||
|
scrollParentRef="window"
|
||||||
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -21,17 +21,17 @@ export const UserProfileSkeleton = ({ className, user, rows = 2 }: UserProfileSk
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'dark:bg-background flex flex-col items-center rounded-xl bg-neutral-100 p-4',
|
'flex flex-col items-center rounded-xl bg-neutral-100 p-4 dark:bg-background',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="border-border bg-background text-muted-foreground inline-block max-w-full truncate rounded-md border px-2.5 py-1.5 text-sm lowercase">
|
<div className="inline-block max-w-full truncate rounded-md border border-border bg-background px-2.5 py-1.5 text-sm lowercase text-muted-foreground">
|
||||||
{baseUrl.host}/u/{user.url}
|
{baseUrl.host}/u/{user.url}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<div className="bg-primary/10 rounded-full p-1.5">
|
<div className="rounded-full bg-primary/10 p-1.5">
|
||||||
<div className="bg-background flex h-20 w-20 items-center justify-center rounded-full border-2">
|
<div className="flex h-20 w-20 items-center justify-center rounded-full border-2 bg-background">
|
||||||
<User2 className="h-12 w-12 text-[hsl(228,10%,90%)]" />
|
<User2 className="h-12 w-12 text-[hsl(228,10%,90%)]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -41,16 +41,16 @@ export const UserProfileSkeleton = ({ className, user, rows = 2 }: UserProfileSk
|
|||||||
<div className="flex items-center justify-center gap-x-2">
|
<div className="flex items-center justify-center gap-x-2">
|
||||||
<h2 className="max-w-[12rem] truncate text-2xl font-semibold">{user.name}</h2>
|
<h2 className="max-w-[12rem] truncate text-2xl font-semibold">{user.name}</h2>
|
||||||
|
|
||||||
<VerifiedIcon className="text-primary h-8 w-8" />
|
<VerifiedIcon className="h-8 w-8 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="dark:bg-foreground/30 mx-auto mt-4 h-2 w-52 rounded-full bg-neutral-300" />
|
<div className="mx-auto mt-4 h-2 w-52 rounded-full bg-neutral-300 dark:bg-foreground/30" />
|
||||||
<div className="dark:bg-foreground/20 mx-auto mt-2 h-2 w-36 rounded-full bg-neutral-200" />
|
<div className="mx-auto mt-2 h-2 w-36 rounded-full bg-neutral-200 dark:bg-foreground/20" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 w-full">
|
<div className="mt-8 w-full">
|
||||||
<div className="dark:divide-foreground/30 dark:border-foreground/30 divide-y-2 divide-neutral-200 overflow-hidden rounded-lg border-2 border-neutral-200">
|
<div className="divide-y-2 divide-neutral-200 overflow-hidden rounded-lg border-2 border-neutral-200 dark:divide-foreground/30 dark:border-foreground/30">
|
||||||
<div className="text-muted-foreground dark:bg-foreground/20 bg-neutral-50 p-4 font-medium">
|
<div className="bg-neutral-50 p-4 font-medium text-muted-foreground dark:bg-foreground/20">
|
||||||
<Trans>Documents</Trans>
|
<Trans>Documents</Trans>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -59,14 +59,14 @@ export const UserProfileSkeleton = ({ className, user, rows = 2 }: UserProfileSk
|
|||||||
.map((_, index) => (
|
.map((_, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="bg-background flex items-center justify-between gap-x-6 p-4"
|
className="flex items-center justify-between gap-x-6 bg-background p-4"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-x-2">
|
<div className="flex items-center gap-x-2">
|
||||||
<File className="text-muted-foreground/80 h-8 w-8" strokeWidth={1.5} />
|
<File className="h-8 w-8 text-muted-foreground/80" strokeWidth={1.5} />
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="dark:bg-foreground/30 h-1.5 w-24 rounded-full bg-neutral-300 md:w-36" />
|
<div className="h-1.5 w-24 rounded-full bg-neutral-300 md:w-36 dark:bg-foreground/30" />
|
||||||
<div className="dark:bg-foreground/20 h-1.5 w-16 rounded-full bg-neutral-200 md:w-24" />
|
<div className="h-1.5 w-16 rounded-full bg-neutral-200 md:w-24 dark:bg-foreground/20" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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,7 +155,9 @@ 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}
|
||||||
|
envelopeItems={envelope.envelopeItems}
|
||||||
token={undefined}
|
token={undefined}
|
||||||
fields={envelope.fields}
|
fields={envelope.fields}
|
||||||
recipients={envelope.recipients}
|
recipients={envelope.recipients}
|
||||||
@@ -169,9 +172,10 @@ 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}
|
||||||
|
scrollParentRef="window"
|
||||||
|
errorMessage={PDF_VIEWER_ERROR_MESSAGES.preview}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -193,11 +197,12 @@ 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"
|
||||||
|
scrollParentRef="window"
|
||||||
/>
|
/>
|
||||||
</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,7 +99,9 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
|
|||||||
return (
|
return (
|
||||||
<EnvelopeEditorProvider initialEnvelope={envelope}>
|
<EnvelopeEditorProvider initialEnvelope={envelope}>
|
||||||
<EnvelopeRenderProvider
|
<EnvelopeRenderProvider
|
||||||
|
version="current"
|
||||||
envelope={envelope}
|
envelope={envelope}
|
||||||
|
envelopeItems={envelope.envelopeItems}
|
||||||
token={undefined}
|
token={undefined}
|
||||||
fields={envelope.fields}
|
fields={envelope.fields}
|
||||||
recipients={envelope.recipients}
|
recipients={envelope.recipients}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export default function DocumentsFoldersPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative w-full max-w-md py-6">
|
<div className="relative w-full max-w-md py-6">
|
||||||
<SearchIcon className="text-muted-foreground absolute left-2 top-9 h-4 w-4" />
|
<SearchIcon className="absolute left-2 top-9 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder={t`Search folders...`}
|
placeholder={t`Search folders...`}
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
@@ -93,7 +93,7 @@ export default function DocumentsFoldersPage() {
|
|||||||
|
|
||||||
{isFoldersLoading ? (
|
{isFoldersLoading ? (
|
||||||
<div className="mt-6 flex justify-center">
|
<div className="mt-6 flex justify-center">
|
||||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -128,7 +128,7 @@ export default function DocumentsFoldersPage() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
{searchTerm && foldersData?.folders.filter(isFolderMatchingSearch).length === 0 && (
|
{searchTerm && foldersData?.folders.filter(isFolderMatchingSearch).length === 0 && (
|
||||||
<div className="text-muted-foreground mt-6 text-center">
|
<div className="mt-6 text-center text-muted-foreground">
|
||||||
<Trans>No folders found matching "{searchTerm}"</Trans>
|
<Trans>No folders found matching "{searchTerm}"</Trans>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
|
|||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-muted-foreground/50 flex flex-row items-center justify-center space-x-2 text-xs',
|
'flex flex-row items-center justify-center space-x-2 text-xs text-muted-foreground/50',
|
||||||
{
|
{
|
||||||
'[&>*:first-child]:text-muted-foreground': !isPublicProfileVisible,
|
'[&>*:first-child]:text-muted-foreground': !isPublicProfileVisible,
|
||||||
'[&>*:last-child]:text-muted-foreground': isPublicProfileVisible,
|
'[&>*:last-child]:text-muted-foreground': isPublicProfileVisible,
|
||||||
@@ -164,7 +164,7 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
|
|||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|
||||||
<TooltipContent className="text-muted-foreground max-w-[40ch] space-y-2 py-2">
|
<TooltipContent className="max-w-[40ch] space-y-2 py-2 text-muted-foreground">
|
||||||
{isPublicProfileVisible ? (
|
{isPublicProfileVisible ? (
|
||||||
<>
|
<>
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -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,7 +174,9 @@ 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}
|
||||||
|
envelopeItems={envelope.envelopeItems}
|
||||||
token={undefined}
|
token={undefined}
|
||||||
fields={envelope.fields}
|
fields={envelope.fields}
|
||||||
recipients={envelope.recipients}
|
recipients={envelope.recipients}
|
||||||
@@ -187,9 +190,10 @@ 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}
|
||||||
|
scrollParentRef="window"
|
||||||
|
errorMessage={PDF_VIEWER_ERROR_MESSAGES.preview}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -210,11 +214,12 @@ 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}
|
||||||
|
scrollParentRef="window"
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export default function TemplatesFoldersPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative w-full max-w-md py-6">
|
<div className="relative w-full max-w-md py-6">
|
||||||
<SearchIcon className="text-muted-foreground absolute left-2 top-9 h-4 w-4" />
|
<SearchIcon className="absolute left-2 top-9 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder={t`Search folders...`}
|
placeholder={t`Search folders...`}
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
@@ -93,7 +93,7 @@ export default function TemplatesFoldersPage() {
|
|||||||
|
|
||||||
{isFoldersLoading ? (
|
{isFoldersLoading ? (
|
||||||
<div className="mt- flex justify-center">
|
<div className="mt- flex justify-center">
|
||||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -128,7 +128,7 @@ export default function TemplatesFoldersPage() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
{searchTerm && foldersData?.folders.filter(isFolderMatchingSearch).length === 0 && (
|
{searchTerm && foldersData?.folders.filter(isFolderMatchingSearch).length === 0 && (
|
||||||
<div className="text-muted-foreground mt-6 text-center">
|
<div className="mt-6 text-center text-muted-foreground">
|
||||||
<Trans>No folders found matching "{searchTerm}"</Trans>
|
<Trans>No folders found matching "{searchTerm}"</Trans>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-4 sm:py-32">
|
<div className="flex flex-col items-center justify-center py-4 sm:py-32">
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<Avatar className="dark:border-border h-24 w-24 border-2 border-solid">
|
<Avatar className="h-24 w-24 border-2 border-solid dark:border-border">
|
||||||
{publicProfile.avatarImageId && (
|
{publicProfile.avatarImageId && (
|
||||||
<AvatarImage src={formatAvatarUrl(publicProfile.avatarImageId)} />
|
<AvatarImage src={formatAvatarUrl(publicProfile.avatarImageId)} />
|
||||||
)}
|
)}
|
||||||
@@ -99,10 +99,10 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="ml-2">
|
<div className="ml-2">
|
||||||
<p className="text-foreground text-base font-semibold">
|
<p className="text-base font-semibold text-foreground">
|
||||||
{BADGE_DATA[publicProfile.badge.type].name}
|
{BADGE_DATA[publicProfile.badge.type].name}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-muted-foreground mt-0.5 text-sm">
|
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||||
<Trans>
|
<Trans>
|
||||||
Since {DateTime.fromJSDate(publicProfile.badge.since).toFormat('LLL ‘yy')}
|
Since {DateTime.fromJSDate(publicProfile.badge.since).toFormat('LLL ‘yy')}
|
||||||
</Trans>
|
</Trans>
|
||||||
@@ -113,7 +113,7 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-muted-foreground mt-4 space-y-1">
|
<div className="mt-4 space-y-1 text-muted-foreground">
|
||||||
{(profile.bio ?? '').split('\n').map((line, index) => (
|
{(profile.bio ?? '').split('\n').map((line, index) => (
|
||||||
<p
|
<p
|
||||||
key={index}
|
key={index}
|
||||||
@@ -127,7 +127,7 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
|
|||||||
|
|
||||||
{templates.length === 0 && (
|
{templates.length === 0 && (
|
||||||
<div className="mt-4 w-full max-w-xl border-t pt-4">
|
<div className="mt-4 w-full max-w-xl border-t pt-4">
|
||||||
<p className="text-muted-foreground max-w-[60ch] whitespace-pre-wrap break-words text-center text-sm leading-relaxed">
|
<p className="max-w-[60ch] whitespace-pre-wrap break-words text-center text-sm leading-relaxed text-muted-foreground">
|
||||||
<Trans>
|
<Trans>
|
||||||
It looks like {publicProfile.name} hasn't added any documents to their profile yet.
|
It looks like {publicProfile.name} hasn't added any documents to their profile yet.
|
||||||
</Trans>{' '}
|
</Trans>{' '}
|
||||||
@@ -167,19 +167,19 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{templates.map((template) => (
|
{templates.map((template) => (
|
||||||
<TableRow key={template.id}>
|
<TableRow key={template.id}>
|
||||||
<TableCell className="text-muted-foreground flex flex-col justify-between overflow-hidden text-sm sm:flex-row">
|
<TableCell className="flex flex-col justify-between overflow-hidden text-sm text-muted-foreground sm:flex-row">
|
||||||
<div className="flex flex-1 items-start justify-start gap-2">
|
<div className="flex flex-1 items-start justify-start gap-2">
|
||||||
<FileIcon
|
<FileIcon
|
||||||
className="text-muted-foreground/40 h-8 w-8 flex-shrink-0"
|
className="h-8 w-8 flex-shrink-0 text-muted-foreground/40"
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col gap-4 overflow-hidden md:flex-row md:items-start md:justify-between">
|
<div className="flex flex-1 flex-col gap-4 overflow-hidden md:flex-row md:items-start md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-foreground text-sm font-semibold leading-none">
|
<p className="text-sm font-semibold leading-none text-foreground">
|
||||||
{template.publicTitle}
|
{template.publicTitle}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-muted-foreground mt-1 line-clamp-3 max-w-[70ch] whitespace-normal text-xs">
|
<p className="mt-1 line-clamp-3 max-w-[70ch] whitespace-normal text-xs text-muted-foreground">
|
||||||
{template.publicDescription}
|
{template.publicDescription}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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,12 @@ 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}
|
||||||
|
envelopeItems={envelope.envelopeItems}
|
||||||
|
token={recipient.token}
|
||||||
|
>
|
||||||
<DocumentSigningPageViewV2 />
|
<DocumentSigningPageViewV2 />
|
||||||
</EnvelopeRenderProvider>
|
</EnvelopeRenderProvider>
|
||||||
</DocumentSigningAuthProvider>
|
</DocumentSigningAuthProvider>
|
||||||
|
|||||||
@@ -504,7 +504,12 @@ 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}
|
||||||
|
envelopeItems={envelope.envelopeItems}
|
||||||
|
token={recipient.token}
|
||||||
|
>
|
||||||
<DocumentSigningPageViewV2 />
|
<DocumentSigningPageViewV2 />
|
||||||
</EnvelopeRenderProvider>
|
</EnvelopeRenderProvider>
|
||||||
</DocumentSigningAuthProvider>
|
</DocumentSigningAuthProvider>
|
||||||
|
|||||||
@@ -320,7 +320,12 @@ const EmbedDirectTemplatePageV2 = ({
|
|||||||
user={user}
|
user={user}
|
||||||
isDirectTemplate={true}
|
isDirectTemplate={true}
|
||||||
>
|
>
|
||||||
<EnvelopeRenderProvider envelope={envelope} token={recipient.token}>
|
<EnvelopeRenderProvider
|
||||||
|
version="current"
|
||||||
|
envelope={envelope}
|
||||||
|
envelopeItems={envelope.envelopeItems}
|
||||||
|
token={recipient.token}
|
||||||
|
>
|
||||||
<EmbedSignDocumentV2ClientPage
|
<EmbedSignDocumentV2ClientPage
|
||||||
hidePoweredBy={hidePoweredBy}
|
hidePoweredBy={hidePoweredBy}
|
||||||
allowWhitelabelling={allowEmbedSigningWhitelabel}
|
allowWhitelabelling={allowEmbedSigningWhitelabel}
|
||||||
|
|||||||
@@ -405,7 +405,12 @@ const EmbedSignDocumentPageV2 = ({
|
|||||||
recipient={recipient}
|
recipient={recipient}
|
||||||
user={user}
|
user={user}
|
||||||
>
|
>
|
||||||
<EnvelopeRenderProvider envelope={envelope} token={token}>
|
<EnvelopeRenderProvider
|
||||||
|
version="current"
|
||||||
|
envelope={envelope}
|
||||||
|
envelopeItems={envelope.envelopeItems}
|
||||||
|
token={token}
|
||||||
|
>
|
||||||
<EmbedSignDocumentV2ClientPage
|
<EmbedSignDocumentV2ClientPage
|
||||||
hidePoweredBy={hidePoweredBy}
|
hidePoweredBy={hidePoweredBy}
|
||||||
allowWhitelabelling={allowEmbedSigningWhitelabel}
|
allowWhitelabelling={allowEmbedSigningWhitelabel}
|
||||||
|
|||||||
@@ -283,7 +283,7 @@ export default function MultisignPage() {
|
|||||||
</DocumentSigningProvider>
|
</DocumentSigningProvider>
|
||||||
|
|
||||||
{!hidePoweredBy && (
|
{!hidePoweredBy && (
|
||||||
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
|
<div className="fixed bottom-0 left-0 z-40 rounded-tr bg-primary px-2 py-1 text-xs font-medium text-primary-foreground opacity-60 hover:opacity-100">
|
||||||
<span>
|
<span>
|
||||||
<Trans>Powered by</Trans>
|
<Trans>Powered by</Trans>
|
||||||
</span>
|
</span>
|
||||||
@@ -300,7 +300,7 @@ export default function MultisignPage() {
|
|||||||
<MultiSignDocumentList envelopes={envelopes} onDocumentSelect={onSelectDocument} />
|
<MultiSignDocumentList envelopes={envelopes} onDocumentSelect={onSelectDocument} />
|
||||||
|
|
||||||
{!hidePoweredBy && (
|
{!hidePoweredBy && (
|
||||||
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
|
<div className="fixed bottom-0 left-0 z-40 rounded-tr bg-primary px-2 py-1 text-xs font-medium text-primary-foreground opacity-60 hover:opacity-100">
|
||||||
<span>
|
<span>
|
||||||
<Trans>Powered by</Trans>
|
<Trans>Powered by</Trans>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { DocumentDataType } from '@prisma/client';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import DocumentDataSchema from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema';
|
import DocumentDataSchema from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema';
|
||||||
@@ -72,3 +73,24 @@ 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(),
|
||||||
|
documentDataType: z.nativeEnum(DocumentDataType),
|
||||||
|
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,64 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await handleEnvelopeItemPageRequest({
|
||||||
|
c,
|
||||||
|
envelopeItem,
|
||||||
|
version,
|
||||||
|
pageIndex,
|
||||||
|
cacheStrategy: 'public',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default route;
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
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 { 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, 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,
|
||||||
|
documentDataId,
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
cacheStrategy: 'private',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
type HandleEnvelopeItemPageRequestOptions = {
|
||||||
|
c: Context<HonoEnv>;
|
||||||
|
envelopeItem: EnvelopeItem & {
|
||||||
|
documentData: DocumentData;
|
||||||
|
};
|
||||||
|
pageIndex: number;
|
||||||
|
version: DocumentDataVersion;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The type of cache strategy to use.
|
||||||
|
*
|
||||||
|
* For access via tokens, we can use a public cache to allow the CDN to cache it.
|
||||||
|
*
|
||||||
|
* For access via session, we must use a private cache.
|
||||||
|
*/
|
||||||
|
cacheStrategy: 'private' | 'public';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleEnvelopeItemPageRequest = async ({
|
||||||
|
c,
|
||||||
|
envelopeItem,
|
||||||
|
pageIndex,
|
||||||
|
version,
|
||||||
|
cacheStrategy,
|
||||||
|
}: HandleEnvelopeItemPageRequestOptions) => {
|
||||||
|
// Determine which PDF data to use based on version requested.
|
||||||
|
const documentDataToUse =
|
||||||
|
version === 'current' ? envelopeItem.documentData.data : envelopeItem.documentData.initialData;
|
||||||
|
|
||||||
|
// 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).catch(() => null);
|
||||||
|
|
||||||
|
if (image) {
|
||||||
|
// Note: Only set these headers on success.
|
||||||
|
c.header('Content-Type', 'image/jpeg');
|
||||||
|
c.header('Cache-Control', `${cacheStrategy}, max-age=31536000, immutable`);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: Only set these headers on success.
|
||||||
|
c.header('Content-Type', 'image/jpeg');
|
||||||
|
c.header('Cache-Control', `${cacheStrategy}, max-age=31536000, immutable`);
|
||||||
|
|
||||||
|
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,140 @@
|
|||||||
|
import { sValidator } from '@hono/standard-validator';
|
||||||
|
import type { DocumentData, EnvelopeItem } from '@prisma/client';
|
||||||
|
import { type Context, Hono } from 'hono';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
|
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||||
|
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
||||||
|
import type { TDocumentDataMeta } from '@documenso/lib/types/document-data';
|
||||||
|
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: TDocumentDataMeta['pages'] = await extractAndStorePdfImages(
|
||||||
|
new Uint8Array(pdfBytes).buffer,
|
||||||
|
item.documentData.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
pageMetadata = {
|
||||||
|
pages: pdfPageMetadata,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages = pageMetadata.pages ?? [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
envelopeItemId: item.id,
|
||||||
|
documentDataId: item.documentData.id,
|
||||||
|
documentDataType: item.documentData.type,
|
||||||
|
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;
|
||||||
Generated
-84
@@ -27388,24 +27388,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",
|
||||||
@@ -27852,23 +27834,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",
|
||||||
@@ -31898,44 +31863,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",
|
||||||
@@ -36505,15 +36432,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",
|
||||||
@@ -37457,7 +37375,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",
|
||||||
@@ -37615,7 +37532,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,
|
||||||
@@ -108,7 +109,9 @@ test.describe('AutoSave Subject Step', () => {
|
|||||||
// Toggle some email settings checkboxes (randomly - some checked, some unchecked)
|
// Toggle some email settings checkboxes (randomly - some checked, some unchecked)
|
||||||
await page.getByText('Email the owner when a recipient signs').click();
|
await page.getByText('Email the owner when a recipient signs').click();
|
||||||
await page.getByText("Email recipients when they're removed from a pending document").click();
|
await page.getByText("Email recipients when they're removed from a pending document").click();
|
||||||
await page.getByText('Email recipients when the document is completed', { exact: true }).click();
|
await page
|
||||||
|
.getByText('Email recipients when the document is completed', { exact: true })
|
||||||
|
.click();
|
||||||
await page.getByText('Email recipients when a pending document is deleted').click();
|
await page.getByText('Email recipients when a pending document is deleted').click();
|
||||||
|
|
||||||
await triggerAutosave(page);
|
await triggerAutosave(page);
|
||||||
@@ -139,16 +142,20 @@ test.describe('AutoSave Subject Step', () => {
|
|||||||
).toBeChecked({
|
).toBeChecked({
|
||||||
checked: emailSettings?.documentCompleted,
|
checked: emailSettings?.documentCompleted,
|
||||||
});
|
});
|
||||||
await expect(page.getByText('Email recipients when a pending document is deleted')).toBeChecked({
|
await expect(
|
||||||
|
page.getByText('Email recipients when a pending document is deleted'),
|
||||||
|
).toBeChecked({
|
||||||
checked: emailSettings?.documentDeleted,
|
checked: emailSettings?.documentDeleted,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(page.getByText('Email recipients with a signing request')).toBeChecked({
|
await expect(page.getByText('Email recipients with a signing request')).toBeChecked({
|
||||||
checked: emailSettings?.recipientSigningRequest,
|
checked: emailSettings?.recipientSigningRequest,
|
||||||
});
|
});
|
||||||
await expect(page.getByText('Email the signer if the document is still pending')).toBeChecked({
|
await expect(page.getByText('Email the signer if the document is still pending')).toBeChecked(
|
||||||
checked: emailSettings?.documentPending,
|
{
|
||||||
});
|
checked: emailSettings?.documentPending,
|
||||||
|
},
|
||||||
|
);
|
||||||
await expect(page.getByText('Email the owner when the document is completed')).toBeChecked({
|
await expect(page.getByText('Email the owner when the document is completed')).toBeChecked({
|
||||||
checked: emailSettings?.ownerDocumentCompleted,
|
checked: emailSettings?.ownerDocumentCompleted,
|
||||||
});
|
});
|
||||||
@@ -167,7 +174,9 @@ test.describe('AutoSave Subject Step', () => {
|
|||||||
|
|
||||||
await page.getByText('Email the owner when a recipient signs').click();
|
await page.getByText('Email the owner when a recipient signs').click();
|
||||||
await page.getByText("Email recipients when they're removed from a pending document").click();
|
await page.getByText("Email recipients when they're removed from a pending document").click();
|
||||||
await page.getByText('Email recipients when the document is completed', { exact: true }).click();
|
await page
|
||||||
|
.getByText('Email recipients when the document is completed', { exact: true })
|
||||||
|
.click();
|
||||||
await page.getByText('Email recipients when a pending document is deleted').click();
|
await page.getByText('Email recipients when a pending document is deleted').click();
|
||||||
|
|
||||||
await triggerAutosave(page);
|
await triggerAutosave(page);
|
||||||
@@ -207,16 +216,20 @@ test.describe('AutoSave Subject Step', () => {
|
|||||||
).toBeChecked({
|
).toBeChecked({
|
||||||
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentCompleted,
|
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentCompleted,
|
||||||
});
|
});
|
||||||
await expect(page.getByText('Email recipients when a pending document is deleted')).toBeChecked({
|
await expect(
|
||||||
|
page.getByText('Email recipients when a pending document is deleted'),
|
||||||
|
).toBeChecked({
|
||||||
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentDeleted,
|
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentDeleted,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(page.getByText('Email recipients with a signing request')).toBeChecked({
|
await expect(page.getByText('Email recipients with a signing request')).toBeChecked({
|
||||||
checked: retrievedDocumentData.documentMeta?.emailSettings?.recipientSigningRequest,
|
checked: retrievedDocumentData.documentMeta?.emailSettings?.recipientSigningRequest,
|
||||||
});
|
});
|
||||||
await expect(page.getByText('Email the signer if the document is still pending')).toBeChecked({
|
await expect(page.getByText('Email the signer if the document is still pending')).toBeChecked(
|
||||||
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentPending,
|
{
|
||||||
});
|
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentPending,
|
||||||
|
},
|
||||||
|
);
|
||||||
await expect(page.getByText('Email the owner when the document is completed')).toBeChecked({
|
await expect(page.getByText('Email the owner when the document is completed')).toBeChecked({
|
||||||
checked: retrievedDocumentData.documentMeta?.emailSettings?.ownerDocumentCompleted,
|
checked: retrievedDocumentData.documentMeta?.emailSettings?.ownerDocumentCompleted,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -394,39 +393,14 @@ test.skip('download envelope images', async ({ page, request }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
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 = {
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { FieldType } from '@prisma/client';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { createEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/create-embedding-presign-token';
|
||||||
|
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||||
|
import { prefixedId } from '@documenso/lib/universal/id';
|
||||||
|
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import {
|
||||||
|
seedBlankDocument,
|
||||||
|
seedPendingDocumentWithFullFields,
|
||||||
|
} from '@documenso/prisma/seed/documents';
|
||||||
|
import { seedBlankTemplate, seedDirectTemplate } from '@documenso/prisma/seed/templates';
|
||||||
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
|
||||||
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
|
|
||||||
|
const PDF_PAGE_SELECTOR = 'img[data-page-number]';
|
||||||
|
|
||||||
|
async function addSecondEnvelopeItem(envelopeId: string) {
|
||||||
|
const firstItem = await prisma.envelopeItem.findFirstOrThrow({
|
||||||
|
where: { envelopeId },
|
||||||
|
orderBy: { order: 'asc' },
|
||||||
|
include: { documentData: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const newDocumentData = await prisma.documentData.create({
|
||||||
|
data: {
|
||||||
|
type: firstItem.documentData.type,
|
||||||
|
data: firstItem.documentData.data,
|
||||||
|
initialData: firstItem.documentData.initialData,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.envelopeItem.create({
|
||||||
|
data: {
|
||||||
|
id: prefixedId('envelope_item'),
|
||||||
|
title: `${firstItem.title} - Page 2`,
|
||||||
|
documentDataId: newDocumentData.id,
|
||||||
|
order: 2,
|
||||||
|
envelopeId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('PDF Viewer Rendering', () => {
|
||||||
|
test.describe('Authenticated Pages', () => {
|
||||||
|
test('should render PDF on all authenticated pages (V1 and V2)', async ({ page }) => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
|
||||||
|
const documentV1 = await seedBlankDocument(user, team.id);
|
||||||
|
const documentV2 = await seedBlankDocument(user, team.id, { internalVersion: 2 });
|
||||||
|
await addSecondEnvelopeItem(documentV2.id);
|
||||||
|
|
||||||
|
const templateV1 = await seedBlankTemplate(user, team.id);
|
||||||
|
const templateV2 = await seedBlankTemplate(user, team.id, {
|
||||||
|
createTemplateOptions: { internalVersion: 2 },
|
||||||
|
});
|
||||||
|
await addSecondEnvelopeItem(templateV2.id);
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/t/${team.url}/documents/${documentV1.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
await page.goto(`/t/${team.url}/documents/${documentV2.id}`);
|
||||||
|
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
await page.getByRole('button', { name: /Page 2/ }).click();
|
||||||
|
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
await page.goto(`/t/${team.url}/templates/${templateV1.id}`);
|
||||||
|
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
await page.goto(`/t/${team.url}/templates/${templateV2.id}`);
|
||||||
|
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
await page.getByRole('button', { name: /Page 2/ }).click();
|
||||||
|
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
await page.goto(`/t/${team.url}/documents/${documentV1.id}/edit`);
|
||||||
|
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
await page.goto(`/t/${team.url}/documents/${documentV2.id}/edit?step=addFields`);
|
||||||
|
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
await page.getByRole('button', { name: /Page 2/ }).click();
|
||||||
|
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
await page.goto(`/t/${team.url}/templates/${templateV1.id}/edit`);
|
||||||
|
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
await page.goto(`/t/${team.url}/templates/${templateV2.id}/edit?step=addFields`);
|
||||||
|
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
await page.getByRole('button', { name: /Page 2/ }).click();
|
||||||
|
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
await page.goto(`/t/${team.url}/documents/${documentV1.id}`);
|
||||||
|
await page.locator(PDF_PAGE_SELECTOR).first().waitFor({ state: 'visible', timeout: 30_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Recipient Signing', () => {
|
||||||
|
test('should render PDF on signing page (V1 and V2)', async ({ page }) => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
|
||||||
|
const { recipients: recipientsV1 } = await seedPendingDocumentWithFullFields({
|
||||||
|
owner: user,
|
||||||
|
teamId: team.id,
|
||||||
|
recipients: ['signer-v1@test.documenso.com'],
|
||||||
|
fields: [FieldType.SIGNATURE],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { document: documentV2, recipients: recipientsV2 } =
|
||||||
|
await seedPendingDocumentWithFullFields({
|
||||||
|
owner: user,
|
||||||
|
teamId: team.id,
|
||||||
|
recipients: ['signer-v2@test.documenso.com'],
|
||||||
|
fields: [FieldType.SIGNATURE],
|
||||||
|
updateDocumentOptions: { internalVersion: 2 },
|
||||||
|
});
|
||||||
|
await addSecondEnvelopeItem(documentV2.id);
|
||||||
|
|
||||||
|
await page.goto(`/sign/${recipientsV1[0].token}`);
|
||||||
|
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
await page.goto(`/sign/${recipientsV2[0].token}`);
|
||||||
|
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
await page.getByRole('button', { name: /Page 2/ }).click();
|
||||||
|
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Direct Template', () => {
|
||||||
|
test('should render PDF on direct template page (V1 and V2)', async ({ page }) => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
|
||||||
|
const templateV1 = await seedDirectTemplate({
|
||||||
|
title: 'PDF Viewer Test Template V1',
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const templateV2 = await seedDirectTemplate({
|
||||||
|
title: 'PDF Viewer Test Template V2',
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
internalVersion: 2,
|
||||||
|
});
|
||||||
|
await addSecondEnvelopeItem(templateV2.id);
|
||||||
|
|
||||||
|
await page.goto(formatDirectTemplatePath(templateV1.directLink?.token || ''));
|
||||||
|
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
await page.goto(formatDirectTemplatePath(templateV2.directLink?.token || ''));
|
||||||
|
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
await page.getByRole('button', { name: /Page 2/ }).click();
|
||||||
|
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Embed Pages', () => {
|
||||||
|
test('should render PDF on embed sign page (V1 and V2)', async ({ page }) => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
|
||||||
|
const { recipients: recipientsV1 } = await seedPendingDocumentWithFullFields({
|
||||||
|
owner: user,
|
||||||
|
teamId: team.id,
|
||||||
|
recipients: ['embed-signer-v1@test.documenso.com'],
|
||||||
|
fields: [FieldType.SIGNATURE],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { document: documentV2, recipients: recipientsV2 } =
|
||||||
|
await seedPendingDocumentWithFullFields({
|
||||||
|
owner: user,
|
||||||
|
teamId: team.id,
|
||||||
|
recipients: ['embed-signer-v2@test.documenso.com'],
|
||||||
|
fields: [FieldType.SIGNATURE],
|
||||||
|
updateDocumentOptions: { internalVersion: 2 },
|
||||||
|
});
|
||||||
|
await addSecondEnvelopeItem(documentV2.id);
|
||||||
|
|
||||||
|
await page.goto(`/embed/sign/${recipientsV1[0].token}`);
|
||||||
|
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
await page.goto(`/embed/sign/${recipientsV2[0].token}`);
|
||||||
|
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
await page.getByRole('button', { name: /Page 2/ }).click();
|
||||||
|
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should render PDF on embed direct template page (V1 and V2)', async ({ page }) => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
|
||||||
|
const templateV1 = await seedDirectTemplate({
|
||||||
|
title: 'Embed Direct Template V1',
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const templateV2 = await seedDirectTemplate({
|
||||||
|
title: 'Embed Direct Template V2',
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
internalVersion: 2,
|
||||||
|
});
|
||||||
|
await addSecondEnvelopeItem(templateV2.id);
|
||||||
|
|
||||||
|
await page.goto(`/embed/direct/${templateV1.directLink?.token || ''}`);
|
||||||
|
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
await page.goto(`/embed/direct/${templateV2.directLink?.token || ''}`);
|
||||||
|
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
await page.getByRole('button', { name: /Page 2/ }).click();
|
||||||
|
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should render PDF on embed authoring document create page', async ({ page }) => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
|
||||||
|
const { token: apiToken } = await createApiToken({
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
tokenName: 'pdf-viewer-test',
|
||||||
|
expiresIn: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { token: presignToken } = await createEmbeddingPresignToken({
|
||||||
|
apiToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
const embedParams = { darkModeDisabled: false, features: {} };
|
||||||
|
const hash = btoa(encodeURIComponent(JSON.stringify(embedParams)));
|
||||||
|
|
||||||
|
await page.goto(
|
||||||
|
`${NEXT_PUBLIC_WEBAPP_URL()}/embed/v1/authoring/document/create?token=${presignToken}#${hash}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(page.getByText('Configure Document')).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
const titleInput = page.getByLabel('Title');
|
||||||
|
await titleInput.click();
|
||||||
|
await titleInput.fill('PDF Viewer E2E Test');
|
||||||
|
|
||||||
|
const emailInput = page.getByPlaceholder('Email').first();
|
||||||
|
await emailInput.click();
|
||||||
|
await emailInput.fill('test-signer@documenso.com');
|
||||||
|
|
||||||
|
const [fileChooser] = await Promise.all([
|
||||||
|
page.waitForEvent('filechooser'),
|
||||||
|
page
|
||||||
|
.locator('input[type=file]')
|
||||||
|
.first()
|
||||||
|
.evaluate((el) => {
|
||||||
|
if (el instanceof HTMLInputElement) {
|
||||||
|
el.click();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await fileChooser.setFiles(path.join(__dirname, '../../../../assets/example.pdf'));
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
|
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const useElementBounds = (elementOrSelector: HTMLElement | string, withSc
|
|||||||
: elementOrSelector;
|
: elementOrSelector;
|
||||||
|
|
||||||
if (!$el) {
|
if (!$el) {
|
||||||
throw new Error('Element not found');
|
return { top: 0, left: 0, width: 0, height: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (withScroll) {
|
if (withScroll) {
|
||||||
|
|||||||
@@ -57,23 +57,64 @@ export const useFieldPageCoords = (
|
|||||||
};
|
};
|
||||||
}, [calculateCoords]);
|
}, [calculateCoords]);
|
||||||
|
|
||||||
|
// Watch for the page element to appear in the DOM (e.g. after a virtual list
|
||||||
|
// scroll) and recalculate coords. Also attach a ResizeObserver once the page
|
||||||
|
// element exists.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const $page = document.querySelector<HTMLElement>(
|
const pageSelector = `${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`;
|
||||||
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!$page) {
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
return;
|
let observedElement: HTMLElement | null = null;
|
||||||
|
|
||||||
|
const attachResizeObserver = ($page: HTMLElement) => {
|
||||||
|
if ($page === observedElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
calculateCoords();
|
||||||
|
});
|
||||||
|
resizeObserver.observe($page);
|
||||||
|
observedElement = $page;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try to attach immediately if the page already exists.
|
||||||
|
const existingPage = document.querySelector<HTMLElement>(pageSelector);
|
||||||
|
|
||||||
|
if (existingPage) {
|
||||||
|
attachResizeObserver(existingPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
const observer = new ResizeObserver(() => {
|
// Watch for DOM mutations to detect when the page element appears (e.g.
|
||||||
|
// after the virtual list scrolls to a new page and renders it).
|
||||||
|
const mutationObserver = new MutationObserver(() => {
|
||||||
|
const $page = document.querySelector<HTMLElement>(pageSelector);
|
||||||
|
|
||||||
|
if (!$page) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only recalculate when the observed page element has changed (e.g. new
|
||||||
|
// element appeared after virtual list scroll). Skip when mutations are
|
||||||
|
// from elsewhere in the DOM and the page element is unchanged.
|
||||||
|
if ($page === observedElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
calculateCoords();
|
calculateCoords();
|
||||||
|
attachResizeObserver($page);
|
||||||
});
|
});
|
||||||
|
|
||||||
observer.observe($page);
|
mutationObserver.observe(document.body, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
observer.disconnect();
|
mutationObserver.disconnect();
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
observedElement = null;
|
||||||
};
|
};
|
||||||
}, [calculateCoords, field.page]);
|
}, [calculateCoords, field.page]);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether the PDF page element for the given page number is currently
|
||||||
|
* present in the DOM. With virtual list rendering only pages near the viewport
|
||||||
|
* are mounted, so this hook lets consumers skip rendering when their page is
|
||||||
|
* virtualised away.
|
||||||
|
*/
|
||||||
|
export const useIsPageInDom = (pageNumber: number) => {
|
||||||
|
const [isPageInDom, setIsPageInDom] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const selector = `${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${pageNumber}"]`;
|
||||||
|
|
||||||
|
setIsPageInDom(document.querySelector(selector) !== null);
|
||||||
|
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
setIsPageInDom(document.querySelector(selector) !== null);
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(document.body, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, [pageNumber]);
|
||||||
|
|
||||||
|
return isPageInDom;
|
||||||
|
};
|
||||||
@@ -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 const 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),
|
||||||
}
|
height: Math.floor(scaledViewport.height),
|
||||||
|
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, imageUrl],
|
||||||
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,49 @@
|
|||||||
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 { DocumentDataType, type Field, type Recipient } from '@prisma/client';
|
||||||
|
import pMap from 'p-map';
|
||||||
|
|
||||||
|
import { PDF_IMAGE_RENDER_SCALE } from '@documenso/lib/constants/pdf-viewer';
|
||||||
|
import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download';
|
||||||
|
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;
|
||||||
@@ -25,11 +51,19 @@ type EnvelopeRenderOverrideSettings = {
|
|||||||
showRecipientSigningStatus?: boolean;
|
showRecipientSigningStatus?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EnvelopeRenderItem = TEnvelope['envelopeItems'][number];
|
type EnvelopeRenderItem = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
order: number;
|
||||||
|
envelopeId: string;
|
||||||
|
data?: Uint8Array | null;
|
||||||
|
};
|
||||||
|
|
||||||
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 +80,17 @@ 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' | 'status' | 'type'>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The envelope items to render.
|
||||||
|
*/
|
||||||
|
envelopeItems: EnvelopeRenderItem[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional fields which are passed down to renderers for custom rendering needs.
|
* Optional fields which are passed down to renderers for custom rendering needs.
|
||||||
@@ -70,6 +114,13 @@ interface EnvelopeRenderProviderProps {
|
|||||||
*/
|
*/
|
||||||
token: string | undefined;
|
token: string | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The presign token to access the envelope.
|
||||||
|
*
|
||||||
|
* If not provided, it will be assumed that the current user can access the document.
|
||||||
|
*/
|
||||||
|
presignToken?: string | undefined;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom override settings for generic page renderers.
|
* Custom override settings for generic page renderers.
|
||||||
*/
|
*/
|
||||||
@@ -89,81 +140,251 @@ 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,
|
||||||
envelope,
|
envelope,
|
||||||
|
envelopeItems: envelopeItemsFromProps,
|
||||||
fields,
|
fields,
|
||||||
token,
|
token,
|
||||||
|
presignToken,
|
||||||
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),
|
() => [...envelopeItemsFromProps].sort((a, b) => a.order - b.order),
|
||||||
[envelope.envelopeItems],
|
[envelopeItemsFromProps],
|
||||||
);
|
);
|
||||||
|
|
||||||
const loadEnvelopeItemPdfFile = async (envelopeItem: EnvelopeRenderItem) => {
|
const [currentItem, setCurrentItem] = useState<EnvelopeRenderItem | null>(
|
||||||
if (files[envelopeItem.id]?.status === 'loading') {
|
envelopeItems[0] ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch metadata and preload initial images when the envelope or token changes.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchEnvelopeRenderData();
|
||||||
|
}, [envelope.id, envelopeItems, token, version, presignToken]);
|
||||||
|
|
||||||
|
const fetchEnvelopeRenderData = useCallback(async () => {
|
||||||
|
if (envelopeItems.length === 0) {
|
||||||
|
setEnvelopeItemsMetaLoadingState('loaded');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!files[envelopeItem.id]) {
|
// Handle "create" embedding mode since all the files are local in that scenario.
|
||||||
setFiles((prev) => ({
|
if (!envelope.id) {
|
||||||
...prev,
|
await handleLocalFileFetch();
|
||||||
[envelopeItem.id]: {
|
|
||||||
status: 'loading',
|
return;
|
||||||
},
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Record when this fetch started to detect stale responses
|
||||||
|
const fetchStartedAt = Date.now();
|
||||||
|
fetchStartedAtRef.current = fetchStartedAt;
|
||||||
|
|
||||||
|
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,
|
||||||
|
presignToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
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[]> = {};
|
||||||
|
|
||||||
|
const s3EnvelopeItems = data.envelopeItems.filter(
|
||||||
|
(item) => item.documentDataType === DocumentDataType.S3_PATH,
|
||||||
|
);
|
||||||
|
|
||||||
|
const localPdfs: { envelopeItemId: string; documentDataId: string; data: Uint8Array }[] = [];
|
||||||
|
|
||||||
|
// Handle local envelope items from embedding flows.
|
||||||
|
for (const item of envelopeItems) {
|
||||||
|
if (item.data) {
|
||||||
|
localPdfs.push({
|
||||||
|
envelopeItemId: item.id,
|
||||||
|
documentDataId: item.id,
|
||||||
|
data: new Uint8Array(item.data as Uint8Array),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes64EnvelopeItems = data.envelopeItems.filter(
|
||||||
|
(item) => item.documentDataType !== DocumentDataType.S3_PATH,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle byte64 envelope items from the database.
|
||||||
|
const pdfs = await pMap(
|
||||||
|
bytes64EnvelopeItems,
|
||||||
|
async (item) => {
|
||||||
|
const envelopeItemPdfUrl = getEnvelopeItemPdfUrl({
|
||||||
|
type: 'view',
|
||||||
|
envelopeItem: {
|
||||||
|
id: item.envelopeItemId,
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
},
|
||||||
|
token,
|
||||||
|
presignToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(envelopeItemPdfUrl);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch envelope item PDF: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pdfBytes = await response.arrayBuffer();
|
||||||
|
|
||||||
|
return {
|
||||||
|
envelopeItemId: item.envelopeItemId,
|
||||||
|
documentDataId: item.documentDataId,
|
||||||
|
data: new Uint8Array(pdfBytes),
|
||||||
|
};
|
||||||
},
|
},
|
||||||
}));
|
{ concurrency: 5 },
|
||||||
|
);
|
||||||
|
|
||||||
|
localPdfs.push(...pdfs);
|
||||||
|
|
||||||
|
for (const item of s3EnvelopeItems) {
|
||||||
|
metaMap[item.envelopeItemId] = item.pages.map((page, pageIndex) => {
|
||||||
|
const imageUrl = getEnvelopeItemPageImageUrl({
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
envelopeItemId: item.envelopeItemId,
|
||||||
|
documentDataId: item.documentDataId,
|
||||||
|
pageIndex,
|
||||||
|
token,
|
||||||
|
presignToken,
|
||||||
|
version,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
envelopeItemId: item.envelopeItemId,
|
||||||
|
documentDataId: item.documentDataId,
|
||||||
|
pageIndex,
|
||||||
|
pageNumber: pageIndex + 1,
|
||||||
|
pageWidth: page.originalWidth,
|
||||||
|
pageHeight: page.originalHeight,
|
||||||
|
imageUrl,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localPdfs.length > 0) {
|
||||||
|
// Dynamically import "pdfToImagesClientSide" function to prevent bundling.
|
||||||
|
const { pdfToImagesClientSide } = await import(
|
||||||
|
'@documenso/lib/server-only/ai/pdf-to-images.client'
|
||||||
|
);
|
||||||
|
|
||||||
|
await pMap(
|
||||||
|
localPdfs,
|
||||||
|
async (item) => {
|
||||||
|
const pdfImages = await pdfToImagesClientSide(item.data, {
|
||||||
|
scale: PDF_IMAGE_RENDER_SCALE,
|
||||||
|
});
|
||||||
|
|
||||||
|
metaMap[item.envelopeItemId] = pdfImages.map((image) => ({
|
||||||
|
envelopeItemId: item.envelopeItemId,
|
||||||
|
documentDataId: item.documentDataId,
|
||||||
|
pageIndex: image.pageIndex,
|
||||||
|
pageNumber: image.pageIndex + 1,
|
||||||
|
pageWidth: image.width,
|
||||||
|
pageHeight: image.height,
|
||||||
|
imageUrl: image.image,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
{ concurrency: 10 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
console.error('Failed to load envelope data:', error);
|
||||||
|
setEnvelopeItemsMetaLoadingState('error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [envelope.id, envelopeItems, token, version]);
|
||||||
|
|
||||||
setFiles((prev) => ({
|
const handleLocalFileFetch = async () => {
|
||||||
...prev,
|
setEnvelopeItemsMetaLoadingState('loading');
|
||||||
[envelopeItem.id]: {
|
|
||||||
status: 'error',
|
try {
|
||||||
},
|
// Build a map of envelope items by ID
|
||||||
}));
|
const metaMap: Record<string, BasePageRenderData[]> = {};
|
||||||
|
|
||||||
|
// Dynamically import "pdfToImagesClientSide" function to prevent bundling.
|
||||||
|
const { pdfToImagesClientSide } = await import(
|
||||||
|
'@documenso/lib/server-only/ai/pdf-to-images.client'
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const item of envelopeItems) {
|
||||||
|
if (item.data) {
|
||||||
|
// Clone the buffer so PDF.js can transfer it to its worker without detaching the one in state
|
||||||
|
const pdfBytes = new Uint8Array(structuredClone(item.data));
|
||||||
|
|
||||||
|
const pdfImages = await pdfToImagesClientSide(pdfBytes, {
|
||||||
|
scale: PDF_IMAGE_RENDER_SCALE,
|
||||||
|
});
|
||||||
|
|
||||||
|
metaMap[item.id] = pdfImages.map((image) => ({
|
||||||
|
envelopeItemId: item.id,
|
||||||
|
documentDataId: item.id,
|
||||||
|
pageIndex: image.pageIndex,
|
||||||
|
pageNumber: image.pageIndex + 1,
|
||||||
|
pageWidth: image.width,
|
||||||
|
pageHeight: image.height,
|
||||||
|
imageUrl: image.image,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setEnvelopeItemsMeta(metaMap);
|
||||||
|
setEnvelopeItemsMetaLoadingState('loaded');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load envelope data:', error);
|
||||||
|
setEnvelopeItemsMetaLoadingState('error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPdfBuffer = useCallback(
|
|
||||||
(envelopeItemId: string) => {
|
|
||||||
return files[envelopeItemId] || null;
|
|
||||||
},
|
|
||||||
[files],
|
|
||||||
);
|
|
||||||
|
|
||||||
const setCurrentEnvelopeItem = (envelopeItemId: string) => {
|
const setCurrentEnvelopeItem = (envelopeItemId: string) => {
|
||||||
const foundItem = envelope.envelopeItems.find((item) => item.id === envelopeItemId);
|
const foundItem = envelopeItems.find((item) => item.id === envelopeItemId);
|
||||||
|
|
||||||
setCurrentItem(foundItem ?? null);
|
setCurrentItem(foundItem ?? null);
|
||||||
};
|
};
|
||||||
@@ -179,15 +400,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 +419,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,
|
||||||
|
|||||||
@@ -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 }>;
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -285,18 +285,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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -496,11 +491,14 @@ const decorateAndSignPdf = async ({
|
|||||||
// Add suffix based on document status
|
// Add suffix based on document status
|
||||||
const suffix = isRejected ? '_rejected.pdf' : '_signed.pdf';
|
const suffix = isRejected ? '_rejected.pdf' : '_signed.pdf';
|
||||||
|
|
||||||
const newDocumentData = await putPdfFileServerSide({
|
const newDocumentData = await putPdfFileServerSide(
|
||||||
name: `${name}${suffix}`,
|
{
|
||||||
type: 'application/pdf',
|
name: `${name}${suffix}`,
|
||||||
arrayBuffer: async () => Promise.resolve(pdfBytes),
|
type: 'application/pdf',
|
||||||
});
|
arrayBuffer: async () => Promise.resolve(pdfBytes),
|
||||||
|
},
|
||||||
|
envelopeItem.documentData.initialData,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
oldDocumentDataId: envelopeItem.documentData.id,
|
oldDocumentDataId: envelopeItem.documentData.id,
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import pMap from 'p-map';
|
||||||
|
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
|
||||||
|
|
||||||
|
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||||
|
'pdfjs-dist/legacy/build/pdf.worker.min.mjs',
|
||||||
|
import.meta.url,
|
||||||
|
).toString();
|
||||||
|
|
||||||
|
export type PdfToImagesOptions = {
|
||||||
|
scale?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PdfImageResult = {
|
||||||
|
pageIndex: number;
|
||||||
|
image: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pdfToImagesClientSide = async (
|
||||||
|
pdfBytes: Uint8Array,
|
||||||
|
options: PdfToImagesOptions = {},
|
||||||
|
): Promise<PdfImageResult[]> => {
|
||||||
|
const { scale = 2 } = options;
|
||||||
|
|
||||||
|
const task = pdfjsLib.getDocument({
|
||||||
|
data: pdfBytes,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pdf = await task.promise;
|
||||||
|
|
||||||
|
const images = await pMap(
|
||||||
|
Array.from({ length: pdf.numPages }),
|
||||||
|
async (_, pageIndex) => {
|
||||||
|
const pageNumber = pageIndex + 1;
|
||||||
|
const page = await pdf.getPage(pageNumber);
|
||||||
|
|
||||||
|
const viewport = page.getViewport({ scale });
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = Math.floor(viewport.width);
|
||||||
|
canvas.height = Math.floor(viewport.height);
|
||||||
|
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('Failed to get 2D canvas context');
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.render({
|
||||||
|
canvasContext: context,
|
||||||
|
viewport,
|
||||||
|
canvas,
|
||||||
|
}).promise;
|
||||||
|
|
||||||
|
const imageBase64 = canvas.toDataURL('image/jpeg');
|
||||||
|
|
||||||
|
page.cleanup();
|
||||||
|
|
||||||
|
return {
|
||||||
|
pageIndex,
|
||||||
|
image: imageBase64,
|
||||||
|
width: canvas.width,
|
||||||
|
height: canvas.height,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ concurrency: 50 },
|
||||||
|
);
|
||||||
|
|
||||||
|
await pdf.destroy();
|
||||||
|
await task.destroy();
|
||||||
|
|
||||||
|
return images;
|
||||||
|
};
|
||||||
@@ -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,84 @@ 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 or equal to 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),
|
||||||
|
};
|
||||||
|
|
||||||
|
void page.cleanup();
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|||||||
@@ -5,14 +5,25 @@ import { prisma } from '@documenso/prisma';
|
|||||||
export type CreateDocumentDataOptions = {
|
export type CreateDocumentDataOptions = {
|
||||||
type: DocumentDataType;
|
type: DocumentDataType;
|
||||||
data: string;
|
data: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The initial data that was used to create the document data.
|
||||||
|
*
|
||||||
|
* If not provided, the current data will be used.
|
||||||
|
*/
|
||||||
|
initialData?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createDocumentData = async ({ type, data }: CreateDocumentDataOptions) => {
|
export const createDocumentData = async ({
|
||||||
|
type,
|
||||||
|
data,
|
||||||
|
initialData,
|
||||||
|
}: CreateDocumentDataOptions) => {
|
||||||
return await prisma.documentData.create({
|
return await prisma.documentData.create({
|
||||||
data: {
|
data: {
|
||||||
type,
|
type,
|
||||||
data,
|
data,
|
||||||
initialData: data,
|
initialData: initialData || data,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -333,7 +333,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);
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {
|
||||||
@@ -20,7 +26,7 @@ type File = {
|
|||||||
* Uploads a document file to the appropriate storage location and creates
|
* Uploads a document file to the appropriate storage location and creates
|
||||||
* a document data record.
|
* a document data record.
|
||||||
*/
|
*/
|
||||||
export const putPdfFileServerSide = async (file: File) => {
|
export const putPdfFileServerSide = async (file: File, initialData?: string) => {
|
||||||
const isEncryptedDocumentsAllowed = false; // Was feature flag.
|
const isEncryptedDocumentsAllowed = false; // Was feature flag.
|
||||||
|
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
@@ -41,7 +47,73 @@ 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, initialData });
|
||||||
|
|
||||||
|
void extractAndStorePdfImages(arrayBuffer, newDocumentData.id).catch((err) => {
|
||||||
|
console.error(`Error extracting and storing PDF images: ${err}`);
|
||||||
|
|
||||||
|
// Do nothing.
|
||||||
|
});
|
||||||
|
|
||||||
|
return newDocumentData;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract and stores page images and metadata to S3.
|
||||||
|
*/
|
||||||
|
export const extractAndStorePdfImages = async (
|
||||||
|
arrayBuffer: ArrayBuffer,
|
||||||
|
documentDataId: string,
|
||||||
|
): Promise<TDocumentDataMeta['pages']> => {
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Only update metadata (page dimensions). Never update type, data, or initialData:
|
||||||
|
// DocumentData content is immutable so cache keys (documentDataId) stay valid.
|
||||||
|
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 +135,18 @@ 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).catch((err) => {
|
||||||
|
console.error(`Error extracting and storing PDF images: ${err}`);
|
||||||
|
|
||||||
|
// Do nothing.
|
||||||
|
});
|
||||||
|
|
||||||
|
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,29 @@ 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) => {
|
||||||
|
// Basic safeguard to prevent path traversal.
|
||||||
|
// Key should never be user-controlled.
|
||||||
|
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 || null;
|
||||||
|
};
|
||||||
|
|
||||||
const getS3Client = () => {
|
const getS3Client = () => {
|
||||||
const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT');
|
const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT');
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
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];
|
||||||
|
|
||||||
|
// Basic safeguard to prevent path traversal.
|
||||||
|
// Key should never be user-controlled.
|
||||||
|
if (baseKey.includes('..') || baseKey.startsWith('/')) {
|
||||||
|
throw new Error('Invalid S3 key');
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${baseKey}/${pageIndex}.jpeg`;
|
||||||
|
};
|
||||||
@@ -23,25 +23,44 @@ export const sortFieldsByPosition = (fields: Field[]): Field[] => {
|
|||||||
*/
|
*/
|
||||||
export const validateFieldsInserted = (fields: Field[]): boolean => {
|
export const validateFieldsInserted = (fields: Field[]): boolean => {
|
||||||
const fieldCardElements = document.getElementsByClassName('field-card-container');
|
const fieldCardElements = document.getElementsByClassName('field-card-container');
|
||||||
|
const pdfContent = document.querySelector('[data-pdf-content]');
|
||||||
|
|
||||||
// Attach validate attribute on all fields.
|
const uninsertedFields = sortFieldsByPosition(fields.filter((field) => !field.inserted));
|
||||||
|
|
||||||
|
// All fields are inserted — clear the validation signal.
|
||||||
|
if (uninsertedFields.length === 0) {
|
||||||
|
pdfContent?.removeAttribute('data-validate-fields');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach validate attribute on all fields currently in the DOM.
|
||||||
Array.from(fieldCardElements).forEach((element) => {
|
Array.from(fieldCardElements).forEach((element) => {
|
||||||
element.setAttribute('data-validate', 'true');
|
element.setAttribute('data-validate', 'true');
|
||||||
});
|
});
|
||||||
|
|
||||||
const uninsertedFields = sortFieldsByPosition(fields.filter((field) => !field.inserted));
|
// Also set a signal on the PDF viewer container so that field elements that
|
||||||
|
// mount later (e.g. after the virtual list scrolls to a new page) can pick
|
||||||
|
// up the validation state.
|
||||||
|
pdfContent?.setAttribute('data-validate-fields', 'true');
|
||||||
|
|
||||||
const firstUninsertedField = uninsertedFields[0];
|
const firstUninsertedField = uninsertedFields[0];
|
||||||
|
|
||||||
const firstUninsertedFieldElement =
|
if (firstUninsertedField) {
|
||||||
firstUninsertedField && document.getElementById(`field-${firstUninsertedField.id}`);
|
// Try direct element scroll first (works if the field's page is currently rendered).
|
||||||
|
const firstUninsertedFieldElement = document.getElementById(`field-${firstUninsertedField.id}`);
|
||||||
|
|
||||||
if (firstUninsertedFieldElement) {
|
if (firstUninsertedFieldElement) {
|
||||||
firstUninsertedFieldElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
firstUninsertedFieldElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
return false;
|
} else {
|
||||||
|
// Field not in DOM (page virtualized away) — signal the PDF viewer to
|
||||||
|
// scroll to the correct page via the data attribute.
|
||||||
|
if (pdfContent) {
|
||||||
|
pdfContent.setAttribute('data-scroll-to-page', String(firstUninsertedField.page));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return uninsertedFields.length === 0;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const validateFieldsUninserted = (): boolean => {
|
export const validateFieldsUninserted = (): boolean => {
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "DocumentData" ADD COLUMN "metadata" JSONB;
|
||||||
@@ -488,11 +488,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?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+2
@@ -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>;
|
||||||
|
|||||||
@@ -98,10 +98,8 @@ export const DocumentReadOnlyFields = ({
|
|||||||
setHiddenFieldIds((prev) => ({ ...prev, [fieldId]: true }));
|
setHiddenFieldIds((prev) => ({ ...prev, [fieldId]: true }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const highestPageNumber = Math.max(...fields.map((field) => field.page));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ElementVisible target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}>
|
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||||
{fields.map(
|
{fields.map(
|
||||||
(field) =>
|
(field) =>
|
||||||
!hiddenFieldIds[field.secondaryId] && (
|
!hiddenFieldIds[field.secondaryId] && (
|
||||||
@@ -163,7 +161,7 @@ export const DocumentReadOnlyFields = ({
|
|||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-1 text-center text-xs">
|
<p className="mt-1 text-center text-xs text-muted-foreground">
|
||||||
{getRecipientDisplayText(field.recipient)}
|
{getRecipientDisplayText(field.recipient)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ export function EnvelopeRecipientFieldTooltip({
|
|||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-1 text-center text-xs">
|
<p className="mt-1 text-center text-xs text-muted-foreground">
|
||||||
{getRecipientDisplayText(field.recipient)}
|
{getRecipientDisplayText(field.recipient)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { createPortal } from 'react-dom';
|
|||||||
|
|
||||||
import { useElementBounds } from '@documenso/lib/client-only/hooks/use-element-bounds';
|
import { useElementBounds } from '@documenso/lib/client-only/hooks/use-element-bounds';
|
||||||
import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords';
|
import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords';
|
||||||
|
import { useIsPageInDom } from '@documenso/lib/client-only/hooks/use-is-page-in-dom';
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
|
|
||||||
@@ -81,6 +82,8 @@ export function FieldRootContainer({
|
|||||||
readonly,
|
readonly,
|
||||||
}: FieldRootContainerProps) {
|
}: FieldRootContainerProps) {
|
||||||
const [isValidating, setIsValidating] = useState(false);
|
const [isValidating, setIsValidating] = useState(false);
|
||||||
|
const isPageInDom = useIsPageInDom(field.page);
|
||||||
|
|
||||||
const ref = React.useRef<HTMLDivElement>(null);
|
const ref = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -88,6 +91,21 @@ export function FieldRootContainer({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check the validation signal on the PDF viewer container. When a field
|
||||||
|
// mounts after the virtual list scrolls to its page, the per-element
|
||||||
|
// `data-validate` attribute will not have been set yet. The signal on the
|
||||||
|
// `[data-pdf-content]` container bridges this gap so newly-rendered fields
|
||||||
|
// pick up the validation state immediately.
|
||||||
|
const pdfContent = document.querySelector('[data-pdf-content]');
|
||||||
|
|
||||||
|
if (
|
||||||
|
pdfContent?.getAttribute('data-validate-fields') === 'true' &&
|
||||||
|
isFieldUnsignedAndRequired(field)
|
||||||
|
) {
|
||||||
|
ref.current.setAttribute('data-validate', 'true');
|
||||||
|
setIsValidating(true);
|
||||||
|
}
|
||||||
|
|
||||||
const observer = new MutationObserver((_mutations) => {
|
const observer = new MutationObserver((_mutations) => {
|
||||||
if (ref.current) {
|
if (ref.current) {
|
||||||
setIsValidating(ref.current.getAttribute('data-validate') === 'true');
|
setIsValidating(ref.current.getAttribute('data-validate') === 'true');
|
||||||
@@ -101,7 +119,11 @@ export function FieldRootContainer({
|
|||||||
return () => {
|
return () => {
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [isPageInDom]);
|
||||||
|
|
||||||
|
if (!isPageInDom) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FieldContainerPortal field={field}>
|
<FieldContainerPortal field={field}>
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
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 type { ScrollTarget } from '../virtual-list/use-virtual-list';
|
||||||
|
import { useVirtualList } from '../virtual-list/use-virtual-list';
|
||||||
|
import { PdfViewerErrorState, PdfViewerLoadingState } from './pdf-viewer-states';
|
||||||
|
import { useScrollToPage } from './use-scroll-to-page';
|
||||||
|
|
||||||
|
export type EnvelopePdfViewerProps = {
|
||||||
|
className?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ref to the scrollable parent container that handles scrolling.
|
||||||
|
*
|
||||||
|
* This must point to an element with `overflow-y: auto` or `overflow-y: scroll`
|
||||||
|
* that is an ancestor of this component, or `'window'` to use the browser
|
||||||
|
* window as the scroll container.
|
||||||
|
*/
|
||||||
|
scrollParentRef: ScrollTarget;
|
||||||
|
|
||||||
|
onDocumentLoad?: () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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}
|
||||||
|
constraintRef={$el}
|
||||||
|
pages={currentItemMeta}
|
||||||
|
envelopeItemId={currentEnvelopeItem.id}
|
||||||
|
numPages={numPages}
|
||||||
|
CustomPageRenderer={CustomPageRenderer}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* No current item selected */}
|
||||||
|
{envelopeItemsMetaLoadingState === 'loaded' && !currentEnvelopeItem && (
|
||||||
|
<div className="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: ScrollTarget;
|
||||||
|
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 contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const { virtualItems, totalSize, constraintWidth, scrollToItem } = useVirtualList({
|
||||||
|
scrollRef: scrollParentRef,
|
||||||
|
constraintRef,
|
||||||
|
contentRef,
|
||||||
|
itemCount: numPages,
|
||||||
|
itemSize: (index, width) => {
|
||||||
|
const pageMeta = pages[index];
|
||||||
|
|
||||||
|
// Calculate height based on aspect ratio and available width
|
||||||
|
const aspectRatio = pageMeta.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,
|
||||||
|
});
|
||||||
|
|
||||||
|
useScrollToPage(contentRef, scrollToItem);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={contentRef}
|
||||||
|
data-pdf-content=""
|
||||||
|
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,33 +0,0 @@
|
|||||||
import React, { Suspense, lazy } from 'react';
|
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { type PDFDocumentProxy } from 'pdfjs-dist';
|
|
||||||
|
|
||||||
import type { PdfViewerRendererMode } from './pdf-viewer-konva';
|
|
||||||
|
|
||||||
export type LoadedPDFDocument = PDFDocumentProxy;
|
|
||||||
|
|
||||||
export type PDFViewerProps = {
|
|
||||||
className?: string;
|
|
||||||
onDocumentLoad?: () => void;
|
|
||||||
renderer: PdfViewerRendererMode;
|
|
||||||
[key: string]: unknown;
|
|
||||||
} & Omit<React.HTMLAttributes<HTMLDivElement>, 'onPageClick'>;
|
|
||||||
|
|
||||||
const EnvelopePdfViewer = lazy(async () => import('./pdf-viewer-konva'));
|
|
||||||
|
|
||||||
export const PDFViewerKonvaLazy = (props: PDFViewerProps) => {
|
|
||||||
return (
|
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<div>
|
|
||||||
<Trans>Loading...</Trans>
|
|
||||||
</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,26 @@
|
|||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
|
||||||
|
import { Spinner } from '@documenso/ui/primitives/spinner';
|
||||||
|
|
||||||
|
export const PdfViewerLoadingState = () => {
|
||||||
|
return (
|
||||||
|
<div className="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,303 @@
|
|||||||
|
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 type { ScrollTarget } from '../virtual-list/use-virtual-list';
|
||||||
|
import { useVirtualList } from '../virtual-list/use-virtual-list';
|
||||||
|
import { PdfViewerErrorState, PdfViewerLoadingState } from './pdf-viewer-states';
|
||||||
|
import { useScrollToPage } from './use-scroll-to-page';
|
||||||
|
|
||||||
|
export type OverrideImage = {
|
||||||
|
image: string;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ref to the scrollable parent container that handles scrolling.
|
||||||
|
*
|
||||||
|
* This must point to an element with `overflow-y: auto` or `overflow-y: scroll`
|
||||||
|
* that is an ancestor of this component, or `'window'` to use the browser
|
||||||
|
* window as the scroll container.
|
||||||
|
*/
|
||||||
|
scrollParentRef: ScrollTarget;
|
||||||
|
|
||||||
|
onDocumentLoad?: () => void;
|
||||||
|
overrideImages?: OverrideImage[];
|
||||||
|
} & React.HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
|
export const PDFViewer = ({
|
||||||
|
className,
|
||||||
|
envelopeItem,
|
||||||
|
token,
|
||||||
|
presignToken,
|
||||||
|
version,
|
||||||
|
scrollParentRef,
|
||||||
|
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: image.image,
|
||||||
|
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('w-full', className)} {...props}>
|
||||||
|
{/* Loading State */}
|
||||||
|
{isLoading && <PdfViewerLoadingState />}
|
||||||
|
|
||||||
|
{/* Error State */}
|
||||||
|
{hasError && <PdfViewerErrorState />}
|
||||||
|
|
||||||
|
{/* Loaded State */}
|
||||||
|
{loadingState === 'loaded' && numPages > 0 && (
|
||||||
|
<VirtualizedPageList
|
||||||
|
scrollParentRef={scrollParentRef}
|
||||||
|
constraintRef={$el}
|
||||||
|
numPages={numPages}
|
||||||
|
pages={derivedPages}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type VirtualizedPageListProps = {
|
||||||
|
scrollParentRef: ScrollTarget;
|
||||||
|
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 contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const { virtualItems, totalSize, constraintWidth, scrollToItem } = useVirtualList({
|
||||||
|
scrollRef: scrollParentRef,
|
||||||
|
constraintRef,
|
||||||
|
contentRef,
|
||||||
|
itemCount: numPages,
|
||||||
|
itemSize: (index, width) => {
|
||||||
|
const pageMeta = pages[index];
|
||||||
|
|
||||||
|
// Calculate height based on aspect ratio and available width
|
||||||
|
const aspectRatio = pageMeta.height / pageMeta.width;
|
||||||
|
const scaledHeight = width * aspectRatio;
|
||||||
|
|
||||||
|
// Add 32px for the page number text and margins (my-2 = 8px * 2 + text height ~16px)
|
||||||
|
return scaledHeight + 32;
|
||||||
|
},
|
||||||
|
overscan: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
useScrollToPage(contentRef, scrollToItem);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={contentRef}
|
||||||
|
data-pdf-content=""
|
||||||
|
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,46 @@
|
|||||||
|
import { type RefObject, useEffect } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watch for `data-scroll-to-page` attribute changes on a container element.
|
||||||
|
*
|
||||||
|
* When set (by `validateFieldsInserted`, `handleOnNextFieldClick`, or similar),
|
||||||
|
* scroll the virtual list to the requested page and clear the attribute.
|
||||||
|
*
|
||||||
|
* This is the communication bridge between field validation logic (which knows
|
||||||
|
* which page to scroll to) and the virtual list (which knows how to scroll).
|
||||||
|
*/
|
||||||
|
export const useScrollToPage = (
|
||||||
|
contentRef: RefObject<HTMLElement | null>,
|
||||||
|
scrollToItem: (index: number) => void,
|
||||||
|
) => {
|
||||||
|
useEffect(() => {
|
||||||
|
const el = contentRef.current;
|
||||||
|
|
||||||
|
if (!el) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
for (const mutation of mutations) {
|
||||||
|
if (mutation.type === 'attributes' && mutation.attributeName === 'data-scroll-to-page') {
|
||||||
|
const raw = el.getAttribute('data-scroll-to-page');
|
||||||
|
|
||||||
|
if (raw) {
|
||||||
|
const pageNumber = parseInt(raw, 10);
|
||||||
|
|
||||||
|
if (!isNaN(pageNumber) && pageNumber >= 1) {
|
||||||
|
// Pages are 1-indexed, virtual list items are 0-indexed.
|
||||||
|
scrollToItem(pageNumber - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
el.removeAttribute('data-scroll-to-page');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(el, { attributes: true, attributeFilter: ['data-scroll-to-page'] });
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [contentRef, scrollToItem]);
|
||||||
|
};
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
export type ScrollTarget = React.RefObject<HTMLElement | null> | 'window';
|
||||||
|
|
||||||
|
export type VirtualListOptions = {
|
||||||
|
scrollRef: ScrollTarget;
|
||||||
|
constraintRef?: React.RefObject<HTMLElement | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ref to the element that contains the virtual list content.
|
||||||
|
*
|
||||||
|
* Used to calculate the offset between the scroll container and the virtual
|
||||||
|
* list when the scroll container is a parent element higher in the DOM tree.
|
||||||
|
*
|
||||||
|
* When the virtual list is not at the top of the scroll container (e.g. there
|
||||||
|
* are headers, alerts, or other content above it), this offset ensures the
|
||||||
|
* scroll position is correctly adjusted for virtualization calculations.
|
||||||
|
*/
|
||||||
|
contentRef?: React.RefObject<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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scroll the scroll container so that the item at the given index is visible.
|
||||||
|
*
|
||||||
|
* The scroll position is calculated from the precomputed item offsets and
|
||||||
|
* adjusted for any content offset (e.g. headers above the virtual list).
|
||||||
|
*/
|
||||||
|
scrollToItem: (index: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A minimal list virtualizer hook that supports fixed item sizes and external scroll containers.
|
||||||
|
*
|
||||||
|
* @param options - Configuration options for the virtual list
|
||||||
|
* @returns Virtual items to render, total size, and constraint width
|
||||||
|
*/
|
||||||
|
export const useVirtualList = (options: VirtualListOptions): VirtualListResult => {
|
||||||
|
const { scrollRef, constraintRef, contentRef, itemCount, itemSize, overscan = 3 } = options;
|
||||||
|
|
||||||
|
const [scrollTop, setScrollTop] = useState(0);
|
||||||
|
const [viewportHeight, setViewportHeight] = useState(0);
|
||||||
|
const [constraintWidth, setConstraintWidth] = useState(0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The offset of the content element relative to the scroll container.
|
||||||
|
*
|
||||||
|
* This is recalculated on scroll to handle cases where dynamic content
|
||||||
|
* above the virtual list changes size.
|
||||||
|
*/
|
||||||
|
const contentOffsetRef = useRef(0);
|
||||||
|
|
||||||
|
// Track constraint element width with ResizeObserver
|
||||||
|
useEffect(() => {
|
||||||
|
const el = constraintRef?.current;
|
||||||
|
|
||||||
|
if (!el) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new ResizeObserver((entries) => {
|
||||||
|
const entry = entries[0];
|
||||||
|
|
||||||
|
if (entry) {
|
||||||
|
setConstraintWidth(entry.contentRect.width);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(el);
|
||||||
|
|
||||||
|
// Set initial width
|
||||||
|
setConstraintWidth(el.getBoundingClientRect().width);
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [constraintRef]);
|
||||||
|
|
||||||
|
// Track scroll container dimensions with ResizeObserver
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollRef === 'window') {
|
||||||
|
const handleResize = () => {
|
||||||
|
setViewportHeight(window.innerHeight);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
// Set initial height
|
||||||
|
setViewportHeight(window.innerHeight);
|
||||||
|
|
||||||
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
|
}
|
||||||
|
|
||||||
|
const el = scrollRef.current;
|
||||||
|
|
||||||
|
if (!el) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new ResizeObserver((entries) => {
|
||||||
|
const entry = entries[0];
|
||||||
|
|
||||||
|
if (entry) {
|
||||||
|
setViewportHeight(entry.contentRect.height);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(el);
|
||||||
|
|
||||||
|
// Set initial height
|
||||||
|
setViewportHeight(el.getBoundingClientRect().height);
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [scrollRef]);
|
||||||
|
|
||||||
|
// Handle scroll events and calculate content offset
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollRef === 'window') {
|
||||||
|
const calculateOffset = () => {
|
||||||
|
const contentEl = contentRef?.current;
|
||||||
|
|
||||||
|
if (!contentEl) {
|
||||||
|
contentOffsetRef.current = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For window scrolling, the offset is the distance from the top of the
|
||||||
|
// content element to the top of the document, which is its bounding rect
|
||||||
|
// top plus the current scroll position.
|
||||||
|
contentOffsetRef.current = contentEl.getBoundingClientRect().top + window.scrollY;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
calculateOffset();
|
||||||
|
|
||||||
|
const adjustedScrollTop = Math.max(0, window.scrollY - contentOffsetRef.current);
|
||||||
|
setScrollTop(adjustedScrollTop);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
|
||||||
|
// Set initial values
|
||||||
|
calculateOffset();
|
||||||
|
const adjustedScrollTop = Math.max(0, window.scrollY - contentOffsetRef.current);
|
||||||
|
setScrollTop(adjustedScrollTop);
|
||||||
|
|
||||||
|
return () => window.removeEventListener('scroll', handleScroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollEl = scrollRef.current;
|
||||||
|
|
||||||
|
if (!scrollEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculateOffset = () => {
|
||||||
|
const contentEl = contentRef?.current;
|
||||||
|
|
||||||
|
if (!contentEl) {
|
||||||
|
contentOffsetRef.current = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollRect = scrollEl.getBoundingClientRect();
|
||||||
|
const contentRect = contentEl.getBoundingClientRect();
|
||||||
|
|
||||||
|
// The offset is the distance from the top of the content element to
|
||||||
|
// the top of the scroll container, adjusted for current scroll position.
|
||||||
|
contentOffsetRef.current = contentRect.top - scrollRect.top + scrollEl.scrollTop;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
calculateOffset();
|
||||||
|
|
||||||
|
const adjustedScrollTop = Math.max(0, scrollEl.scrollTop - contentOffsetRef.current);
|
||||||
|
setScrollTop(adjustedScrollTop);
|
||||||
|
};
|
||||||
|
|
||||||
|
scrollEl.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
|
||||||
|
// Set initial values
|
||||||
|
calculateOffset();
|
||||||
|
const adjustedScrollTop = Math.max(0, scrollEl.scrollTop - contentOffsetRef.current);
|
||||||
|
setScrollTop(adjustedScrollTop);
|
||||||
|
|
||||||
|
return () => scrollEl.removeEventListener('scroll', handleScroll);
|
||||||
|
}, [scrollRef, contentRef]);
|
||||||
|
|
||||||
|
// Get item size helper
|
||||||
|
const getItemSize = useCallback(
|
||||||
|
(index: number): number => {
|
||||||
|
if (typeof itemSize === 'function') {
|
||||||
|
return itemSize(index, constraintWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
return itemSize;
|
||||||
|
},
|
||||||
|
[itemSize, constraintWidth],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Precompute item offsets for O(1) lookup
|
||||||
|
const { offsets, totalSize } = useMemo(() => {
|
||||||
|
const result: number[] = [];
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < itemCount; i++) {
|
||||||
|
result.push(offset);
|
||||||
|
offset += getItemSize(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { offsets: result, totalSize: offset };
|
||||||
|
}, [itemCount, getItemSize]);
|
||||||
|
|
||||||
|
// Binary search to find the first visible item
|
||||||
|
const findStartIndex = useCallback(
|
||||||
|
(scrollTop: number): number => {
|
||||||
|
let low = 0;
|
||||||
|
let high = itemCount - 1;
|
||||||
|
|
||||||
|
while (low <= high) {
|
||||||
|
const mid = Math.floor((low + high) / 2);
|
||||||
|
const offset = offsets[mid];
|
||||||
|
|
||||||
|
if (offset < scrollTop) {
|
||||||
|
low = mid + 1;
|
||||||
|
} else {
|
||||||
|
high = mid - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(0, low - 1);
|
||||||
|
},
|
||||||
|
[offsets, itemCount],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate virtual items to render
|
||||||
|
const virtualItems = useMemo((): VirtualItem[] => {
|
||||||
|
if (itemCount === 0 || constraintWidth === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const startIndex = findStartIndex(scrollTop);
|
||||||
|
const items: VirtualItem[] = [];
|
||||||
|
|
||||||
|
// Apply overscan before visible area
|
||||||
|
const overscanStart = Math.max(0, startIndex - overscan);
|
||||||
|
|
||||||
|
// Find items within the visible area + overscan
|
||||||
|
for (let i = overscanStart; i < itemCount; i++) {
|
||||||
|
const start = offsets[i];
|
||||||
|
const size = getItemSize(i);
|
||||||
|
|
||||||
|
// Stop if we've gone past the visible area + overscan
|
||||||
|
if (start > scrollTop + viewportHeight) {
|
||||||
|
// Add overscan items after visible area
|
||||||
|
const overscanEnd = Math.min(itemCount, i + overscan);
|
||||||
|
|
||||||
|
for (let j = i; j < overscanEnd; j++) {
|
||||||
|
items.push({
|
||||||
|
index: j,
|
||||||
|
start: offsets[j],
|
||||||
|
size: getItemSize(j),
|
||||||
|
key: `virtual-item-${j}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
index: i,
|
||||||
|
start,
|
||||||
|
size,
|
||||||
|
key: `virtual-item-${i}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}, [
|
||||||
|
itemCount,
|
||||||
|
constraintWidth,
|
||||||
|
scrollTop,
|
||||||
|
viewportHeight,
|
||||||
|
overscan,
|
||||||
|
offsets,
|
||||||
|
getItemSize,
|
||||||
|
findStartIndex,
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imperatively scroll the scroll container so that the item at the given
|
||||||
|
* index is at the top of the viewport.
|
||||||
|
*/
|
||||||
|
const scrollToItem = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
if (index < 0 || index >= itemCount) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemOffset = offsets[index] ?? 0;
|
||||||
|
|
||||||
|
if (scrollRef === 'window') {
|
||||||
|
const contentEl = contentRef?.current;
|
||||||
|
const contentTop = contentEl ? contentEl.getBoundingClientRect().top + window.scrollY : 0;
|
||||||
|
|
||||||
|
window.scrollTo({
|
||||||
|
top: contentTop + itemOffset,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const scrollEl = scrollRef.current;
|
||||||
|
|
||||||
|
if (!scrollEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recalculate content offset to get the most up-to-date value.
|
||||||
|
const contentEl = contentRef?.current;
|
||||||
|
let contentOffset = 0;
|
||||||
|
|
||||||
|
if (contentEl) {
|
||||||
|
const scrollRect = scrollEl.getBoundingClientRect();
|
||||||
|
const contentRect = contentEl.getBoundingClientRect();
|
||||||
|
contentOffset = contentRect.top - scrollRect.top + scrollEl.scrollTop;
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollEl.scrollTo({
|
||||||
|
top: contentOffset + itemOffset,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[scrollRef, contentRef, offsets, itemCount],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
virtualItems,
|
||||||
|
totalSize,
|
||||||
|
constraintWidth,
|
||||||
|
scrollToItem,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Rnd } from 'react-rnd';
|
|||||||
import { useSearchParams } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
|
|
||||||
import { useElementBounds } from '@documenso/lib/client-only/hooks/use-element-bounds';
|
import { useElementBounds } from '@documenso/lib/client-only/hooks/use-element-bounds';
|
||||||
|
import { useIsPageInDom } from '@documenso/lib/client-only/hooks/use-is-page-in-dom';
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
import type { TFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
import type { TFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||||
import { ZCheckboxFieldMeta, ZRadioFieldMeta } from '@documenso/lib/types/field-meta';
|
import { ZCheckboxFieldMeta, ZRadioFieldMeta } from '@documenso/lib/types/field-meta';
|
||||||
@@ -50,7 +51,17 @@ export type FieldItemProps = {
|
|||||||
/**
|
/**
|
||||||
* The item when editing fields??
|
* The item when editing fields??
|
||||||
*/
|
*/
|
||||||
export const FieldItem = ({
|
export const FieldItem = (props: FieldItemProps) => {
|
||||||
|
const isPageInDom = useIsPageInDom(props.field.pageNumber);
|
||||||
|
|
||||||
|
if (!isPageInDom) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <FieldItemInner {...props} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FieldItemInner = ({
|
||||||
fieldClassName,
|
fieldClassName,
|
||||||
field,
|
field,
|
||||||
passive,
|
passive,
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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,19 +0,0 @@
|
|||||||
import { ClientOnly } from '../../components/client-only';
|
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
|
|
||||||
import { PDFViewer, type PDFViewerProps } from './base.client';
|
|
||||||
|
|
||||||
export const PDFViewerLazy = (props: PDFViewerProps) => {
|
|
||||||
return (
|
|
||||||
<ClientOnly
|
|
||||||
fallback={
|
|
||||||
<div>
|
|
||||||
<Trans>Loading...</Trans>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{() => <PDFViewer {...props} />}
|
|
||||||
</ClientOnly>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user