mirror of
https://github.com/documenso/documenso
synced 2026-04-21 13:27:18 +00:00
feat: add pdf image renderer (#2554)
## Description Replace the PDF renderer with an custom image renderer. This allows us to remove the "react-pdf" dependency and allows us to use a virtual list to improve performance.
This commit is contained in:
parent
0ce909a298
commit
6faa01d384
82 changed files with 2581 additions and 1365 deletions
|
|
@ -13,7 +13,6 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
|
@ -38,19 +37,6 @@ export const DocumentDuplicateDialog = ({
|
|||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { data: envelopeItemsPayload, isLoading: isLoadingEnvelopeItems } =
|
||||
trpcReact.envelope.item.getManyByToken.useQuery(
|
||||
{
|
||||
envelopeId: id,
|
||||
access: token ? { type: 'recipient', token } : { type: 'user' },
|
||||
},
|
||||
{
|
||||
enabled: open,
|
||||
},
|
||||
);
|
||||
|
||||
const envelopeItems = envelopeItemsPayload?.data || [];
|
||||
|
||||
const documentsPath = formatDocumentsPath(team.url);
|
||||
|
||||
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } =
|
||||
|
|
@ -88,22 +74,6 @@ export const DocumentDuplicateDialog = ({
|
|||
<Trans>Duplicate</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{isLoadingEnvelopeItems || !envelopeItems || envelopeItems.length === 0 ? (
|
||||
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
|
||||
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
|
||||
<Trans>Loading Document...</Trans>
|
||||
</h1>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll">
|
||||
<PDFViewerLazy
|
||||
key={envelopeItems[0].id}
|
||||
envelopeItem={envelopeItems[0]}
|
||||
token={undefined}
|
||||
version="original"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||
|
|
|
|||
|
|
@ -5,17 +5,17 @@ import { useLingui } from '@lingui/react';
|
|||
import { Trans } from '@lingui/react/macro';
|
||||
import type { EnvelopeItem, FieldType } from '@prisma/client';
|
||||
import { ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client';
|
||||
import { base64 } from '@scure/base';
|
||||
import { ChevronsUpDown } from 'lucide-react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
|
||||
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
||||
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR, getPdfPagesCount } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { type TFieldMetaSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download';
|
||||
import { useRecipientColors } from '@documenso/ui/lib/recipient-colors';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
|
@ -24,14 +24,15 @@ import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/type
|
|||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { FieldSelector } from '@documenso/ui/primitives/field-selector';
|
||||
import { Form } from '@documenso/ui/primitives/form/form';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
|
||||
import { Sheet, SheetContent, SheetTrigger } from '@documenso/ui/primitives/sheet';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { FieldAdvancedSettingsDrawer } from '~/components/embed/authoring/field-advanced-settings-drawer';
|
||||
import { PDFViewer } from '~/components/general/pdf-viewer/pdf-viewer';
|
||||
|
||||
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
|
||||
import type { TConfigureFieldsFormSchema } from './configure-fields-view.types';
|
||||
import { FieldAdvancedSettingsDrawer } from './field-advanced-settings-drawer';
|
||||
|
||||
const MIN_HEIGHT_PX = 12;
|
||||
const MIN_WIDTH_PX = 36;
|
||||
|
|
@ -42,7 +43,7 @@ const DEFAULT_WIDTH_PX = MIN_WIDTH_PX * 2.5;
|
|||
export type ConfigureFieldsViewProps = {
|
||||
configData: TConfigureEmbedFormSchema;
|
||||
presignToken?: string | undefined;
|
||||
envelopeItem?: Pick<EnvelopeItem, 'id' | 'envelopeId'>;
|
||||
envelopeItem?: Pick<EnvelopeItem, 'id' | 'envelopeId' | 'documentDataId'>;
|
||||
defaultValues?: Partial<TConfigureFieldsFormSchema>;
|
||||
onBack?: (data: TConfigureFieldsFormSchema) => void;
|
||||
onSubmit: (data: TConfigureFieldsFormSchema) => void;
|
||||
|
|
@ -86,23 +87,22 @@ export const ConfigureFieldsView = ({
|
|||
|
||||
const normalizedDocumentData = useMemo(() => {
|
||||
if (envelopeItem) {
|
||||
return undefined;
|
||||
return getDocumentDataUrl({
|
||||
envelopeId: envelopeItem.envelopeId,
|
||||
envelopeItemId: envelopeItem.id,
|
||||
documentDataId: envelopeItem.documentDataId,
|
||||
version: 'current',
|
||||
token: undefined,
|
||||
presignToken,
|
||||
});
|
||||
}
|
||||
|
||||
if (!configData.documentData) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return base64.encode(configData.documentData.data);
|
||||
}, [configData.documentData]);
|
||||
|
||||
const normalizedEnvelopeItem = useMemo(() => {
|
||||
if (envelopeItem) {
|
||||
return envelopeItem;
|
||||
}
|
||||
|
||||
return { id: '', envelopeId: '' };
|
||||
}, [envelopeItem]);
|
||||
return configData.documentData.data;
|
||||
}, [configData.documentData, envelopeItem, presignToken]);
|
||||
|
||||
const recipients = useMemo(() => {
|
||||
return configData.signers.map<Recipient>((signer, index) => ({
|
||||
|
|
@ -179,8 +179,6 @@ export const ConfigureFieldsView = ({
|
|||
name: 'fields',
|
||||
});
|
||||
|
||||
const highestPageNumber = Math.max(...localFields.map((field) => field.pageNumber));
|
||||
|
||||
const onFieldCopy = useCallback(
|
||||
(event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => {
|
||||
const { duplicate = false, duplicateAll = false } = options ?? {};
|
||||
|
|
@ -205,13 +203,15 @@ export const ConfigureFieldsView = ({
|
|||
}
|
||||
|
||||
if (duplicateAll) {
|
||||
const pages = Array.from(document.querySelectorAll(PDF_VIEWER_PAGE_SELECTOR));
|
||||
const totalPages = getPdfPagesCount();
|
||||
|
||||
pages.forEach((_, index) => {
|
||||
const pageNumber = index + 1;
|
||||
if (totalPages < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) {
|
||||
if (pageNumber === lastActiveField.pageNumber) {
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
|
||||
const newField: TConfigureFieldsFormSchema['fields'][0] = {
|
||||
|
|
@ -224,7 +224,7 @@ export const ConfigureFieldsView = ({
|
|||
};
|
||||
|
||||
append(newField);
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
@ -548,17 +548,11 @@ export const ConfigureFieldsView = ({
|
|||
|
||||
<Form {...form}>
|
||||
<div>
|
||||
<PDFViewerLazy
|
||||
presignToken={presignToken}
|
||||
overrideData={normalizedDocumentData}
|
||||
envelopeItem={normalizedEnvelopeItem}
|
||||
token={undefined}
|
||||
version="signed"
|
||||
/>
|
||||
{normalizedDocumentData && (
|
||||
<PDFViewer data={normalizedDocumentData} scrollParentRef="window" />
|
||||
)}
|
||||
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
|
||||
>
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
{localFields.map((field, index) => {
|
||||
const recipientIndex = recipients.findIndex((r) => r.id === field.recipientId);
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@ import {
|
|||
isFieldUnsignedAndRequired,
|
||||
isRequiredField,
|
||||
} from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||
import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download';
|
||||
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type {
|
||||
|
|
@ -35,11 +36,11 @@ import { Button } from '@documenso/ui/primitives/button';
|
|||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { BrandingLogo } from '~/components/general/branding-logo';
|
||||
import { PDFViewer } from '~/components/general/pdf-viewer/pdf-viewer';
|
||||
import { ZDirectTemplateEmbedDataSchema } from '~/types/embed-direct-template-schema';
|
||||
import { injectCss } from '~/utils/css-vars';
|
||||
|
||||
|
|
@ -54,7 +55,7 @@ export type EmbedDirectTemplateClientPageProps = {
|
|||
token: string;
|
||||
envelopeId: string;
|
||||
updatedAt: Date;
|
||||
envelopeItems: Pick<EnvelopeItem, 'id' | 'envelopeId'>[];
|
||||
envelopeItems: Pick<EnvelopeItem, 'id' | 'envelopeId' | 'documentDataId'>[];
|
||||
recipient: Recipient;
|
||||
fields: Field[];
|
||||
metadata?: DocumentMeta | null;
|
||||
|
|
@ -97,12 +98,10 @@ export const EmbedDirectTemplateClientPage = ({
|
|||
const [localFields, setLocalFields] = useState<DirectTemplateLocalField[]>(() => fields);
|
||||
|
||||
const [pendingFields, _completedFields] = [
|
||||
localFields.filter((field) => isFieldUnsignedAndRequired(field)),
|
||||
sortFieldsByPosition(localFields.filter((field) => isFieldUnsignedAndRequired(field))),
|
||||
localFields.filter((field) => field.inserted),
|
||||
];
|
||||
|
||||
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
|
||||
|
||||
const hasSignatureField = localFields.some((field) => isSignatureFieldType(field.type));
|
||||
|
||||
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
|
||||
|
|
@ -341,10 +340,16 @@ export const EmbedDirectTemplateClientPage = ({
|
|||
<div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
||||
{/* Viewer */}
|
||||
<div className="flex-1">
|
||||
<PDFViewerLazy
|
||||
envelopeItem={envelopeItems[0]}
|
||||
token={recipient.token}
|
||||
version="signed"
|
||||
<PDFViewer
|
||||
data={getDocumentDataUrl({
|
||||
envelopeId: envelopeItems[0].envelopeId,
|
||||
envelopeItemId: envelopeItems[0].id,
|
||||
documentDataId: envelopeItems[0].documentDataId,
|
||||
version: 'current',
|
||||
token: recipient.token,
|
||||
presignToken: undefined,
|
||||
})}
|
||||
scrollParentRef="window"
|
||||
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -478,15 +483,15 @@ export const EmbedDirectTemplateClientPage = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPendingPageNumber}"]`}
|
||||
>
|
||||
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
||||
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${pendingFields[0].page}"]`}
|
||||
>
|
||||
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
|
||||
<Trans>Click to insert field</Trans>
|
||||
</FieldToolTip>
|
||||
)}
|
||||
</ElementVisible>
|
||||
</ElementVisible>
|
||||
)}
|
||||
|
||||
{/* Fields */}
|
||||
<EmbedDocumentFields
|
||||
|
|
|
|||
|
|
@ -50,10 +50,8 @@ export const EmbedDocumentFields = ({
|
|||
onSignField,
|
||||
onUnsignField,
|
||||
}: EmbedDocumentFieldsProps) => {
|
||||
const highestPageNumber = Math.max(...fields.map((field) => field.page));
|
||||
|
||||
return (
|
||||
<ElementVisible target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}>
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
{fields.map((field) =>
|
||||
match(field.type)
|
||||
.with(FieldType.SIGNATURE, () => (
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
|||
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||
import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download';
|
||||
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
|
|
@ -23,12 +24,12 @@ import { Button } from '@documenso/ui/primitives/button';
|
|||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
||||
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { BrandingLogo } from '~/components/general/branding-logo';
|
||||
import { PDFViewer } from '~/components/general/pdf-viewer/pdf-viewer';
|
||||
import { injectCss } from '~/utils/css-vars';
|
||||
|
||||
import { ZSignDocumentEmbedDataSchema } from '../../types/embed-document-sign-schema';
|
||||
|
|
@ -45,7 +46,7 @@ export type EmbedSignDocumentV1ClientPageProps = {
|
|||
token: string;
|
||||
documentId: number;
|
||||
envelopeId: string;
|
||||
envelopeItems: Pick<EnvelopeItem, 'id' | 'envelopeId'>[];
|
||||
envelopeItems: (Pick<EnvelopeItem, 'id' | 'envelopeId'> & { documentData: { id: string } })[];
|
||||
recipient: RecipientWithFields;
|
||||
fields: Field[];
|
||||
completedFields: DocumentField[];
|
||||
|
|
@ -100,14 +101,14 @@ export const EmbedSignDocumentV1ClientPage = ({
|
|||
const [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500);
|
||||
|
||||
const [pendingFields, _completedFields] = [
|
||||
fields.filter(
|
||||
(field) => field.recipientId === recipient.id && isFieldUnsignedAndRequired(field),
|
||||
sortFieldsByPosition(
|
||||
fields.filter(
|
||||
(field) => field.recipientId === recipient.id && isFieldUnsignedAndRequired(field),
|
||||
),
|
||||
),
|
||||
fields.filter((field) => field.inserted),
|
||||
];
|
||||
|
||||
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
|
||||
|
||||
const { mutateAsync: completeDocumentWithToken, isPending: isSubmitting } =
|
||||
trpc.recipient.completeDocumentWithToken.useMutation();
|
||||
|
||||
|
|
@ -287,10 +288,16 @@ export const EmbedSignDocumentV1ClientPage = ({
|
|||
<div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
||||
{/* Viewer */}
|
||||
<div className="embed--DocumentViewer flex-1">
|
||||
<PDFViewerLazy
|
||||
envelopeItem={envelopeItems[0]}
|
||||
token={token}
|
||||
version="signed"
|
||||
<PDFViewer
|
||||
data={getDocumentDataUrl({
|
||||
envelopeId: envelopeItems[0].envelopeId,
|
||||
envelopeItemId: envelopeItems[0].id,
|
||||
documentDataId: envelopeItems[0].documentData.id,
|
||||
version: 'current',
|
||||
token: token,
|
||||
presignToken: undefined,
|
||||
})}
|
||||
scrollParentRef="window"
|
||||
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -491,15 +498,15 @@ export const EmbedSignDocumentV1ClientPage = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPendingPageNumber}"]`}
|
||||
>
|
||||
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
||||
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${pendingFields[0].page}"]`}
|
||||
>
|
||||
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
|
||||
<Trans>Click to insert field</Trans>
|
||||
</FieldToolTip>
|
||||
)}
|
||||
</ElementVisible>
|
||||
</ElementVisible>
|
||||
)}
|
||||
|
||||
{/* Fields */}
|
||||
<EmbedDocumentFields fields={fields} metadata={metadata} />
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import { P, match } from 'ts-pattern';
|
|||
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download';
|
||||
import { sortFieldsByPosition } from '@documenso/lib/utils/fields';
|
||||
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type {
|
||||
|
|
@ -22,10 +24,11 @@ import { Button } from '@documenso/ui/primitives/button';
|
|||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { PDFViewer } from '~/components/general/pdf-viewer/pdf-viewer';
|
||||
|
||||
import { useRequiredDocumentSigningContext } from '../../general/document-signing/document-signing-provider';
|
||||
import { DocumentSigningRejectDialog } from '../../general/document-signing/document-signing-reject-dialog';
|
||||
import { EmbedDocumentFields } from '../embed-document-fields';
|
||||
|
|
@ -87,14 +90,14 @@ export const MultiSignDocumentSigningView = ({
|
|||
const hasSignatureField = document?.fields.some((field) => isSignatureFieldType(field.type));
|
||||
|
||||
const [pendingFields, completedFields] = [
|
||||
document?.fields.filter((field) => field.recipient.signingStatus !== SigningStatus.SIGNED) ??
|
||||
[],
|
||||
sortFieldsByPosition(
|
||||
document?.fields.filter((field) => field.recipient.signingStatus !== SigningStatus.SIGNED) ??
|
||||
[],
|
||||
),
|
||||
document?.fields.filter((field) => field.recipient.signingStatus === SigningStatus.SIGNED) ??
|
||||
[],
|
||||
];
|
||||
|
||||
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
|
||||
|
||||
const uninsertedFields = document?.fields.filter((field) => !field.inserted) ?? [];
|
||||
|
||||
const onSignField = async (payload: TSignFieldWithTokenMutationSchema) => {
|
||||
|
|
@ -226,10 +229,16 @@ export const MultiSignDocumentSigningView = ({
|
|||
'md:mx-auto md:max-w-2xl': document.status === DocumentStatus.COMPLETED,
|
||||
})}
|
||||
>
|
||||
<PDFViewerLazy
|
||||
envelopeItem={document.envelopeItems[0]}
|
||||
token={token}
|
||||
version="signed"
|
||||
<PDFViewer
|
||||
data={getDocumentDataUrl({
|
||||
envelopeId: document.envelopeId,
|
||||
envelopeItemId: document.envelopeItems[0].id,
|
||||
documentDataId: document.documentData.id,
|
||||
version: 'current',
|
||||
token,
|
||||
presignToken: undefined,
|
||||
})}
|
||||
scrollParentRef="window"
|
||||
onDocumentLoad={() => {
|
||||
setHasDocumentLoaded(true);
|
||||
onDocumentReady?.();
|
||||
|
|
@ -362,19 +371,13 @@ export const MultiSignDocumentSigningView = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{hasDocumentLoaded && (
|
||||
{hasDocumentLoaded && showPendingFieldTooltip && pendingFields.length > 0 && (
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPendingPageNumber}"]`}
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${pendingFields[0].page}"]`}
|
||||
>
|
||||
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
||||
<FieldToolTip
|
||||
key={pendingFields[0].id}
|
||||
field={pendingFields[0]}
|
||||
color="warning"
|
||||
>
|
||||
<Trans>Click to insert field</Trans>
|
||||
</FieldToolTip>
|
||||
)}
|
||||
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
|
||||
<Trans>Click to insert field</Trans>
|
||||
</FieldToolTip>
|
||||
</ElementVisible>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,16 +9,17 @@ import { useNavigate, useSearchParams } from 'react-router';
|
|||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { TTemplate } from '@documenso/lib/types/template';
|
||||
import { isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
import { Stepper } from '@documenso/ui/primitives/stepper';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useRequiredDocumentSigningAuthContext } from '~/components/general/document-signing/document-signing-auth-provider';
|
||||
import { useRequiredDocumentSigningContext } from '~/components/general/document-signing/document-signing-provider';
|
||||
import { PDFViewer } from '~/components/general/pdf-viewer/pdf-viewer';
|
||||
|
||||
import {
|
||||
DirectTemplateConfigureForm,
|
||||
|
|
@ -151,11 +152,17 @@ export const DirectTemplatePageView = ({
|
|||
gradient
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
<PDFViewerLazy
|
||||
<PDFViewer
|
||||
key={template.id}
|
||||
envelopeItem={template.envelopeItems[0]}
|
||||
token={directTemplateRecipient.token}
|
||||
version="signed"
|
||||
data={getDocumentDataUrl({
|
||||
envelopeId: template.envelopeId,
|
||||
envelopeItemId: template.envelopeItems[0].id,
|
||||
documentDataId: template.templateDocumentDataId,
|
||||
version: 'current',
|
||||
token: directTemplateRecipient.token,
|
||||
presignToken: undefined,
|
||||
})}
|
||||
scrollParentRef="window"
|
||||
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||
/>
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -82,8 +82,6 @@ export const DirectTemplateSigningForm = ({
|
|||
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const highestPageNumber = Math.max(...localFields.map((field) => field.page));
|
||||
|
||||
const fieldsRequiringValidation = useMemo(() => {
|
||||
return localFields.filter((field) => isFieldUnsignedAndRequired(field));
|
||||
}, [localFields]);
|
||||
|
|
@ -250,9 +248,7 @@ export const DirectTemplateSigningForm = ({
|
|||
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
|
||||
|
||||
<DocumentFlowFormContainerContent>
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
|
||||
>
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
{validateUninsertedFields && uninsertedFields[0] && (
|
||||
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
|
||||
<Trans>Click to insert field</Trans>
|
||||
|
|
|
|||
|
|
@ -130,69 +130,67 @@ export const DocumentSigningFieldContainer = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className={cn('[container-type:size]')}>
|
||||
<FieldRootContainer
|
||||
color={
|
||||
field.fieldMeta?.readOnly ? RECIPIENT_COLOR_STYLES.readOnly : RECIPIENT_COLOR_STYLES.green
|
||||
}
|
||||
field={field}
|
||||
>
|
||||
{!field.inserted && !loading && !readOnlyField && (
|
||||
<button
|
||||
type="submit"
|
||||
className="absolute inset-0 z-10 h-full w-full rounded-[2px]"
|
||||
onClick={async () => handleInsertField()}
|
||||
/>
|
||||
)}
|
||||
<FieldRootContainer
|
||||
color={
|
||||
field.fieldMeta?.readOnly ? RECIPIENT_COLOR_STYLES.readOnly : RECIPIENT_COLOR_STYLES.green
|
||||
}
|
||||
field={field}
|
||||
>
|
||||
{!field.inserted && !loading && !readOnlyField && (
|
||||
<button
|
||||
type="submit"
|
||||
className="absolute inset-0 z-10 h-full w-full rounded-[2px]"
|
||||
onClick={async () => handleInsertField()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{type === 'Checkbox' && field.inserted && !loading && !readOnlyField && (
|
||||
<button
|
||||
className="absolute -bottom-10 flex items-center justify-evenly rounded-md border bg-gray-900 opacity-0 group-hover:opacity-100"
|
||||
onClick={() => void onClearCheckBoxValues(type)}
|
||||
{type === 'Checkbox' && field.inserted && !loading && !readOnlyField && (
|
||||
<button
|
||||
className="absolute -bottom-10 flex items-center justify-evenly rounded-md border bg-gray-900 opacity-0 group-hover:opacity-100"
|
||||
onClick={() => void onClearCheckBoxValues(type)}
|
||||
>
|
||||
<span className="rounded-md p-1 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100">
|
||||
<X className="h-4 w-4" />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{type !== 'Checkbox' && field.inserted && !loading && !readOnlyField && (
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<button className="absolute inset-0 z-10" onClick={onRemoveSignedFieldClick}></button>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent
|
||||
className="border-0 bg-orange-300 fill-orange-300 text-orange-900"
|
||||
sideOffset={2}
|
||||
>
|
||||
<span className="rounded-md p-1 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100">
|
||||
<X className="h-4 w-4" />
|
||||
</span>
|
||||
</button>
|
||||
{tooltipText && <p>{tooltipText}</p>}
|
||||
|
||||
<Trans>Remove</Trans>
|
||||
<TooltipArrow />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{(field.type === FieldType.RADIO || field.type === FieldType.CHECKBOX) &&
|
||||
field.fieldMeta?.label && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute -top-16 left-0 right-0 rounded-md p-2 text-center text-xs text-gray-700',
|
||||
{
|
||||
'border border-border bg-foreground/5': !field.inserted,
|
||||
},
|
||||
{
|
||||
'border border-primary bg-documenso-200': field.inserted,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{field.fieldMeta.label}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type !== 'Checkbox' && field.inserted && !loading && !readOnlyField && (
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<button className="absolute inset-0 z-10" onClick={onRemoveSignedFieldClick}></button>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent
|
||||
className="border-0 bg-orange-300 fill-orange-300 text-orange-900"
|
||||
sideOffset={2}
|
||||
>
|
||||
{tooltipText && <p>{tooltipText}</p>}
|
||||
|
||||
<Trans>Remove</Trans>
|
||||
<TooltipArrow />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{(field.type === FieldType.RADIO || field.type === FieldType.CHECKBOX) &&
|
||||
field.fieldMeta?.label && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute -top-16 left-0 right-0 rounded-md p-2 text-center text-xs text-gray-700',
|
||||
{
|
||||
'bg-foreground/5 border-border border': !field.inserted,
|
||||
},
|
||||
{
|
||||
'bg-documenso-200 border-primary border': field.inserted,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{field.fieldMeta.label}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</FieldRootContainer>
|
||||
</div>
|
||||
{children}
|
||||
</FieldRootContainer>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
} from '@documenso/lib/types/field-meta';
|
||||
import type { CompletedField } from '@documenso/lib/types/fields';
|
||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download';
|
||||
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
|
||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||
|
|
@ -30,7 +31,6 @@ import { DocumentReadOnlyFields } from '@documenso/ui/components/document/docume
|
|||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
|
||||
import { DocumentSigningAttachmentsPopover } from '~/components/general/document-signing/document-signing-attachments-popover';
|
||||
import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign';
|
||||
|
|
@ -46,6 +46,7 @@ import { DocumentSigningRadioField } from '~/components/general/document-signing
|
|||
import { DocumentSigningRejectDialog } from '~/components/general/document-signing/document-signing-reject-dialog';
|
||||
import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field';
|
||||
import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field';
|
||||
import { PDFViewer } from '~/components/general/pdf-viewer/pdf-viewer';
|
||||
|
||||
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
|
||||
|
|
@ -162,8 +163,6 @@ export const DocumentSigningPageViewV1 = ({
|
|||
: undefined;
|
||||
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
|
||||
|
||||
const highestPageNumber = Math.max(...fields.map((field) => field.page));
|
||||
|
||||
const pendingFields = fieldsRequiringValidation.filter((field) => !field.inserted);
|
||||
const hasPendingFields = pendingFields.length > 0;
|
||||
|
||||
|
|
@ -274,11 +273,17 @@ export const DocumentSigningPageViewV1 = ({
|
|||
<div className="flex-1">
|
||||
<Card className="rounded-xl before:rounded-xl" gradient>
|
||||
<CardContent className="p-2">
|
||||
<PDFViewerLazy
|
||||
<PDFViewer
|
||||
key={document.envelopeItems[0].id}
|
||||
envelopeItem={document.envelopeItems[0]}
|
||||
token={recipient.token}
|
||||
version="signed"
|
||||
data={getDocumentDataUrl({
|
||||
envelopeId: document.envelopeId,
|
||||
envelopeItemId: document.envelopeItems[0].id,
|
||||
documentDataId: document.envelopeItems[0].documentData.id,
|
||||
version: 'current',
|
||||
token: recipient.token,
|
||||
presignToken: undefined,
|
||||
})}
|
||||
scrollParentRef="window"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -400,9 +405,7 @@ export const DocumentSigningPageViewV1 = ({
|
|||
<DocumentSigningAutoSign recipient={recipient} fields={fields} />
|
||||
)}
|
||||
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
|
||||
>
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
{fields
|
||||
.filter(
|
||||
(field) =>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { lazy, useMemo } from 'react';
|
||||
import { useMemo, useRef } from 'react';
|
||||
|
||||
import { Plural, Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType, RecipientRole } from '@prisma/client';
|
||||
|
|
@ -8,8 +8,8 @@ import { Link } from 'react-router';
|
|||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
|
||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
|
||||
|
|
@ -23,6 +23,8 @@ import { SignFieldNumberDialog } from '~/components/dialogs/sign-field-number-di
|
|||
import { SignFieldSignatureDialog } from '~/components/dialogs/sign-field-signature-dialog';
|
||||
import { SignFieldTextDialog } from '~/components/dialogs/sign-field-text-dialog';
|
||||
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
|
||||
import { EnvelopeSignerPageRenderer } from '~/components/general/envelope-signing/envelope-signer-page-renderer';
|
||||
import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
|
||||
|
||||
import { BrandingLogo } from '../branding-logo';
|
||||
import { DocumentSigningAttachmentsPopover } from '../document-signing/document-signing-attachments-popover';
|
||||
|
|
@ -33,13 +35,11 @@ import { DocumentSigningMobileWidget } from './document-signing-mobile-widget';
|
|||
import { DocumentSigningRejectDialog } from './document-signing-reject-dialog';
|
||||
import { useRequiredEnvelopeSigningContext } from './envelope-signing-provider';
|
||||
|
||||
const EnvelopeSignerPageRenderer = lazy(
|
||||
async () => import('~/components/general/envelope-signing/envelope-signer-page-renderer'),
|
||||
);
|
||||
|
||||
export const DocumentSigningPageViewV2 = () => {
|
||||
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
|
||||
const scrollableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const {
|
||||
isDirectTemplate,
|
||||
envelope,
|
||||
|
|
@ -199,7 +199,10 @@ export const DocumentSigningPageViewV2 = () => {
|
|||
</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">
|
||||
{/* Horizontal envelope item selector */}
|
||||
{envelopeItems.length > 1 && (
|
||||
|
|
@ -228,15 +231,16 @@ export const DocumentSigningPageViewV2 = () => {
|
|||
{/* Document View */}
|
||||
<div className="embed--DocumentViewer flex flex-col items-center justify-center p-2 sm:mt-4 sm:p-4">
|
||||
{currentEnvelopeItem ? (
|
||||
<PDFViewerKonvaLazy
|
||||
renderer="signing"
|
||||
<EnvelopePdfViewer
|
||||
key={currentEnvelopeItem.id}
|
||||
customPageRenderer={EnvelopeSignerPageRenderer}
|
||||
scrollParentRef={scrollableContainerRef}
|
||||
errorMessage={PDF_VIEWER_ERROR_MESSAGES.signing}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-32">
|
||||
<p className="text-sm text-foreground">
|
||||
<Trans>No documents found</Trans>
|
||||
<Trans>No document selected</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { lazy, useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { type DocumentData, DocumentStatus, type EnvelopeItem, EnvelopeType } from '@prisma/client';
|
||||
|
|
@ -9,9 +9,10 @@ import {
|
|||
EnvelopeRenderProvider,
|
||||
useCurrentEnvelopeRender,
|
||||
} from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
|
||||
import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -21,15 +22,13 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
|
||||
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
|
||||
import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
|
||||
import { PDFViewer } from '~/components/general/pdf-viewer/pdf-viewer';
|
||||
|
||||
import { EnvelopeRendererFileSelector } from '../envelope-editor/envelope-file-selector';
|
||||
|
||||
const EnvelopeGenericPageRenderer = lazy(
|
||||
async () => import('~/components/general/envelope-editor/envelope-generic-page-renderer'),
|
||||
);
|
||||
import { EnvelopeGenericPageRenderer } from '../envelope-editor/envelope-generic-page-renderer';
|
||||
|
||||
export type DocumentCertificateQRViewProps = {
|
||||
documentId: number;
|
||||
|
|
@ -104,11 +103,13 @@ export const DocumentCertificateQRView = ({
|
|||
|
||||
{internalVersion === 2 ? (
|
||||
<EnvelopeRenderProvider
|
||||
version="current"
|
||||
envelope={{
|
||||
envelopeItems,
|
||||
id: envelopeItems[0].envelopeId,
|
||||
status: DocumentStatus.COMPLETED,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
}}
|
||||
envelopeItems={envelopeItems}
|
||||
token={token}
|
||||
>
|
||||
<DocumentCertificateQrV2
|
||||
|
|
@ -149,11 +150,17 @@ export const DocumentCertificateQRView = ({
|
|||
</div>
|
||||
|
||||
<div className="mt-12 w-full">
|
||||
<PDFViewerLazy
|
||||
<PDFViewer
|
||||
key={envelopeItems[0].id}
|
||||
envelopeItem={envelopeItems[0]}
|
||||
token={token}
|
||||
version="signed"
|
||||
data={getDocumentDataUrl({
|
||||
envelopeId: envelopeItems[0].envelopeId,
|
||||
envelopeItemId: envelopeItems[0].id,
|
||||
documentDataId: envelopeItems[0].documentDataId,
|
||||
version: 'current',
|
||||
token,
|
||||
presignToken: undefined,
|
||||
})}
|
||||
scrollParentRef="window"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
|
@ -175,7 +182,9 @@ const DocumentCertificateQrV2 = ({
|
|||
formattedDate,
|
||||
token,
|
||||
}: DocumentCertificateQrV2Props) => {
|
||||
const { currentEnvelopeItem, envelopeItems } = useCurrentEnvelopeRender();
|
||||
const { envelopeItems } = useCurrentEnvelopeRender();
|
||||
|
||||
const scrollableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-start">
|
||||
|
|
@ -207,10 +216,14 @@ const DocumentCertificateQrV2 = ({
|
|||
/>
|
||||
</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={''} />
|
||||
|
||||
<PDFViewerKonvaLazy renderer="preview" customPageRenderer={EnvelopeGenericPageRenderer} />
|
||||
<EnvelopePdfViewer
|
||||
customPageRenderer={EnvelopeGenericPageRenderer}
|
||||
scrollParentRef={scrollableContainerRef}
|
||||
errorMessage={PDF_VIEWER_ERROR_MESSAGES.preview}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
} from '@documenso/lib/constants/trpc';
|
||||
import type { TDocument } from '@documenso/lib/types/document';
|
||||
import { ZDocumentAccessAuthTypesSchema } from '@documenso/lib/types/document-auth';
|
||||
import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
|
|
@ -27,10 +28,10 @@ import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/ad
|
|||
import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
||||
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
import { Stepper } from '@documenso/ui/primitives/stepper';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { PDFViewer } from '~/components/general/pdf-viewer/pdf-viewer';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type DocumentEditFormProps = {
|
||||
|
|
@ -440,11 +441,17 @@ export const DocumentEditForm = ({
|
|||
gradient
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
<PDFViewerLazy
|
||||
<PDFViewer
|
||||
key={document.envelopeItems[0].id}
|
||||
envelopeItem={document.envelopeItems[0]}
|
||||
token={undefined}
|
||||
version="signed"
|
||||
data={getDocumentDataUrl({
|
||||
envelopeId: document.envelopeId,
|
||||
envelopeItemId: document.envelopeItems[0].id,
|
||||
documentDataId: initialDocument.documentDataId,
|
||||
version: 'current',
|
||||
token: undefined,
|
||||
presignToken: undefined,
|
||||
})}
|
||||
scrollParentRef="window"
|
||||
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||
/>
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -175,15 +175,6 @@ export const EnvelopeEditorFieldDragDrop = ({
|
|||
|
||||
const { top, left, height, width } = getBoundingClientRect($page);
|
||||
|
||||
console.log({
|
||||
top,
|
||||
left,
|
||||
height,
|
||||
width,
|
||||
rawPageX: event.pageX,
|
||||
rawPageY: event.pageY,
|
||||
});
|
||||
|
||||
const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10);
|
||||
|
||||
// Calculate x and y as a percentage of the page width and height
|
||||
|
|
@ -278,13 +269,13 @@ export const EnvelopeEditorFieldDragDrop = ({
|
|||
onMouseDown={() => setSelectedField(field.type)}
|
||||
data-selected={selectedField === field.type ? true : undefined}
|
||||
className={cn(
|
||||
'border-border group flex h-12 cursor-pointer items-center justify-center rounded-lg border px-4 transition-colors',
|
||||
'group flex h-12 cursor-pointer items-center justify-center rounded-lg border border-border px-4 transition-colors',
|
||||
RECIPIENT_COLOR_STYLES[selectedRecipientColor].fieldButton,
|
||||
)}
|
||||
>
|
||||
<p
|
||||
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,
|
||||
{
|
||||
'group-hover:text-recipient-green': selectedRecipientColor === 'green',
|
||||
|
|
@ -306,7 +297,7 @@ export const EnvelopeEditorFieldDragDrop = ({
|
|||
{selectedField && (
|
||||
<div
|
||||
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,
|
||||
selectedField === FieldType.SIGNATURE && 'font-signature',
|
||||
{
|
||||
|
|
|
|||
|
|
@ -10,7 +10,10 @@ import { CopyPlusIcon, SquareStackIcon, TrashIcon, UserCircleIcon } from 'lucide
|
|||
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
|
||||
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
||||
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 {
|
||||
MIN_FIELD_HEIGHT_PX,
|
||||
|
|
@ -25,7 +28,7 @@ import { CommandDialog } from '@documenso/ui/primitives/command';
|
|||
import { fieldButtonList } from './envelope-editor-fields-drag-drop';
|
||||
import { EnvelopeRecipientSelectorCommand } from './envelope-recipient-selector';
|
||||
|
||||
export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageRenderData }) => {
|
||||
const { t, i18n } = useLingui();
|
||||
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
|
||||
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
|
||||
|
|
@ -37,34 +40,22 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||
const [isFieldChanging, setIsFieldChanging] = useState(false);
|
||||
const [pendingFieldCreation, setPendingFieldCreation] = useState<Konva.Rect | null>(null);
|
||||
|
||||
const {
|
||||
stage,
|
||||
pageLayer,
|
||||
canvasElement,
|
||||
konvaContainer,
|
||||
pageContext,
|
||||
scaledViewport,
|
||||
unscaledViewport,
|
||||
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer));
|
||||
const { stage, pageLayer, konvaContainer, scaledViewport, unscaledViewport } = usePageRenderer(
|
||||
({ stage, pageLayer }) => createPageCanvas(stage, pageLayer),
|
||||
pageData,
|
||||
);
|
||||
|
||||
const { _className, scale } = pageContext;
|
||||
const { scale, pageNumber } = pageData;
|
||||
|
||||
const localPageFields = useMemo(
|
||||
() =>
|
||||
editorFields.localFields.filter(
|
||||
(field) =>
|
||||
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
||||
(field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
||||
),
|
||||
[editorFields.localFields, pageContext.pageNumber],
|
||||
[editorFields.localFields, pageNumber, currentEnvelopeItem?.id],
|
||||
);
|
||||
|
||||
const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
|
||||
const { current: container } = canvasElement;
|
||||
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isDragEvent = event.type === 'dragend';
|
||||
|
||||
const fieldGroup = event.target as Konva.Group;
|
||||
|
|
@ -344,7 +335,6 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||
// Create a field if no items are selected or the size is too small.
|
||||
if (
|
||||
selectedFieldGroups.length === 0 &&
|
||||
canvasElement.current &&
|
||||
unscaledBoxWidth > MIN_FIELD_WIDTH_PX &&
|
||||
unscaledBoxHeight > MIN_FIELD_HEIGHT_PX &&
|
||||
editorFields.selectedRecipient &&
|
||||
|
|
@ -531,7 +521,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||
|
||||
removePendingField();
|
||||
|
||||
if (!canvasElement.current || !currentEnvelopeItem || !editorFields.selectedRecipient) {
|
||||
if (!currentEnvelopeItem || !editorFields.selectedRecipient) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -546,7 +536,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||
|
||||
editorFields.addField({
|
||||
envelopeItemId: currentEnvelopeItem.id,
|
||||
page: pageContext.pageNumber,
|
||||
page: pageNumber,
|
||||
type,
|
||||
positionX: fieldX,
|
||||
positionY: fieldY,
|
||||
|
|
@ -575,10 +565,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-full"
|
||||
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
|
||||
>
|
||||
<>
|
||||
{selectedKonvaFieldGroups.length > 0 &&
|
||||
interactiveTransformer.current &&
|
||||
!isFieldChanging && (
|
||||
|
|
@ -640,17 +627,9 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||
|
||||
{/* The element Konva will inject it's canvas into. */}
|
||||
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
|
||||
|
||||
{/* Canvas the PDF will be rendered on. */}
|
||||
<canvas
|
||||
className={`${_className}__canvas z-0`}
|
||||
ref={canvasElement}
|
||||
height={scaledViewport.height}
|
||||
width={scaledViewport.width}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
type FieldActionButtonsProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
handleDuplicateSelectedFields: () => void;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { lazy, useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
|
@ -12,6 +12,7 @@ import { match } from 'ts-pattern';
|
|||
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
|
||||
import type { NormalizedFieldWithContext } from '@documenso/lib/server-only/ai/envelope/detect-fields/types';
|
||||
import {
|
||||
FIELD_META_DEFAULT_VALUES,
|
||||
|
|
@ -29,7 +30,6 @@ import {
|
|||
} from '@documenso/lib/types/field-meta';
|
||||
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
|
|
@ -46,16 +46,14 @@ import { EditorFieldNumberForm } from '~/components/forms/editor/editor-field-nu
|
|||
import { EditorFieldRadioForm } from '~/components/forms/editor/editor-field-radio-form';
|
||||
import { EditorFieldSignatureForm } from '~/components/forms/editor/editor-field-signature-form';
|
||||
import { EditorFieldTextForm } from '~/components/forms/editor/editor-field-text-form';
|
||||
import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
|
||||
import { EnvelopeEditorFieldsPageRenderer } from './envelope-editor-fields-page-renderer';
|
||||
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
||||
import { EnvelopeRecipientSelector } from './envelope-recipient-selector';
|
||||
|
||||
const EnvelopeEditorFieldsPageRenderer = lazy(
|
||||
async () => import('~/components/general/envelope-editor/envelope-editor-fields-page-renderer'),
|
||||
);
|
||||
|
||||
const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
|
||||
[FieldType.SIGNATURE]: msg`Signature Settings`,
|
||||
[FieldType.FREE_SIGNATURE]: msg`Free Signature Settings`,
|
||||
|
|
@ -75,6 +73,8 @@ export const EnvelopeEditorFieldsPage = () => {
|
|||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const scrollableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { envelope, editorFields, relativePath } = useCurrentEnvelopeEditor();
|
||||
|
||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
|
|
@ -156,12 +156,12 @@ export const EnvelopeEditorFieldsPage = () => {
|
|||
|
||||
return (
|
||||
<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 */}
|
||||
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
|
||||
|
||||
{/* 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 && (
|
||||
<Alert
|
||||
variant="neutral"
|
||||
|
|
@ -185,9 +185,10 @@ export const EnvelopeEditorFieldsPage = () => {
|
|||
)}
|
||||
|
||||
{currentEnvelopeItem !== null ? (
|
||||
<PDFViewerKonvaLazy
|
||||
renderer="editor"
|
||||
<EnvelopePdfViewer
|
||||
customPageRenderer={EnvelopeEditorFieldsPageRenderer}
|
||||
scrollParentRef={scrollableContainerRef}
|
||||
errorMessage={PDF_VIEWER_ERROR_MESSAGES.editor}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-32">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { lazy, useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { faker } from '@faker-js/faker/locale/en';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
|
@ -11,21 +11,20 @@ import {
|
|||
EnvelopeRenderProvider,
|
||||
useCurrentEnvelopeRender,
|
||||
} from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
|
||||
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing';
|
||||
import { toCheckboxCustomText } from '@documenso/lib/utils/fields';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
|
||||
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
||||
import { EnvelopeGenericPageRenderer } from '~/components/general/envelope-editor/envelope-generic-page-renderer';
|
||||
import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
|
||||
|
||||
const EnvelopeGenericPageRenderer = lazy(
|
||||
async () => import('~/components/general/envelope-editor/envelope-generic-page-renderer'),
|
||||
);
|
||||
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
||||
|
||||
// Todo: Envelopes - Dynamically import faker
|
||||
export const EnvelopeEditorPreviewPage = () => {
|
||||
|
|
@ -33,6 +32,8 @@ export const EnvelopeEditorPreviewPage = () => {
|
|||
|
||||
const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender();
|
||||
|
||||
const scrollableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [selectedPreviewMode, setSelectedPreviewMode] = useState<'recipient' | 'signed'>(
|
||||
'recipient',
|
||||
);
|
||||
|
|
@ -200,7 +201,9 @@ export const EnvelopeEditorPreviewPage = () => {
|
|||
// Override the parent renderer provider so we can inject custom fields.
|
||||
return (
|
||||
<EnvelopeRenderProvider
|
||||
version="current"
|
||||
envelope={envelope}
|
||||
envelopeItems={envelope.envelopeItems}
|
||||
token={undefined}
|
||||
fields={fieldsWithPlaceholders}
|
||||
recipients={envelope.recipients.map((recipient) => ({
|
||||
|
|
@ -212,12 +215,12 @@ export const EnvelopeEditorPreviewPage = () => {
|
|||
}}
|
||||
>
|
||||
<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 */}
|
||||
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
|
||||
|
||||
{/* 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]">
|
||||
<AlertTitle>
|
||||
<Trans>Preview Mode</Trans>
|
||||
|
|
@ -228,9 +231,10 @@ export const EnvelopeEditorPreviewPage = () => {
|
|||
</Alert>
|
||||
|
||||
{currentEnvelopeItem !== null ? (
|
||||
<PDFViewerKonvaLazy
|
||||
renderer="editor"
|
||||
<EnvelopePdfViewer
|
||||
customPageRenderer={EnvelopeGenericPageRenderer}
|
||||
scrollParentRef={scrollableContainerRef}
|
||||
errorMessage={PDF_VIEWER_ERROR_MESSAGES.preview}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-32">
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@ import { DocumentStatus, type Recipient, SigningStatus } from '@prisma/client';
|
|||
import type Konva from 'konva';
|
||||
|
||||
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import {
|
||||
type PageRenderData,
|
||||
useCurrentEnvelopeRender,
|
||||
} from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
|
||||
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
|
||||
|
|
@ -15,7 +18,7 @@ type GenericLocalField = TEnvelope['fields'][number] & {
|
|||
recipient: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>;
|
||||
};
|
||||
|
||||
export default function EnvelopeGenericPageRenderer() {
|
||||
export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRenderData }) => {
|
||||
const { i18n } = useLingui();
|
||||
|
||||
const {
|
||||
|
|
@ -28,19 +31,14 @@ export default function EnvelopeGenericPageRenderer() {
|
|||
overrideSettings,
|
||||
} = useCurrentEnvelopeRender();
|
||||
|
||||
const {
|
||||
stage,
|
||||
pageLayer,
|
||||
canvasElement,
|
||||
konvaContainer,
|
||||
pageContext,
|
||||
scaledViewport,
|
||||
unscaledViewport,
|
||||
} = usePageRenderer(({ stage, pageLayer }) => {
|
||||
createPageCanvas(stage, pageLayer);
|
||||
});
|
||||
const { stage, pageLayer, konvaContainer, unscaledViewport } = usePageRenderer(
|
||||
({ stage, pageLayer }) => {
|
||||
createPageCanvas(stage, pageLayer);
|
||||
},
|
||||
pageData,
|
||||
);
|
||||
|
||||
const { _className, scale } = pageContext;
|
||||
const { scale, pageNumber } = pageData;
|
||||
|
||||
const localPageFields = useMemo((): GenericLocalField[] => {
|
||||
if (envelopeStatus === DocumentStatus.COMPLETED) {
|
||||
|
|
@ -49,8 +47,7 @@ export default function EnvelopeGenericPageRenderer() {
|
|||
|
||||
return fields
|
||||
.filter(
|
||||
(field) =>
|
||||
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
||||
(field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
||||
)
|
||||
.map((field) => {
|
||||
const recipient = recipients.find((recipient) => recipient.id === field.recipientId);
|
||||
|
|
@ -73,7 +70,7 @@ export default function EnvelopeGenericPageRenderer() {
|
|||
(recipient.signingStatus === SigningStatus.SIGNED ? inserted : true) ||
|
||||
fieldMeta?.readOnly,
|
||||
);
|
||||
}, [fields, pageContext.pageNumber, currentEnvelopeItem?.id, recipients]);
|
||||
}, [fields, pageNumber, currentEnvelopeItem?.id, recipients, envelopeStatus]);
|
||||
|
||||
const unsafeRenderFieldOnLayer = (field: GenericLocalField) => {
|
||||
if (!pageLayer.current) {
|
||||
|
|
@ -160,11 +157,9 @@ export default function EnvelopeGenericPageRenderer() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-full"
|
||||
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
|
||||
>
|
||||
<>
|
||||
{overrideSettings?.showRecipientTooltip &&
|
||||
pageData.imageLoadingState === 'loaded' &&
|
||||
localPageFields.map((field) => (
|
||||
<EnvelopeRecipientFieldTooltip
|
||||
key={field.id}
|
||||
|
|
@ -176,14 +171,6 @@ export default function EnvelopeGenericPageRenderer() {
|
|||
|
||||
{/* The element Konva will inject it's canvas into. */}
|
||||
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
|
||||
|
||||
{/* Canvas the PDF will be rendered on. */}
|
||||
<canvas
|
||||
className={`${_className}__canvas z-0`}
|
||||
ref={canvasElement}
|
||||
height={scaledViewport.height}
|
||||
width={scaledViewport.width}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,7 +14,10 @@ import type { KonvaEventObject } from 'konva/lib/Node';
|
|||
import { match } from 'ts-pattern';
|
||||
|
||||
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import {
|
||||
type PageRenderData,
|
||||
useCurrentEnvelopeRender,
|
||||
} from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates';
|
||||
import { isBase64Image } from '@documenso/lib/constants/signatures';
|
||||
|
|
@ -49,7 +52,7 @@ type GenericLocalField = TEnvelope['fields'][number] & {
|
|||
recipient: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>;
|
||||
};
|
||||
|
||||
export default function EnvelopeSignerPageRenderer() {
|
||||
export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderData }) => {
|
||||
const { t, i18n } = useLingui();
|
||||
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
|
||||
const { sessionData } = useOptionalSession();
|
||||
|
|
@ -77,17 +80,12 @@ export default function EnvelopeSignerPageRenderer() {
|
|||
|
||||
const { onFieldSigned, onFieldUnsigned } = useEmbedSigningContext() || {};
|
||||
|
||||
const {
|
||||
stage,
|
||||
pageLayer,
|
||||
canvasElement,
|
||||
konvaContainer,
|
||||
pageContext,
|
||||
scaledViewport,
|
||||
unscaledViewport,
|
||||
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer));
|
||||
const { stage, pageLayer, konvaContainer, unscaledViewport } = usePageRenderer(
|
||||
({ stage, pageLayer }) => createPageCanvas(stage, pageLayer),
|
||||
pageData,
|
||||
);
|
||||
|
||||
const { _className, scale } = pageContext;
|
||||
const { scale, pageNumber } = pageData;
|
||||
|
||||
const { envelope } = envelopeData;
|
||||
|
||||
|
|
@ -99,10 +97,9 @@ export default function EnvelopeSignerPageRenderer() {
|
|||
}
|
||||
|
||||
return fieldsToRender.filter(
|
||||
(field) =>
|
||||
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
||||
(field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
||||
);
|
||||
}, [recipientFields, selectedAssistantRecipientFields, pageContext.pageNumber]);
|
||||
}, [recipientFields, selectedAssistantRecipientFields, pageNumber, currentEnvelopeItem?.id]);
|
||||
|
||||
/**
|
||||
* Returns fields that have been fully signed by other recipients for this specific
|
||||
|
|
@ -117,7 +114,7 @@ export default function EnvelopeSignerPageRenderer() {
|
|||
return recipient.fields
|
||||
.filter(
|
||||
(field) =>
|
||||
field.page === pageContext.pageNumber &&
|
||||
field.page === pageNumber &&
|
||||
field.envelopeItemId === currentEnvelopeItem?.id &&
|
||||
(field.inserted || field.fieldMeta?.readOnly),
|
||||
)
|
||||
|
|
@ -132,7 +129,7 @@ export default function EnvelopeSignerPageRenderer() {
|
|||
},
|
||||
}));
|
||||
});
|
||||
}, [envelope.recipients, pageContext.pageNumber]);
|
||||
}, [envelope.recipients, pageNumber, currentEnvelopeItem?.id]);
|
||||
|
||||
const unsafeRenderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
|
||||
if (!pageLayer.current) {
|
||||
|
|
@ -534,14 +531,11 @@ export default function EnvelopeSignerPageRenderer() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-full"
|
||||
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
|
||||
>
|
||||
<>
|
||||
{showPendingFieldTooltip &&
|
||||
recipientFieldsRemaining.length > 0 &&
|
||||
recipientFieldsRemaining[0]?.envelopeItemId === currentEnvelopeItem?.id &&
|
||||
recipientFieldsRemaining[0]?.page === pageContext.pageNumber && (
|
||||
recipientFieldsRemaining[0]?.page === pageNumber && (
|
||||
<EnvelopeFieldToolTip
|
||||
key={recipientFieldsRemaining[0].id}
|
||||
field={recipientFieldsRemaining[0]}
|
||||
|
|
@ -562,14 +556,6 @@ export default function EnvelopeSignerPageRenderer() {
|
|||
|
||||
{/* The element Konva will inject it's canvas into. */}
|
||||
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
|
||||
|
||||
{/* Canvas the PDF will be rendered on. */}
|
||||
<canvas
|
||||
className={`${_className}__canvas z-0`}
|
||||
ref={canvasElement}
|
||||
height={scaledViewport.height}
|
||||
width={scaledViewport.width}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { useNavigate, useRevalidator, useSearchParams } from 'react-router';
|
|||
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { PDF_VIEWER_CONTENT_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { isBase64Image } from '@documenso/lib/constants/signatures';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
|
||||
|
|
@ -71,6 +72,14 @@ export const EnvelopeSignerCompleteDialog = () => {
|
|||
|
||||
if (fieldTooltip) {
|
||||
fieldTooltip.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
} else {
|
||||
// Tooltip not in DOM (page virtualized away) — signal the PDF viewer
|
||||
// to scroll to the correct page via the data attribute.
|
||||
const pdfContent = document.querySelector(PDF_VIEWER_CONTENT_SELECTOR);
|
||||
|
||||
if (pdfContent) {
|
||||
pdfContent.setAttribute('data-scroll-to-page', String(nextField.page));
|
||||
}
|
||||
}
|
||||
},
|
||||
isEnvelopeItemSwitch ? 150 : 50,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
import React, { useRef } from 'react';
|
||||
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
|
||||
import { PDFViewer, type PDFViewerProps } from './pdf-viewer';
|
||||
|
||||
export type EnvelopePdfViewerProps = {
|
||||
/**
|
||||
* The error message to render when there is an error.
|
||||
*/
|
||||
errorMessage: { title: MessageDescriptor; description: MessageDescriptor } | null;
|
||||
} & Omit<PDFViewerProps, 'data'>;
|
||||
|
||||
export const EnvelopePdfViewer = ({
|
||||
errorMessage,
|
||||
className,
|
||||
...props
|
||||
}: EnvelopePdfViewerProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const $el = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { currentEnvelopeItem, renderError } = useCurrentEnvelopeRender();
|
||||
|
||||
if (renderError || !currentEnvelopeItem) {
|
||||
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>
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PDFViewer
|
||||
{...props}
|
||||
className={cn('h-full w-full max-w-[800px]', className)}
|
||||
data={currentEnvelopeItem.data}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnvelopePdfViewer;
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import type { ImageLoadingState } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Spinner } from '@documenso/ui/primitives/spinner';
|
||||
|
||||
type PdfViewerPageImageProps = {
|
||||
imageLoadingState: ImageLoadingState;
|
||||
imageProps: React.ImgHTMLAttributes<HTMLImageElement> & Record<string, unknown> & { alt: '' };
|
||||
};
|
||||
|
||||
export const PdfViewerPageImage = ({ imageLoadingState, imageProps }: PdfViewerPageImageProps) => {
|
||||
return (
|
||||
<>
|
||||
{/* Loading State */}
|
||||
{imageLoadingState === 'loading' && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center text-muted-foreground opacity-20">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{imageLoadingState === 'error' && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center">
|
||||
<p>
|
||||
<Trans>Error loading page</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The PDF image. */}
|
||||
{imageProps.src && <img {...imageProps} className={cn(imageProps.className, '')} alt="" />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 opacity-50">
|
||||
<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>
|
||||
);
|
||||
};
|
||||
478
apps/remix/app/components/general/pdf-viewer/pdf-viewer.tsx
Normal file
478
apps/remix/app/components/general/pdf-viewer/pdf-viewer.tsx
Normal file
|
|
@ -0,0 +1,478 @@
|
|||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import pMap from 'p-map';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker?url';
|
||||
|
||||
import type {
|
||||
ImageLoadingState,
|
||||
PageRenderData,
|
||||
} from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { PDF_VIEWER_PAGE_CLASSNAME } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import type { ScrollTarget } from '../virtual-list/use-virtual-list';
|
||||
import { useVirtualList } from '../virtual-list/use-virtual-list';
|
||||
import { PdfViewerPageImage } from './pdf-viewer-page-image';
|
||||
import { PdfViewerErrorState, PdfViewerLoadingState } from './pdf-viewer-states';
|
||||
import { useScrollToPage } from './use-scroll-to-page';
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker;
|
||||
|
||||
type PageMeta = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type LoadingState = 'loading' | 'loaded' | 'error';
|
||||
|
||||
const LOW_RENDER_RESOLUTION = 1;
|
||||
const HIGH_RENDER_RESOLUTION = 2;
|
||||
const IDLE_RENDER_DELAY = 200;
|
||||
|
||||
export type PDFViewerProps = {
|
||||
className?: string;
|
||||
|
||||
/**
|
||||
* The PDF data to render.
|
||||
*
|
||||
* If it's a URL, it will be fetched and rendered.
|
||||
*/
|
||||
data: Uint8Array | string;
|
||||
|
||||
/**
|
||||
* Ref to the scrollable parent container that handles scrolling.
|
||||
*
|
||||
* This must point to an element with `overflow-y: auto` or `overflow-y: scroll`
|
||||
* that is an ancestor of this component, or `'window'` to use the browser
|
||||
* window as the scroll container.
|
||||
*/
|
||||
scrollParentRef: ScrollTarget;
|
||||
|
||||
onDocumentLoad?: () => void;
|
||||
|
||||
/**
|
||||
* Additional component to render next to the image, such as a Konva canvas
|
||||
* for rendering fields.
|
||||
*/
|
||||
customPageRenderer?: React.FunctionComponent<{ pageData: PageRenderData }>;
|
||||
} & React.HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const PDFViewer = ({
|
||||
className,
|
||||
data,
|
||||
scrollParentRef,
|
||||
onDocumentLoad,
|
||||
customPageRenderer,
|
||||
...props
|
||||
}: PDFViewerProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const $el = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [loadingState, setLoadingState] = useState<LoadingState>('loading');
|
||||
|
||||
const [pdf, setPdf] = useState<pdfjsLib.PDFDocumentProxy | null>(null);
|
||||
|
||||
const [pages, setPages] = useState<PageMeta[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMetadata = async () => {
|
||||
try {
|
||||
setLoadingState('loading');
|
||||
setPages([]);
|
||||
|
||||
let result: Uint8Array | null = typeof data === 'string' ? null : new Uint8Array(data);
|
||||
|
||||
if (typeof data === 'string') {
|
||||
const response = await fetch(data);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch PDF data: ${response.status}`);
|
||||
}
|
||||
|
||||
result = new Uint8Array(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
const loadedPdf = await pdfjsLib.getDocument({ data: result! }).promise;
|
||||
|
||||
if (pdf) {
|
||||
await pdf.destroy();
|
||||
}
|
||||
|
||||
setPdf(loadedPdf);
|
||||
|
||||
// Fetch the pages
|
||||
const pages = await pMap(
|
||||
Array.from({ length: loadedPdf.numPages }),
|
||||
async (_, pageIndex) => {
|
||||
const page = await loadedPdf.getPage(pageIndex + 1);
|
||||
const viewport = page.getViewport({ scale: 1 });
|
||||
|
||||
return {
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
setPages(pages);
|
||||
|
||||
setLoadingState('loaded');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setLoadingState('error');
|
||||
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: t`An error occurred while loading the document.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
void fetchMetadata();
|
||||
|
||||
return () => {
|
||||
if (pdf) {
|
||||
void pdf.destroy();
|
||||
}
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
// Notify when document is loaded
|
||||
useEffect(() => {
|
||||
if (loadingState === 'loaded' && onDocumentLoad) {
|
||||
onDocumentLoad();
|
||||
}
|
||||
}, [loadingState, onDocumentLoad]);
|
||||
|
||||
const isLoading = loadingState === 'loading';
|
||||
const hasError = loadingState === 'error';
|
||||
|
||||
return (
|
||||
<div ref={$el} className={cn('h-full w-full', className)} {...props}>
|
||||
{/* Loading State */}
|
||||
{isLoading && <PdfViewerLoadingState />}
|
||||
|
||||
{/* Error State */}
|
||||
{hasError && <PdfViewerErrorState />}
|
||||
|
||||
{/* Loaded State */}
|
||||
{loadingState === 'loaded' && pages.length > 0 && pdf && (
|
||||
<VirtualizedPageList
|
||||
scrollParentRef={scrollParentRef}
|
||||
constraintRef={$el}
|
||||
numPages={pages.length}
|
||||
pages={pages}
|
||||
pdf={pdf}
|
||||
customPageRenderer={customPageRenderer}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type VirtualizedPageListProps = {
|
||||
scrollParentRef: ScrollTarget;
|
||||
constraintRef: React.RefObject<HTMLDivElement>;
|
||||
pages: PageMeta[];
|
||||
numPages: number;
|
||||
pdf: pdfjsLib.PDFDocumentProxy;
|
||||
customPageRenderer?: React.FunctionComponent<{ pageData: PageRenderData }>;
|
||||
};
|
||||
|
||||
const VirtualizedPageList = ({
|
||||
scrollParentRef,
|
||||
constraintRef,
|
||||
pages,
|
||||
numPages,
|
||||
pdf,
|
||||
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.height / pageMeta.width;
|
||||
const scaledHeight = width * aspectRatio;
|
||||
|
||||
// Add 32px for the page number text and margins (my-2 = 8px * 2 + text height ~16px)
|
||||
// Add additional 2px for the top and bottom borders.
|
||||
return scaledHeight + 32 + 2;
|
||||
},
|
||||
overscan: 5,
|
||||
});
|
||||
|
||||
useScrollToPage(contentRef, scrollToItem);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={contentRef}
|
||||
// Note: This is actually used.
|
||||
data-pdf-content=""
|
||||
data-page-count={numPages}
|
||||
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 = Math.floor(pageMeta.width * scale);
|
||||
const scaledHeight = Math.floor(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)`,
|
||||
}}
|
||||
>
|
||||
<PdfViewerPage
|
||||
unscaledWidth={pageMeta.width}
|
||||
unscaledHeight={pageMeta.height}
|
||||
scaledWidth={scaledWidth}
|
||||
scaledHeight={scaledHeight}
|
||||
pageNumber={pageNumber}
|
||||
pdf={pdf}
|
||||
scale={scale}
|
||||
customPageRenderer={customPageRenderer}
|
||||
/>
|
||||
|
||||
<p className="my-2 text-center text-[11px] text-muted-foreground/80">
|
||||
<Trans>
|
||||
Page {pageNumber} of {numPages}
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type PdfViewerPageProps = {
|
||||
pageNumber: number;
|
||||
pdf: pdfjsLib.PDFDocumentProxy;
|
||||
unscaledWidth: number;
|
||||
unscaledHeight: number;
|
||||
scaledWidth: number;
|
||||
scaledHeight: number;
|
||||
scale: number;
|
||||
customPageRenderer?: React.FunctionComponent<{ pageData: PageRenderData }>;
|
||||
};
|
||||
|
||||
const PdfViewerPage = ({
|
||||
pageNumber,
|
||||
pdf,
|
||||
unscaledWidth,
|
||||
unscaledHeight,
|
||||
scaledWidth,
|
||||
scaledHeight,
|
||||
scale,
|
||||
customPageRenderer: CustomPageRenderer,
|
||||
}: PdfViewerPageProps) => {
|
||||
const { imageProps, imageLoadingState } = usePdfPageImage({
|
||||
pageNumber,
|
||||
pdf,
|
||||
unscaledWidth,
|
||||
unscaledHeight,
|
||||
scaledWidth,
|
||||
scaledHeight,
|
||||
scale,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-full rounded border border-border"
|
||||
style={{ width: scaledWidth, height: scaledHeight }}
|
||||
>
|
||||
{CustomPageRenderer && imageLoadingState === 'loaded' && (
|
||||
<CustomPageRenderer
|
||||
pageData={{
|
||||
scale,
|
||||
pageIndex: pageNumber - 1,
|
||||
pageNumber,
|
||||
pageWidth: unscaledWidth,
|
||||
pageHeight: unscaledHeight,
|
||||
imageLoadingState,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PdfViewerPageImage imageLoadingState={imageLoadingState} imageProps={imageProps} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Manages rendering a page from a pdf.
|
||||
*/
|
||||
const usePdfPageImage = ({
|
||||
pageNumber,
|
||||
pdf,
|
||||
scale,
|
||||
scaledWidth,
|
||||
scaledHeight,
|
||||
}: PdfViewerPageProps) => {
|
||||
const [imageLoadingState, setImageLoadingState] = useState<ImageLoadingState>('loading');
|
||||
|
||||
const [imageUrl, setImageUrl] = useState('');
|
||||
const renderTaskRef = useRef<pdfjsLib.RenderTask | null>(null);
|
||||
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const renderedResolutionRef = useRef<number | null>(null);
|
||||
const renderedPageNumberRef = useRef<number | null>(null);
|
||||
const renderedPdfRef = useRef<pdfjsLib.PDFDocumentProxy | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
const cancelRenderTask = () => {
|
||||
if (!renderTaskRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
renderTaskRef.current.cancel();
|
||||
renderTaskRef.current = null;
|
||||
};
|
||||
|
||||
const hasMatchingRenderedImage = (resolution: number) => {
|
||||
return (
|
||||
renderedPdfRef.current === pdf &&
|
||||
renderedPageNumberRef.current === pageNumber &&
|
||||
renderedResolutionRef.current === resolution
|
||||
);
|
||||
};
|
||||
|
||||
const setRenderedImageMeta = (resolution: number) => {
|
||||
renderedPdfRef.current = pdf;
|
||||
renderedPageNumberRef.current = pageNumber;
|
||||
renderedResolutionRef.current = resolution;
|
||||
};
|
||||
|
||||
const renderAtResolution = async (resolution: number) => {
|
||||
let currentTask: pdfjsLib.RenderTask | null = null;
|
||||
|
||||
try {
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasMatchingRenderedImage(resolution)) {
|
||||
return;
|
||||
}
|
||||
|
||||
cancelRenderTask();
|
||||
|
||||
const page = await pdf.getPage(pageNumber);
|
||||
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const renderScale = scale * resolution;
|
||||
const viewport = page.getViewport({ scale: renderScale });
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.floor(viewport.width);
|
||||
canvas.height = Math.floor(viewport.height);
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
if (!context) {
|
||||
throw new Error('Failed to get canvas context');
|
||||
}
|
||||
|
||||
currentTask = page.render({
|
||||
canvasContext: context,
|
||||
viewport,
|
||||
canvas,
|
||||
});
|
||||
renderTaskRef.current = currentTask;
|
||||
|
||||
await currentTask.promise;
|
||||
|
||||
if (isCancelled || renderTaskRef.current !== currentTask) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRenderedImageMeta(resolution);
|
||||
|
||||
setImageUrl(canvas.toDataURL('image/jpeg'));
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === 'RenderingCancelledException') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isCancelled) {
|
||||
console.error(err);
|
||||
setImageLoadingState('error');
|
||||
}
|
||||
} finally {
|
||||
if (renderTaskRef.current === currentTask) {
|
||||
renderTaskRef.current = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void renderAtResolution(LOW_RENDER_RESOLUTION);
|
||||
|
||||
idleTimerRef.current = setTimeout(() => {
|
||||
void renderAtResolution(HIGH_RENDER_RESOLUTION);
|
||||
}, IDLE_RENDER_DELAY);
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
|
||||
if (idleTimerRef.current) {
|
||||
clearTimeout(idleTimerRef.current);
|
||||
idleTimerRef.current = null;
|
||||
}
|
||||
|
||||
cancelRenderTask();
|
||||
};
|
||||
}, [pdf, pageNumber, scale]);
|
||||
|
||||
const imageProps = useMemo(
|
||||
(): React.ImgHTMLAttributes<HTMLImageElement> & Record<string, unknown> & { alt: '' } => ({
|
||||
className: PDF_VIEWER_PAGE_CLASSNAME,
|
||||
width: Math.floor(scaledWidth),
|
||||
height: Math.floor(scaledHeight),
|
||||
alt: '',
|
||||
onLoad: () => setImageLoadingState('loaded'),
|
||||
onError: () => setImageLoadingState('error'),
|
||||
src: imageUrl,
|
||||
'data-page-number': pageNumber,
|
||||
draggable: false,
|
||||
}),
|
||||
[scaledWidth, scaledHeight, imageUrl, pageNumber],
|
||||
);
|
||||
|
||||
return {
|
||||
imageProps,
|
||||
imageLoadingState,
|
||||
};
|
||||
};
|
||||
|
|
@ -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]);
|
||||
};
|
||||
|
|
@ -13,12 +13,12 @@ import {
|
|||
} from '@documenso/lib/constants/trpc';
|
||||
import { ZDocumentAccessAuthTypesSchema } from '@documenso/lib/types/document-auth';
|
||||
import type { TTemplate } from '@documenso/lib/types/template';
|
||||
import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
import { Stepper } from '@documenso/ui/primitives/stepper';
|
||||
import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-fields';
|
||||
import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types';
|
||||
|
|
@ -28,6 +28,7 @@ import { AddTemplateSettingsFormPartial } from '@documenso/ui/primitives/templat
|
|||
import type { TAddTemplateSettingsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-settings.types';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { PDFViewer } from '~/components/general/pdf-viewer/pdf-viewer';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type TemplateEditFormProps = {
|
||||
|
|
@ -312,11 +313,17 @@ export const TemplateEditForm = ({
|
|||
gradient
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
<PDFViewerLazy
|
||||
<PDFViewer
|
||||
key={template.envelopeItems[0].id}
|
||||
envelopeItem={template.envelopeItems[0]}
|
||||
token={undefined}
|
||||
version="signed"
|
||||
data={getDocumentDataUrl({
|
||||
envelopeId: template.envelopeId,
|
||||
envelopeItemId: template.envelopeItems[0].id,
|
||||
documentDataId: initialTemplate.templateDocumentDataId,
|
||||
version: 'current',
|
||||
token: undefined,
|
||||
presignToken: undefined,
|
||||
})}
|
||||
scrollParentRef="window"
|
||||
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||
/>
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
import { lazy } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Plural, Trans, useLingui } from '@lingui/react/macro';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
|
|
@ -9,19 +7,19 @@ import { match } from 'ts-pattern';
|
|||
|
||||
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
|
||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||
import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import {
|
||||
DocumentReadOnlyFields,
|
||||
mapFieldsWithRecipients,
|
||||
} from '@documenso/ui/components/document/document-read-only-fields';
|
||||
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
import { Spinner } from '@documenso/ui/primitives/spinner';
|
||||
|
||||
import { DocumentPageViewButton } from '~/components/general/document/document-page-view-button';
|
||||
|
|
@ -35,16 +33,15 @@ import {
|
|||
FRIENDLY_STATUS_MAP,
|
||||
} from '~/components/general/document/document-status';
|
||||
import { EnvelopeRendererFileSelector } from '~/components/general/envelope-editor/envelope-file-selector';
|
||||
import { EnvelopeGenericPageRenderer } from '~/components/general/envelope-editor/envelope-generic-page-renderer';
|
||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||
import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
|
||||
import { PDFViewer } from '~/components/general/pdf-viewer/pdf-viewer';
|
||||
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
import type { Route } from './+types/documents.$id._index';
|
||||
|
||||
const EnvelopeGenericPageRenderer = lazy(
|
||||
async () => import('~/components/general/envelope-editor/envelope-generic-page-renderer'),
|
||||
);
|
||||
|
||||
export default function DocumentPage({ params }: Route.ComponentProps) {
|
||||
const { t } = useLingui();
|
||||
const { user } = useSession();
|
||||
|
|
@ -154,7 +151,9 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
|
|||
{envelope.internalVersion === 2 ? (
|
||||
<div className="relative col-span-12 lg:col-span-6 xl:col-span-7">
|
||||
<EnvelopeRenderProvider
|
||||
version="current"
|
||||
envelope={envelope}
|
||||
envelopeItems={envelope.envelopeItems}
|
||||
token={undefined}
|
||||
fields={envelope.fields}
|
||||
recipients={envelope.recipients}
|
||||
|
|
@ -169,9 +168,10 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
|
|||
|
||||
<Card className="rounded-xl before:rounded-xl" gradient>
|
||||
<CardContent className="p-2">
|
||||
<PDFViewerKonvaLazy
|
||||
renderer="preview"
|
||||
<EnvelopePdfViewer
|
||||
customPageRenderer={EnvelopeGenericPageRenderer}
|
||||
scrollParentRef="window"
|
||||
errorMessage={PDF_VIEWER_ERROR_MESSAGES.preview}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -193,11 +193,17 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
|
|||
/>
|
||||
)}
|
||||
|
||||
<PDFViewerLazy
|
||||
envelopeItem={envelope.envelopeItems[0]}
|
||||
token={undefined}
|
||||
<PDFViewer
|
||||
data={getDocumentDataUrl({
|
||||
envelopeId: envelope.id,
|
||||
envelopeItemId: envelope.envelopeItems[0].id,
|
||||
documentDataId: envelope.envelopeItems[0].documentDataId,
|
||||
version: 'current',
|
||||
token: undefined,
|
||||
presignToken: undefined,
|
||||
})}
|
||||
key={envelope.envelopeItems[0].id}
|
||||
version="signed"
|
||||
scrollParentRef="window"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
|
|||
|
||||
if (envelope && (envelope.teamId !== team.id || envelope.internalVersion !== 2)) {
|
||||
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 />
|
||||
<Trans>Redirecting</Trans>
|
||||
</div>
|
||||
|
|
@ -67,7 +67,7 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
|
|||
|
||||
if (isLoadingEnvelope) {
|
||||
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 />
|
||||
<Trans>Loading</Trans>
|
||||
</div>
|
||||
|
|
@ -99,7 +99,9 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
|
|||
return (
|
||||
<EnvelopeEditorProvider initialEnvelope={envelope}>
|
||||
<EnvelopeRenderProvider
|
||||
version="current"
|
||||
envelope={envelope}
|
||||
envelopeItems={envelope.envelopeItems}
|
||||
token={undefined}
|
||||
fields={envelope.fields}
|
||||
recipients={envelope.recipients}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import { lazy } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { DocumentSigningOrder, SigningStatus } from '@prisma/client';
|
||||
|
|
@ -8,22 +6,25 @@ import { Link, useNavigate } from 'react-router';
|
|||
|
||||
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
|
||||
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
||||
import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download';
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
|
||||
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
import { Spinner } from '@documenso/ui/primitives/spinner';
|
||||
|
||||
import { TemplateBulkSendDialog } from '~/components/dialogs/template-bulk-send-dialog';
|
||||
import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog';
|
||||
import { TemplateUseDialog } from '~/components/dialogs/template-use-dialog';
|
||||
import { EnvelopeRendererFileSelector } from '~/components/general/envelope-editor/envelope-file-selector';
|
||||
import { EnvelopeGenericPageRenderer } from '~/components/general/envelope-editor/envelope-generic-page-renderer';
|
||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||
import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
|
||||
import { PDFViewer } from '~/components/general/pdf-viewer/pdf-viewer';
|
||||
import { TemplateDirectLinkBadge } from '~/components/general/template/template-direct-link-badge';
|
||||
import { TemplatePageViewDocumentsTable } from '~/components/general/template/template-page-view-documents-table';
|
||||
import { TemplatePageViewInformation } from '~/components/general/template/template-page-view-information';
|
||||
|
|
@ -35,10 +36,6 @@ import { useCurrentTeam } from '~/providers/team';
|
|||
|
||||
import type { Route } from './+types/templates.$id._index';
|
||||
|
||||
const EnvelopeGenericPageRenderer = lazy(
|
||||
async () => import('~/components/general/envelope-editor/envelope-generic-page-renderer'),
|
||||
);
|
||||
|
||||
export default function TemplatePage({ params }: Route.ComponentProps) {
|
||||
const { t } = useLingui();
|
||||
const { user } = useSession();
|
||||
|
|
@ -173,7 +170,9 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
|
|||
{envelope.internalVersion === 2 ? (
|
||||
<div className="relative col-span-12 lg:col-span-6 xl:col-span-7">
|
||||
<EnvelopeRenderProvider
|
||||
version="current"
|
||||
envelope={envelope}
|
||||
envelopeItems={envelope.envelopeItems}
|
||||
token={undefined}
|
||||
fields={envelope.fields}
|
||||
recipients={envelope.recipients}
|
||||
|
|
@ -187,9 +186,10 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
|
|||
|
||||
<Card className="rounded-xl before:rounded-xl" gradient>
|
||||
<CardContent className="p-2">
|
||||
<PDFViewerKonvaLazy
|
||||
renderer="preview"
|
||||
<EnvelopePdfViewer
|
||||
customPageRenderer={EnvelopeGenericPageRenderer}
|
||||
scrollParentRef="window"
|
||||
errorMessage={PDF_VIEWER_ERROR_MESSAGES.preview}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -210,11 +210,17 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
|
|||
documentMeta={mockedDocumentMeta}
|
||||
/>
|
||||
|
||||
<PDFViewerLazy
|
||||
envelopeItem={envelope.envelopeItems[0]}
|
||||
token={undefined}
|
||||
version="signed"
|
||||
<PDFViewer
|
||||
data={getDocumentDataUrl({
|
||||
envelopeId: envelope.id,
|
||||
envelopeItemId: envelope.envelopeItems[0].id,
|
||||
documentDataId: envelope.envelopeItems[0].documentDataId,
|
||||
version: 'current',
|
||||
token: undefined,
|
||||
presignToken: undefined,
|
||||
})}
|
||||
key={envelope.envelopeItems[0].id}
|
||||
scrollParentRef="window"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -198,7 +198,7 @@ const DirectSigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV
|
|||
{template.title}
|
||||
</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" />
|
||||
<p className="text-muted-foreground/80">
|
||||
<Plural value={template.recipients.length} one="# recipient" other="# recipients" />
|
||||
|
|
@ -246,7 +246,12 @@ const DirectSigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV
|
|||
recipient={recipient}
|
||||
user={user}
|
||||
>
|
||||
<EnvelopeRenderProvider envelope={envelope} token={recipient.token}>
|
||||
<EnvelopeRenderProvider
|
||||
version="current"
|
||||
envelope={envelope}
|
||||
envelopeItems={envelope.envelopeItems}
|
||||
token={recipient.token}
|
||||
>
|
||||
<DocumentSigningPageViewV2 />
|
||||
</EnvelopeRenderProvider>
|
||||
</DocumentSigningAuthProvider>
|
||||
|
|
|
|||
|
|
@ -504,7 +504,12 @@ const SigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV2Loade
|
|||
recipient={recipient}
|
||||
user={user}
|
||||
>
|
||||
<EnvelopeRenderProvider envelope={envelope} token={recipient.token}>
|
||||
<EnvelopeRenderProvider
|
||||
version="current"
|
||||
envelope={envelope}
|
||||
envelopeItems={envelope.envelopeItems}
|
||||
token={recipient.token}
|
||||
>
|
||||
<DocumentSigningPageViewV2 />
|
||||
</EnvelopeRenderProvider>
|
||||
</DocumentSigningAuthProvider>
|
||||
|
|
|
|||
|
|
@ -320,7 +320,12 @@ const EmbedDirectTemplatePageV2 = ({
|
|||
user={user}
|
||||
isDirectTemplate={true}
|
||||
>
|
||||
<EnvelopeRenderProvider envelope={envelope} token={recipient.token}>
|
||||
<EnvelopeRenderProvider
|
||||
version="current"
|
||||
envelope={envelope}
|
||||
envelopeItems={envelope.envelopeItems}
|
||||
token={recipient.token}
|
||||
>
|
||||
<EmbedSignDocumentV2ClientPage
|
||||
hidePoweredBy={hidePoweredBy}
|
||||
allowWhitelabelling={allowEmbedSigningWhitelabel}
|
||||
|
|
|
|||
|
|
@ -405,7 +405,12 @@ const EmbedSignDocumentPageV2 = ({
|
|||
recipient={recipient}
|
||||
user={user}
|
||||
>
|
||||
<EnvelopeRenderProvider envelope={envelope} token={token}>
|
||||
<EnvelopeRenderProvider
|
||||
version="current"
|
||||
envelope={envelope}
|
||||
envelopeItems={envelope.envelopeItems}
|
||||
token={token}
|
||||
>
|
||||
<EmbedSignDocumentV2ClientPage
|
||||
hidePoweredBy={hidePoweredBy}
|
||||
allowWhitelabelling={allowEmbedSigningWhitelabel}
|
||||
|
|
|
|||
|
|
@ -325,7 +325,10 @@ export default function EmbeddingAuthoringTemplateEditPage() {
|
|||
<ConfigureFieldsView
|
||||
configData={configuration!}
|
||||
presignToken={token}
|
||||
envelopeItem={template.envelopeItems[0]}
|
||||
envelopeItem={{
|
||||
...template.envelopeItems[0],
|
||||
documentDataId: template.templateDocumentDataId,
|
||||
}}
|
||||
defaultValues={fields ?? undefined}
|
||||
onBack={canGoBack ? handleBackToConfig : undefined}
|
||||
onSubmit={handleConfigureFieldsSubmit}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ import {
|
|||
ZGetPresignedPostUrlRequestSchema,
|
||||
ZUploadPdfRequestSchema,
|
||||
} from './files.types';
|
||||
import getEnvelopeItemPdfRoute from './routes/get-envelope-item-pdf';
|
||||
import getEnvelopeItemPdfByTokenRoute from './routes/get-envelope-item-pdf-by-token';
|
||||
|
||||
export const filesRoute = new Hono<HonoEnv>()
|
||||
/**
|
||||
|
|
@ -319,3 +321,8 @@ export const filesRoute = new Hono<HonoEnv>()
|
|||
});
|
||||
},
|
||||
);
|
||||
|
||||
// PDF routes for both tokens and auth based
|
||||
// Is different to the other file endpoints since it uses documentDataId for hard caching.
|
||||
filesRoute.route('/', getEnvelopeItemPdfRoute);
|
||||
filesRoute.route('/', getEnvelopeItemPdfByTokenRoute);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
import { sValidator } from '@hono/standard-validator';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { HonoEnv } from '../../../router';
|
||||
import { handleEnvelopeItemPdfRequest } from './get-envelope-item-pdf';
|
||||
|
||||
const route = new Hono<HonoEnv>();
|
||||
|
||||
const ZGetEnvelopeItemByTokenParamsSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
envelopeId: z.string().min(1),
|
||||
envelopeItemId: z.string().min(1),
|
||||
documentDataId: z.string().min(1),
|
||||
version: z.enum(['initial', 'current']),
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns a PDF file for an envelope item using a token.
|
||||
*/
|
||||
route.get(
|
||||
'/token/:token/envelope/:envelopeId/envelopeItem/:envelopeItemId/dataId/:documentDataId/:version/item.pdf',
|
||||
sValidator('param', ZGetEnvelopeItemByTokenParamsSchema),
|
||||
async (c) => {
|
||||
const { token, envelopeId, envelopeItemId, documentDataId, version } = c.req.valid('param');
|
||||
|
||||
if (!token) {
|
||||
return c.json({ error: 'Not found' }, 404);
|
||||
}
|
||||
|
||||
// Recipient token based query.
|
||||
let envelopeItemWhereQuery: Prisma.EnvelopeItemWhereInput = {
|
||||
id: envelopeItemId,
|
||||
documentDataId,
|
||||
envelope: {
|
||||
id: envelopeId,
|
||||
recipients: {
|
||||
some: {
|
||||
token,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// QR token based query.
|
||||
if (token.startsWith('qr_')) {
|
||||
envelopeItemWhereQuery = {
|
||||
id: envelopeItemId,
|
||||
documentDataId,
|
||||
envelope: {
|
||||
id: envelopeId,
|
||||
qrToken: token,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Validate envelope access.
|
||||
const envelopeItem = await prisma.envelopeItem.findFirst({
|
||||
where: envelopeItemWhereQuery,
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelopeItem) {
|
||||
return c.json({ error: 'Not found' }, 404);
|
||||
}
|
||||
|
||||
return await handleEnvelopeItemPdfRequest({
|
||||
c,
|
||||
envelopeItem,
|
||||
version,
|
||||
cacheStrategy: 'private',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export default route;
|
||||
145
apps/remix/server/api/files/routes/get-envelope-item-pdf.ts
Normal file
145
apps/remix/server/api/files/routes/get-envelope-item-pdf.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { sValidator } from '@hono/standard-validator';
|
||||
import type { DocumentData, EnvelopeItem } from '@prisma/client';
|
||||
import { type Context, Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
||||
import type { DocumentDataVersion } from '@documenso/lib/types/document';
|
||||
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { HonoEnv } from '../../../router';
|
||||
|
||||
const route = new Hono<HonoEnv>();
|
||||
|
||||
const ZGetEnvelopeItemPdfRequestParamsSchema = z.object({
|
||||
envelopeId: z.string().min(1),
|
||||
envelopeItemId: z.string().min(1),
|
||||
documentDataId: z.string().min(1),
|
||||
version: z.enum(['initial', 'current']),
|
||||
});
|
||||
|
||||
const ZGetEnvelopeItemPdfRequestQuerySchema = z.object({
|
||||
presignToken: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns a PDF file for an envelope item.
|
||||
*/
|
||||
route.get(
|
||||
'/envelope/:envelopeId/envelopeItem/:envelopeItemId/dataId/:documentDataId/:version/item.pdf',
|
||||
sValidator('param', ZGetEnvelopeItemPdfRequestParamsSchema),
|
||||
sValidator('query', ZGetEnvelopeItemPdfRequestQuerySchema),
|
||||
async (c) => {
|
||||
const { envelopeId, envelopeItemId, documentDataId, version } = c.req.valid('param');
|
||||
|
||||
const { presignToken } = c.req.valid('query');
|
||||
|
||||
const session = await getOptionalSession(c);
|
||||
|
||||
let userId = session.user?.id;
|
||||
|
||||
// Check presignToken if provided
|
||||
if (presignToken) {
|
||||
const verifiedToken = await verifyEmbeddingPresignToken({
|
||||
token: presignToken,
|
||||
}).catch(() => undefined);
|
||||
|
||||
userId = verifiedToken?.userId;
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
return c.json({ error: 'Not found' }, 404);
|
||||
}
|
||||
|
||||
// Note: We authenticate whether the user can access this in the `getTeamById` below.
|
||||
const envelopeItem = await prisma.envelopeItem.findFirst({
|
||||
where: {
|
||||
id: envelopeItemId,
|
||||
envelopeId,
|
||||
documentDataId,
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
envelope: {
|
||||
select: {
|
||||
id: true,
|
||||
teamId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelopeItem) {
|
||||
return c.json({ error: 'Not found' }, 404);
|
||||
}
|
||||
|
||||
// Check whether the user has access to the document.
|
||||
const team = await getTeamById({
|
||||
userId,
|
||||
teamId: envelopeItem.envelope.teamId,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!team) {
|
||||
return c.json({ error: 'Not found' }, 404);
|
||||
}
|
||||
|
||||
return await handleEnvelopeItemPdfRequest({
|
||||
c,
|
||||
envelopeItem,
|
||||
version,
|
||||
cacheStrategy: 'private',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
type HandleEnvelopeItemPdfRequestOptions = {
|
||||
c: Context<HonoEnv>;
|
||||
envelopeItem: EnvelopeItem & {
|
||||
documentData: DocumentData;
|
||||
};
|
||||
version: DocumentDataVersion;
|
||||
|
||||
/**
|
||||
* The type of cache strategy to use.
|
||||
*
|
||||
* For access via tokens, we can use a public cache to allow the CDN to cache it.
|
||||
*
|
||||
* For access via session, we must use a private cache.
|
||||
*/
|
||||
cacheStrategy: 'private' | 'public';
|
||||
};
|
||||
|
||||
export const handleEnvelopeItemPdfRequest = async ({
|
||||
c,
|
||||
envelopeItem,
|
||||
version,
|
||||
cacheStrategy,
|
||||
}: HandleEnvelopeItemPdfRequestOptions) => {
|
||||
// Determine which PDF data to use based on version requested.
|
||||
const documentDataToUse =
|
||||
version === 'current' ? envelopeItem.documentData.data : envelopeItem.documentData.initialData;
|
||||
|
||||
const file = await getFileServerSide({
|
||||
type: envelopeItem.documentData.type,
|
||||
data: documentDataToUse,
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!file) {
|
||||
return c.json({ error: 'Not found' }, 404);
|
||||
}
|
||||
|
||||
// Note: Only set these headers on success.
|
||||
c.header('Content-Type', 'application/pdf');
|
||||
c.header('Cache-Control', `${cacheStrategy}, max-age=31536000, immutable`);
|
||||
|
||||
return c.body(file);
|
||||
};
|
||||
|
||||
export default route;
|
||||
84
package-lock.json
generated
84
package-lock.json
generated
|
|
@ -27753,24 +27753,6 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/make-cancellable-promise": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/make-cancellable-promise/-/make-cancellable-promise-2.0.0.tgz",
|
||||
"integrity": "sha512-3SEQqTpV9oqVsIWqAcmDuaNeo7yBO3tqPtqGRcKkEo0lrzD3wqbKG9mkxO65KoOgXqj+zH2phJ2LiAsdzlogSw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/wojtekmaj/make-cancellable-promise?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/make-event-props": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/make-event-props/-/make-event-props-2.0.0.tgz",
|
||||
"integrity": "sha512-G/hncXrl4Qt7mauJEXSg3AcdYzmpkIITTNl5I+rH9sog5Yw0kK6vseJjCaPfOXqOqQuPUP89Rkhfz5kPS8ijtw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/wojtekmaj/make-event-props?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/map-stream": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz",
|
||||
|
|
@ -28155,23 +28137,6 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/merge-refs": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/merge-refs/-/merge-refs-2.0.0.tgz",
|
||||
"integrity": "sha512-3+B21mYK2IqUWnd2EivABLT7ueDhb0b8/dGK8LoFQPrU61YITeCMn14F7y7qZafWNZhUEKb24cJdiT5Wxs3prg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/wojtekmaj/merge-refs?sponsor=1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/merge-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
||||
|
|
@ -32012,44 +31977,6 @@
|
|||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-pdf": {
|
||||
"version": "10.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-10.3.0.tgz",
|
||||
"integrity": "sha512-2LQzC9IgNVAX8gM+6F+1t/70a9/5RWThYxc+CWAmT2LW/BRmnj+35x1os5j/nR2oldyf8L+hCAMBmVKU8wrYFA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"clsx": "^2.0.0",
|
||||
"dequal": "^2.0.3",
|
||||
"make-cancellable-promise": "^2.0.0",
|
||||
"make-event-props": "^2.0.0",
|
||||
"merge-refs": "^2.0.0",
|
||||
"pdfjs-dist": "5.4.296",
|
||||
"tiny-invariant": "^1.0.0",
|
||||
"warning": "^4.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/wojtekmaj/react-pdf?sponsor=1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-pdf/node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/react-promise-suspense": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/react-promise-suspense/-/react-promise-suspense-0.3.4.tgz",
|
||||
|
|
@ -36264,15 +36191,6 @@
|
|||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/warning": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
|
||||
"integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wcwidth": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
|
||||
|
|
@ -37189,7 +37107,6 @@
|
|||
"posthog-js": "^1.297.2",
|
||||
"posthog-node": "4.18.0",
|
||||
"react": "^18",
|
||||
"react-pdf": "^10.3.0",
|
||||
"remeda": "^2.32.0",
|
||||
"sharp": "0.34.5",
|
||||
"skia-canvas": "^3.0.8",
|
||||
|
|
@ -37347,7 +37264,6 @@
|
|||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18",
|
||||
"react-hook-form": "^7.66.1",
|
||||
"react-pdf": "^10.3.0",
|
||||
"react-rnd": "^10.5.2",
|
||||
"remeda": "^2.32.0",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
|
@ -46,7 +47,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
|
|
@ -54,7 +55,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
|
|
@ -74,7 +75,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 500,
|
||||
|
|
@ -100,7 +101,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
|
|
@ -108,7 +109,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
|
|
@ -128,7 +129,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 500,
|
||||
|
|
@ -162,7 +163,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
|
|
@ -170,7 +171,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
|
|
@ -190,7 +191,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 500,
|
||||
|
|
@ -224,7 +225,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
|
|
@ -232,7 +233,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { Page } from '@playwright/test';
|
|||
import { expect, test } from '@playwright/test';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
|
@ -28,7 +29,7 @@ export const setupDocumentAndNavigateToSubjectStep = async (page: Page) => {
|
|||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
|
|
@ -108,7 +109,9 @@ test.describe('AutoSave Subject Step', () => {
|
|||
// Toggle some email settings checkboxes (randomly - some checked, some unchecked)
|
||||
await page.getByText('Email the owner when a recipient signs').click();
|
||||
await page.getByText("Email recipients when they're removed from a pending document").click();
|
||||
await page.getByText('Email recipients when the document is completed', { exact: true }).click();
|
||||
await page
|
||||
.getByText('Email recipients when the document is completed', { exact: true })
|
||||
.click();
|
||||
await page.getByText('Email recipients when a pending document is deleted').click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
|
@ -139,16 +142,20 @@ test.describe('AutoSave Subject Step', () => {
|
|||
).toBeChecked({
|
||||
checked: emailSettings?.documentCompleted,
|
||||
});
|
||||
await expect(page.getByText('Email recipients when a pending document is deleted')).toBeChecked({
|
||||
await expect(
|
||||
page.getByText('Email recipients when a pending document is deleted'),
|
||||
).toBeChecked({
|
||||
checked: emailSettings?.documentDeleted,
|
||||
});
|
||||
|
||||
await expect(page.getByText('Email recipients with a signing request')).toBeChecked({
|
||||
checked: emailSettings?.recipientSigningRequest,
|
||||
});
|
||||
await expect(page.getByText('Email the signer if the document is still pending')).toBeChecked({
|
||||
checked: emailSettings?.documentPending,
|
||||
});
|
||||
await expect(page.getByText('Email the signer if the document is still pending')).toBeChecked(
|
||||
{
|
||||
checked: emailSettings?.documentPending,
|
||||
},
|
||||
);
|
||||
await expect(page.getByText('Email the owner when the document is completed')).toBeChecked({
|
||||
checked: emailSettings?.ownerDocumentCompleted,
|
||||
});
|
||||
|
|
@ -167,7 +174,9 @@ test.describe('AutoSave Subject Step', () => {
|
|||
|
||||
await page.getByText('Email the owner when a recipient signs').click();
|
||||
await page.getByText("Email recipients when they're removed from a pending document").click();
|
||||
await page.getByText('Email recipients when the document is completed', { exact: true }).click();
|
||||
await page
|
||||
.getByText('Email recipients when the document is completed', { exact: true })
|
||||
.click();
|
||||
await page.getByText('Email recipients when a pending document is deleted').click();
|
||||
|
||||
await triggerAutosave(page);
|
||||
|
|
@ -207,16 +216,20 @@ test.describe('AutoSave Subject Step', () => {
|
|||
).toBeChecked({
|
||||
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentCompleted,
|
||||
});
|
||||
await expect(page.getByText('Email recipients when a pending document is deleted')).toBeChecked({
|
||||
await expect(
|
||||
page.getByText('Email recipients when a pending document is deleted'),
|
||||
).toBeChecked({
|
||||
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentDeleted,
|
||||
});
|
||||
|
||||
await expect(page.getByText('Email recipients with a signing request')).toBeChecked({
|
||||
checked: retrievedDocumentData.documentMeta?.emailSettings?.recipientSigningRequest,
|
||||
});
|
||||
await expect(page.getByText('Email the signer if the document is still pending')).toBeChecked({
|
||||
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentPending,
|
||||
});
|
||||
await expect(page.getByText('Email the signer if the document is still pending')).toBeChecked(
|
||||
{
|
||||
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentPending,
|
||||
},
|
||||
);
|
||||
await expect(page.getByText('Email the owner when the document is completed')).toBeChecked({
|
||||
checked: retrievedDocumentData.documentMeta?.emailSettings?.ownerDocumentCompleted,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
|
|
@ -33,14 +34,14 @@ test('[DOCUMENT_FLOW]: Simple duplicate recipients test', async ({ page }) => {
|
|||
|
||||
// Step 3: Add fields
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
|
||||
|
||||
await page.getByRole('combobox').first().click();
|
||||
|
||||
// Switch to second duplicate and add field
|
||||
await page.getByText('Duplicate 2 (duplicate@example.com)').first().click();
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
|
||||
|
||||
// Continue to send
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
|
|
|||
|
|
@ -44,21 +44,21 @@ const completeDocumentFlowWithDuplicateRecipients = async (options: {
|
|||
// Step 3: Add fields for each recipient
|
||||
// Add signature field for first duplicate recipient
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
|
||||
|
||||
await page.getByText('Duplicate Recipient 1 (duplicate@example.com)').click();
|
||||
|
||||
// Switch to second duplicate recipient and add their field
|
||||
await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click();
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
|
||||
|
||||
await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click();
|
||||
|
||||
// Switch to unique recipient and add their field
|
||||
await page.getByText('Unique Recipient (unique@example.com)').click();
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 300, y: 100 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 300, y: 100 } });
|
||||
|
||||
// Continue to subject
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
|
@ -122,7 +122,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
|
|||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
|
||||
|
||||
// Save the document by going to subject
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
|
@ -149,7 +149,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
|
|||
await page.getByText('Test Recipient Duplicate (test@example.com)').first().click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
|
||||
|
||||
// Complete the flow
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
|
@ -270,24 +270,24 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
|
|||
|
||||
// Add signature for first recipient
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
|
||||
|
||||
// Add name field for second recipient
|
||||
await page.getByRole('combobox').first().click();
|
||||
|
||||
await page.getByText('Approver Role (signer@example.com)').first().click();
|
||||
await page.getByRole('button', { name: 'Name' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
|
||||
|
||||
// Add date field for second recipient
|
||||
await page.getByRole('button', { name: 'Date' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 150 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 150 } });
|
||||
|
||||
// If second recipient is still a SIGNER (role change wasn't available),
|
||||
// add a signature field for them to pass validation
|
||||
if (!secondRecipientIsApprover) {
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 200 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 200 } });
|
||||
}
|
||||
|
||||
// Complete the document
|
||||
|
|
@ -349,7 +349,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
|
|||
|
||||
// Add another field to the second duplicate
|
||||
await page.getByRole('button', { name: 'Name' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 250, y: 150 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 250, y: 150 } });
|
||||
|
||||
// Save changes
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
import { DateTime } from 'luxon';
|
||||
import path from 'node:path';
|
||||
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import {
|
||||
seedBlankDocument,
|
||||
|
|
@ -92,7 +93,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) =>
|
|||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
|
|
@ -100,7 +101,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) =>
|
|||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Email' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
|
|
@ -158,7 +159,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
|||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
|
|
@ -166,7 +167,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
|||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Email' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
|
|
@ -177,7 +178,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
|||
await page.getByText('User 2 (user2@example.com)').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 500,
|
||||
y: 100,
|
||||
|
|
@ -185,7 +186,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
|||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Email' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 500,
|
||||
y: 200,
|
||||
|
|
@ -256,7 +257,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
|||
await page.getByRole('option', { name: 'User 1 (user1@example.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
|
|
@ -264,7 +265,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
|||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Email' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
|
|
@ -275,7 +276,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
|||
await page.getByRole('option', { name: 'User 3 (user3@example.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 500,
|
||||
y: 100,
|
||||
|
|
@ -283,7 +284,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
|||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Email' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 500,
|
||||
y: 200,
|
||||
|
|
@ -576,7 +577,7 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
|
|||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100 * i,
|
||||
|
|
|
|||
416
packages/app-tests/e2e/pdf-viewer/pdf-viewer.spec.ts
Normal file
416
packages/app-tests/e2e/pdf-viewer/pdf-viewer.spec.ts
Normal file
|
|
@ -0,0 +1,416 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
import { FieldType } from '@prisma/client';
|
||||
import path from 'node:path';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/create-embedding-presign-token';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { prefixedId } from '@documenso/lib/universal/id';
|
||||
import {
|
||||
mapSecondaryIdToDocumentId,
|
||||
mapSecondaryIdToTemplateId,
|
||||
} from '@documenso/lib/utils/envelope';
|
||||
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import {
|
||||
seedBlankDocument,
|
||||
seedCompletedDocument,
|
||||
seedPendingDocumentWithFullFields,
|
||||
} from '@documenso/prisma/seed/documents';
|
||||
import { seedBlankTemplate, seedDirectTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
const PDF_PAGE_SELECTOR = 'img[data-page-number]';
|
||||
|
||||
async function addSecondEnvelopeItem(envelopeId: string) {
|
||||
const firstItem = await prisma.envelopeItem.findFirstOrThrow({
|
||||
where: { envelopeId },
|
||||
orderBy: { order: 'asc' },
|
||||
include: { documentData: true },
|
||||
});
|
||||
|
||||
const newDocumentData = await prisma.documentData.create({
|
||||
data: {
|
||||
type: firstItem.documentData.type,
|
||||
data: firstItem.documentData.data,
|
||||
initialData: firstItem.documentData.initialData,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.envelopeItem.create({
|
||||
data: {
|
||||
id: prefixedId('envelope_item'),
|
||||
title: `${firstItem.title} - Page 2`,
|
||||
documentDataId: newDocumentData.id,
|
||||
order: 2,
|
||||
envelopeId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('PDF Viewer Rendering', () => {
|
||||
test.describe('Authenticated Pages', () => {
|
||||
test('should render PDF on all authenticated pages (V1 and V2)', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const documentV1 = await seedBlankDocument(user, team.id);
|
||||
const documentV2 = await seedBlankDocument(user, team.id, { internalVersion: 2 });
|
||||
await addSecondEnvelopeItem(documentV2.id);
|
||||
|
||||
const templateV1 = await seedBlankTemplate(user, team.id);
|
||||
const templateV2 = await seedBlankTemplate(user, team.id, {
|
||||
createTemplateOptions: { internalVersion: 2 },
|
||||
});
|
||||
await addSecondEnvelopeItem(templateV2.id);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents/${documentV1.id}`,
|
||||
});
|
||||
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.goto(`/t/${team.url}/documents/${documentV2.id}`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('button', { name: /Page 2/ }).click();
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.goto(`/t/${team.url}/templates/${templateV1.id}`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.goto(`/t/${team.url}/templates/${templateV2.id}`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('button', { name: /Page 2/ }).click();
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.goto(`/t/${team.url}/documents/${documentV1.id}/edit`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.goto(`/t/${team.url}/documents/${documentV2.id}/edit?step=addFields`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('button', { name: /Page 2/ }).click();
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.goto(`/t/${team.url}/templates/${templateV1.id}/edit`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.goto(`/t/${team.url}/templates/${templateV2.id}/edit?step=addFields`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('button', { name: /Page 2/ }).click();
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.goto(`/t/${team.url}/documents/${documentV1.id}`);
|
||||
await page.locator(PDF_PAGE_SELECTOR).first().waitFor({ state: 'visible', timeout: 30_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Recipient Signing', () => {
|
||||
test('should render PDF on signing page (V1 and V2)', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { recipients: recipientsV1 } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: ['signer-v1@test.documenso.com'],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
});
|
||||
|
||||
const { document: documentV2, recipients: recipientsV2 } =
|
||||
await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: ['signer-v2@test.documenso.com'],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
updateDocumentOptions: { internalVersion: 2 },
|
||||
});
|
||||
await addSecondEnvelopeItem(documentV2.id);
|
||||
|
||||
await page.goto(`/sign/${recipientsV1[0].token}`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.goto(`/sign/${recipientsV2[0].token}`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('button', { name: /Page 2/ }).click();
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Direct Template', () => {
|
||||
test('should render PDF on direct template page (V1 and V2)', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const templateV1 = await seedDirectTemplate({
|
||||
title: 'PDF Viewer Test Template V1',
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const templateV2 = await seedDirectTemplate({
|
||||
title: 'PDF Viewer Test Template V2',
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
internalVersion: 2,
|
||||
});
|
||||
await addSecondEnvelopeItem(templateV2.id);
|
||||
|
||||
await page.goto(formatDirectTemplatePath(templateV1.directLink?.token || ''));
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.goto(formatDirectTemplatePath(templateV2.directLink?.token || ''));
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('button', { name: /Page 2/ }).click();
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Share Page', () => {
|
||||
test('should render PDF on share page (V1 and V2)', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const qrTokenV1 = prefixedId('qr');
|
||||
const qrTokenV2 = prefixedId('qr');
|
||||
|
||||
const documentV1 = await seedCompletedDocument(
|
||||
user,
|
||||
team.id,
|
||||
['share-v1@test.documenso.com'],
|
||||
{
|
||||
createDocumentOptions: { qrToken: qrTokenV1 },
|
||||
},
|
||||
);
|
||||
|
||||
const documentV2 = await seedCompletedDocument(
|
||||
user,
|
||||
team.id,
|
||||
['share-v2@test.documenso.com'],
|
||||
{
|
||||
createDocumentOptions: { qrToken: qrTokenV2 },
|
||||
internalVersion: 2,
|
||||
},
|
||||
);
|
||||
await addSecondEnvelopeItem(documentV2.id);
|
||||
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/share/${qrTokenV1}`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/share/${qrTokenV2}`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.getByRole('button', { name: /Page 2/ }).click();
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Embed Pages', () => {
|
||||
test('should render PDF on embed sign page (V1 and V2)', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { recipients: recipientsV1 } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: ['embed-signer-v1@test.documenso.com'],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
});
|
||||
|
||||
const { document: documentV2, recipients: recipientsV2 } =
|
||||
await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: ['embed-signer-v2@test.documenso.com'],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
updateDocumentOptions: { internalVersion: 2 },
|
||||
});
|
||||
await addSecondEnvelopeItem(documentV2.id);
|
||||
|
||||
await page.goto(`/embed/sign/${recipientsV1[0].token}`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.goto(`/embed/sign/${recipientsV2[0].token}`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Todo: Multisign does not support multiple envelope items.
|
||||
// await page.getByRole('button', { name: /Page 2/ }).click();
|
||||
// await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
test('should render PDF on embed direct template page (V1 and V2)', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const templateV1 = await seedDirectTemplate({
|
||||
title: 'Embed Direct Template V1',
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const templateV2 = await seedDirectTemplate({
|
||||
title: 'Embed Direct Template V2',
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
internalVersion: 2,
|
||||
});
|
||||
await addSecondEnvelopeItem(templateV2.id);
|
||||
|
||||
await page.goto(`/embed/direct/${templateV1.directLink?.token || ''}`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.goto(`/embed/direct/${templateV2.directLink?.token || ''}`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('button', { name: /Page 2/ }).click();
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
test('should render PDF on embed multisign page (V1 and V2)', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { recipients: recipientsV1 } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: ['multisign-v1@test.documenso.com'],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
});
|
||||
|
||||
const { document: documentV2, recipients: recipientsV2 } =
|
||||
await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: ['multisign-v2@test.documenso.com'],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
updateDocumentOptions: { internalVersion: 2 },
|
||||
});
|
||||
await addSecondEnvelopeItem(documentV2.id);
|
||||
|
||||
await page.goto(`/embed/v1/multisign?token=${recipientsV1[0].token}`);
|
||||
await expect(page.getByText('Sign Documents')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Todo: Multisign does not support multiple envelope items.
|
||||
// await page.getByRole('button', { name: /View/i }).first().click();
|
||||
// await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.goto(`/embed/v1/multisign?token=${recipientsV2[0].token}`);
|
||||
await expect(page.getByText('Sign Documents')).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByRole('button', { name: /View/i }).first().click();
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Todo: Multisign does not support multiple envelope items.
|
||||
// await page.getByRole('button', { name: /Page 2/ }).click();
|
||||
// await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
test('should render PDF on embed authoring document create page', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { token: apiToken } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'pdf-viewer-test',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const { token: presignToken } = await createEmbeddingPresignToken({
|
||||
apiToken,
|
||||
});
|
||||
|
||||
const embedParams = { darkModeDisabled: false, features: {} };
|
||||
const hash = btoa(encodeURIComponent(JSON.stringify(embedParams)));
|
||||
|
||||
await page.goto(
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/embed/v1/authoring/document/create?token=${presignToken}#${hash}`,
|
||||
);
|
||||
|
||||
await expect(page.getByText('Configure Document')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const titleInput = page.getByLabel('Title');
|
||||
await titleInput.click();
|
||||
await titleInput.fill('PDF Viewer E2E Test');
|
||||
|
||||
const emailInput = page.getByPlaceholder('Email').first();
|
||||
await emailInput.click();
|
||||
await emailInput.fill('test-signer@documenso.com');
|
||||
|
||||
const [fileChooser] = await Promise.all([
|
||||
page.waitForEvent('filechooser'),
|
||||
page
|
||||
.locator('input[type=file]')
|
||||
.first()
|
||||
.evaluate((el) => {
|
||||
if (el instanceof HTMLInputElement) {
|
||||
el.click();
|
||||
}
|
||||
}),
|
||||
]);
|
||||
|
||||
await fileChooser.setFiles(path.join(__dirname, '../../../../assets/example.pdf'));
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
test('should render PDF on embed document edit page', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const document = await seedBlankDocument(user, team.id);
|
||||
const documentId = mapSecondaryIdToDocumentId(document.secondaryId);
|
||||
|
||||
const { token: apiToken } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'pdf-viewer-doc-edit-test',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const { token: presignToken } = await createEmbeddingPresignToken({
|
||||
apiToken,
|
||||
scope: `documentId:${documentId}`,
|
||||
});
|
||||
|
||||
const embedParams = {
|
||||
darkModeDisabled: false,
|
||||
features: {},
|
||||
onlyEditFields: true,
|
||||
};
|
||||
const hash = btoa(encodeURIComponent(JSON.stringify(embedParams)));
|
||||
|
||||
await page.goto(
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/embed/v1/authoring/document/edit/${documentId}?token=${presignToken}#${hash}`,
|
||||
);
|
||||
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
test('should render PDF on embed template edit page', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const template = await seedBlankTemplate(user, team.id);
|
||||
const templateId = mapSecondaryIdToTemplateId(template.secondaryId);
|
||||
|
||||
const { token: apiToken } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'pdf-viewer-template-edit-test',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const { token: presignToken } = await createEmbeddingPresignToken({
|
||||
apiToken,
|
||||
scope: `templateId:${templateId}`,
|
||||
});
|
||||
|
||||
const embedParams = {
|
||||
darkModeDisabled: false,
|
||||
features: {},
|
||||
onlyEditFields: true,
|
||||
};
|
||||
const hash = btoa(encodeURIComponent(JSON.stringify(embedParams)));
|
||||
|
||||
await page.goto(
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/embed/v1/authoring/template/edit/${templateId}?token=${presignToken}#${hash}`,
|
||||
);
|
||||
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -42,21 +42,21 @@ const completeTemplateFlowWithDuplicateRecipients = async (options: {
|
|||
// Step 3: Add fields for each recipient instance
|
||||
// Add signature field for first instance
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
|
||||
|
||||
// Switch to second instance and add their field
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByText('Second Instance').first().click();
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
|
||||
|
||||
// Switch to different recipient and add their fields
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByText('Different Recipient').first().click();
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 300, y: 100 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 300, y: 100 } });
|
||||
await page.getByRole('button', { name: 'Name' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 300, y: 150 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 300, y: 150 } });
|
||||
|
||||
// Save template
|
||||
await page.getByRole('button', { name: 'Save Template' }).click();
|
||||
|
|
@ -209,17 +209,17 @@ test.describe('[TEMPLATE_FLOW]: Duplicate Recipients', () => {
|
|||
|
||||
// Add fields for each recipient
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
|
||||
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByText('Duplicate Recipient 2').first().click();
|
||||
await page.getByRole('button', { name: 'Date' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
|
||||
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByText('Different Recipient').first().click();
|
||||
await page.getByRole('button', { name: 'Name' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 200 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 200 } });
|
||||
|
||||
// Save template
|
||||
await page.getByRole('button', { name: 'Save Template' }).click();
|
||||
|
|
@ -272,7 +272,7 @@ test.describe('[TEMPLATE_FLOW]: Duplicate Recipients', () => {
|
|||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: 'First Instance' }).first().click();
|
||||
await page.getByRole('button', { name: 'Name' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 300 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 300 } });
|
||||
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
|
|
@ -47,7 +48,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
|
|
@ -55,7 +56,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
|
|
@ -75,7 +76,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 500,
|
||||
|
|
@ -110,7 +111,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
|
|
@ -118,7 +119,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
|
|
@ -138,7 +139,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 500,
|
||||
|
|
@ -179,7 +180,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
|
|
@ -187,7 +188,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
|
|
@ -207,7 +208,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 500,
|
||||
|
|
@ -250,7 +251,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
|
|
@ -258,7 +259,7 @@ test.describe('AutoSave Fields Step', () => {
|
|||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export const getBoundingClientRect = (element: HTMLElement) => {
|
||||
export const getBoundingClientRect = (element: HTMLElement | Element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
|
||||
const { width, height } = rect;
|
||||
|
|
|
|||
|
|
@ -14,7 +14,10 @@ export const useDocumentElement = () => {
|
|||
const target = event.target;
|
||||
|
||||
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) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { FieldType } from '@prisma/client';
|
|||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getPdfPagesCount } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
|
||||
|
|
@ -222,14 +223,16 @@ export const useEditorFields = ({
|
|||
|
||||
const duplicateFieldToAllPages = useCallback(
|
||||
(field: TLocalField): TLocalField[] => {
|
||||
const pages = Array.from(document.querySelectorAll('[data-page-number]'));
|
||||
const totalPages = getPdfPagesCount();
|
||||
const newFields: TLocalField[] = [];
|
||||
|
||||
pages.forEach((_, index) => {
|
||||
const pageNumber = index + 1;
|
||||
if (totalPages < 1) {
|
||||
return newFields;
|
||||
}
|
||||
|
||||
for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) {
|
||||
if (pageNumber === field.page) {
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
|
||||
const newField: TLocalField = {
|
||||
|
|
@ -241,7 +244,7 @@ export const useEditorFields = ({
|
|||
|
||||
append(newField);
|
||||
newFields.push(newField);
|
||||
});
|
||||
}
|
||||
|
||||
triggerFieldsUpdate();
|
||||
return newFields;
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export const useElementBounds = (elementOrSelector: HTMLElement | string, withSc
|
|||
: elementOrSelector;
|
||||
|
||||
if (!$el) {
|
||||
throw new Error('Element not found');
|
||||
return { top: 0, left: 0, width: 0, height: 0 };
|
||||
}
|
||||
|
||||
if (withScroll) {
|
||||
|
|
@ -36,7 +36,7 @@ export const useElementBounds = (elementOrSelector: HTMLElement | string, withSc
|
|||
|
||||
useEffect(() => {
|
||||
setBounds(calculateBounds());
|
||||
}, []);
|
||||
}, [calculateBounds]);
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
|
|
@ -48,7 +48,7 @@ export const useElementBounds = (elementOrSelector: HTMLElement | string, withSc
|
|||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
};
|
||||
}, []);
|
||||
}, [calculateBounds]);
|
||||
|
||||
useEffect(() => {
|
||||
const $el =
|
||||
|
|
@ -69,7 +69,7 @@ export const useElementBounds = (elementOrSelector: HTMLElement | string, withSc
|
|||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
}, [elementOrSelector, calculateBounds]);
|
||||
|
||||
return bounds;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@ import { useCallback, useEffect, useState } from 'react';
|
|||
import type { Field } from '@prisma/client';
|
||||
|
||||
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import {
|
||||
PDF_VIEWER_CONTENT_SELECTOR,
|
||||
PDF_VIEWER_PAGE_SELECTOR,
|
||||
} from '@documenso/lib/constants/pdf-viewer';
|
||||
|
||||
export const useFieldPageCoords = (
|
||||
field: Pick<Field, 'positionX' | 'positionY' | 'width' | 'height' | 'page'>,
|
||||
|
|
@ -57,23 +60,65 @@ export const useFieldPageCoords = (
|
|||
};
|
||||
}, [calculateCoords]);
|
||||
|
||||
// Watch for the page element to appear in the DOM (e.g. after a virtual list
|
||||
// scroll) and recalculate coords. Also attach a ResizeObserver once the page
|
||||
// element exists.
|
||||
useEffect(() => {
|
||||
const $page = document.querySelector<HTMLElement>(
|
||||
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`,
|
||||
);
|
||||
const pageSelector = `${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`;
|
||||
|
||||
if (!$page) {
|
||||
return;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let observedElement: HTMLElement | null = null;
|
||||
|
||||
const attachResizeObserver = ($page: HTMLElement) => {
|
||||
if ($page === observedElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
calculateCoords();
|
||||
});
|
||||
resizeObserver.observe($page);
|
||||
observedElement = $page;
|
||||
};
|
||||
|
||||
// Try to attach immediately if the page already exists.
|
||||
const existingPage = document.querySelector<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).
|
||||
// Scope to the PDF viewer content container to avoid firing on unrelated
|
||||
// DOM changes elsewhere in the document.
|
||||
const mutationObserver = new MutationObserver(() => {
|
||||
const $page = document.querySelector<HTMLElement>(pageSelector);
|
||||
|
||||
if (!$page) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($page === observedElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
calculateCoords();
|
||||
attachResizeObserver($page);
|
||||
});
|
||||
|
||||
observer.observe($page);
|
||||
const $container = document.querySelector(PDF_VIEWER_CONTENT_SELECTOR) ?? document.body;
|
||||
|
||||
mutationObserver.observe($container, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
mutationObserver.disconnect();
|
||||
resizeObserver?.disconnect();
|
||||
observedElement = null;
|
||||
};
|
||||
}, [calculateCoords, field.page]);
|
||||
|
||||
|
|
|
|||
39
packages/lib/client-only/hooks/use-is-page-in-dom.ts
Normal file
39
packages/lib/client-only/hooks/use-is-page-in-dom.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
PDF_VIEWER_CONTENT_SELECTOR,
|
||||
PDF_VIEWER_PAGE_SELECTOR,
|
||||
} from '@documenso/lib/constants/pdf-viewer';
|
||||
|
||||
/**
|
||||
* Returns whether the PDF page element for the given page number is currently
|
||||
* present in the DOM. With virtual list rendering only pages near the viewport
|
||||
* are mounted, so this hook lets consumers skip rendering when their page is
|
||||
* virtualised away.
|
||||
*/
|
||||
export const useIsPageInDom = (pageNumber: number) => {
|
||||
const [isPageInDom, setIsPageInDom] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const selector = `${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${pageNumber}"]`;
|
||||
|
||||
setIsPageInDom(document.querySelector(selector) !== null);
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
setIsPageInDom(document.querySelector(selector) !== null);
|
||||
});
|
||||
|
||||
const $container = document.querySelector(PDF_VIEWER_CONTENT_SELECTOR) ?? document.body;
|
||||
|
||||
observer.observe($container, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [pageNumber]);
|
||||
|
||||
return isPageInDom;
|
||||
};
|
||||
|
|
@ -1,135 +1,85 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import Konva from 'konva';
|
||||
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
|
||||
import { usePageContext } from 'react-pdf';
|
||||
|
||||
import { type PageRenderData } from '../providers/envelope-render-provider';
|
||||
|
||||
type RenderFunction = (props: { stage: Konva.Stage; pageLayer: Konva.Layer }) => void;
|
||||
|
||||
export function usePageRenderer(renderFunction: RenderFunction) {
|
||||
const pageContext = usePageContext();
|
||||
export const usePageRenderer = (renderFunction: RenderFunction, pageData: PageRenderData) => {
|
||||
const { pageWidth, pageHeight, scale, imageLoadingState } = pageData;
|
||||
|
||||
if (!pageContext) {
|
||||
throw new Error('Unable to find Page context.');
|
||||
}
|
||||
|
||||
const { page, rotate, scale } = pageContext;
|
||||
|
||||
if (!page) {
|
||||
throw new Error('Attempted to render page canvas, but no page was specified.');
|
||||
}
|
||||
|
||||
const canvasElement = useRef<HTMLCanvasElement>(null);
|
||||
const konvaContainer = useRef<HTMLDivElement>(null);
|
||||
|
||||
const stage = useRef<Konva.Stage | null>(null);
|
||||
const pageLayer = useRef<Konva.Layer | null>(null);
|
||||
|
||||
const [renderError, setRenderError] = useState<boolean>(false);
|
||||
|
||||
/**
|
||||
* The raw viewport with no scaling. Basically the actual PDF size.
|
||||
*/
|
||||
const unscaledViewport = useMemo(
|
||||
() => page.getViewport({ scale: 1, rotation: rotate }),
|
||||
[page, rotate, scale],
|
||||
() => ({
|
||||
scale: 1,
|
||||
width: pageWidth,
|
||||
height: pageHeight,
|
||||
}),
|
||||
[pageWidth, pageHeight],
|
||||
);
|
||||
|
||||
/**
|
||||
* The viewport scaled according to page width.
|
||||
*/
|
||||
const scaledViewport = useMemo(
|
||||
() => page.getViewport({ scale, rotation: rotate }),
|
||||
[page, rotate, scale],
|
||||
() => ({
|
||||
scale,
|
||||
width: pageWidth * scale,
|
||||
height: pageHeight * scale,
|
||||
}),
|
||||
[pageWidth, pageHeight, scale],
|
||||
);
|
||||
|
||||
/**
|
||||
* Viewport with the device pixel ratio applied so we can render the PDF
|
||||
* in a higher resolution.
|
||||
*/
|
||||
const renderViewport = useMemo(
|
||||
() => page.getViewport({ scale: scale * window.devicePixelRatio, rotation: rotate }),
|
||||
[page, rotate, scale],
|
||||
);
|
||||
useEffect(() => {
|
||||
const { current: container } = konvaContainer;
|
||||
|
||||
/**
|
||||
* Render the PDF and create the scaled Konva stage.
|
||||
*/
|
||||
useEffect(
|
||||
function drawPageOnCanvas() {
|
||||
if (!page) {
|
||||
return;
|
||||
}
|
||||
if (!container || imageLoadingState !== 'loaded') {
|
||||
return;
|
||||
}
|
||||
|
||||
const { current: canvas } = canvasElement;
|
||||
const { current: kContainer } = konvaContainer;
|
||||
stage.current = new Konva.Stage({
|
||||
container,
|
||||
width: scaledViewport.width,
|
||||
height: scaledViewport.height,
|
||||
scale: {
|
||||
x: scale,
|
||||
y: scale,
|
||||
},
|
||||
});
|
||||
|
||||
if (!canvas || !kContainer) {
|
||||
return;
|
||||
}
|
||||
// Create the main layer for interactive elements.
|
||||
pageLayer.current = new Konva.Layer();
|
||||
|
||||
canvas.width = renderViewport.width;
|
||||
canvas.height = renderViewport.height;
|
||||
stage.current.add(pageLayer.current);
|
||||
|
||||
canvas.style.width = `${Math.floor(scaledViewport.width)}px`;
|
||||
canvas.style.height = `${Math.floor(scaledViewport.height)}px`;
|
||||
renderFunction({
|
||||
stage: stage.current,
|
||||
pageLayer: pageLayer.current,
|
||||
});
|
||||
|
||||
const renderContext: RenderParameters = {
|
||||
canvas,
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
|
||||
viewport: renderViewport,
|
||||
};
|
||||
void document.fonts.ready.then(function () {
|
||||
pageLayer.current?.batchDraw();
|
||||
});
|
||||
|
||||
const cancellable = page.render(renderContext);
|
||||
const runningTask = cancellable;
|
||||
|
||||
cancellable.promise.catch(() => {
|
||||
// Intentionally empty
|
||||
});
|
||||
|
||||
void cancellable.promise.then(() => {
|
||||
stage.current = new Konva.Stage({
|
||||
container: kContainer,
|
||||
width: scaledViewport.width,
|
||||
height: scaledViewport.height,
|
||||
scale: {
|
||||
x: scale,
|
||||
y: scale,
|
||||
},
|
||||
});
|
||||
|
||||
// Create the main layer for interactive elements.
|
||||
pageLayer.current = new Konva.Layer();
|
||||
|
||||
stage.current.add(pageLayer.current);
|
||||
|
||||
renderFunction({
|
||||
stage: stage.current,
|
||||
pageLayer: pageLayer.current,
|
||||
});
|
||||
|
||||
void document.fonts.ready.then(function () {
|
||||
pageLayer.current?.batchDraw();
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
runningTask.cancel();
|
||||
};
|
||||
},
|
||||
[page, scaledViewport],
|
||||
);
|
||||
return () => {
|
||||
stage.current?.destroy();
|
||||
stage.current = null;
|
||||
};
|
||||
}, [imageLoadingState, scaledViewport]);
|
||||
|
||||
return {
|
||||
canvasElement,
|
||||
konvaContainer,
|
||||
stage,
|
||||
pageLayer,
|
||||
unscaledViewport,
|
||||
scaledViewport,
|
||||
pageContext,
|
||||
renderError,
|
||||
setRenderError,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,23 +1,26 @@
|
|||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import type { Field, Recipient } from '@prisma/client';
|
||||
import { type Field, type Recipient } from '@prisma/client';
|
||||
|
||||
import type { DocumentDataVersion } from '@documenso/lib/types/document';
|
||||
import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download';
|
||||
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
|
||||
import { AVAILABLE_RECIPIENT_COLORS } from '@documenso/ui/lib/recipient-colors';
|
||||
|
||||
import type { TEnvelope } from '../../types/envelope';
|
||||
import type { FieldRenderMode } from '../../universal/field-renderer/render-field';
|
||||
import { getEnvelopeItemPdfUrl } from '../../utils/envelope-download';
|
||||
|
||||
type FileData =
|
||||
| {
|
||||
status: 'loading' | 'error';
|
||||
}
|
||||
| {
|
||||
file: Uint8Array;
|
||||
status: 'loaded';
|
||||
};
|
||||
export type PageRenderData = {
|
||||
scale: number;
|
||||
pageIndex: number;
|
||||
pageNumber: number;
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
imageLoadingState: ImageLoadingState;
|
||||
};
|
||||
|
||||
export type ImageLoadingState = 'loading' | 'loaded' | 'error';
|
||||
|
||||
type EnvelopeRenderOverrideSettings = {
|
||||
mode?: FieldRenderMode;
|
||||
|
|
@ -25,10 +28,22 @@ type EnvelopeRenderOverrideSettings = {
|
|||
showRecipientSigningStatus?: boolean;
|
||||
};
|
||||
|
||||
type EnvelopeRenderItem = TEnvelope['envelopeItems'][number];
|
||||
type EnvelopeRenderItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
order: number;
|
||||
envelopeId: string;
|
||||
|
||||
/**
|
||||
* The PDF data to render.
|
||||
*
|
||||
* If it's a string we assume it's a URL to the PDF file.
|
||||
*/
|
||||
data: Uint8Array | string;
|
||||
};
|
||||
|
||||
type EnvelopeRenderProviderValue = {
|
||||
getPdfBuffer: (envelopeItemId: string) => FileData | null;
|
||||
version: DocumentDataVersion;
|
||||
envelopeItems: EnvelopeRenderItem[];
|
||||
envelopeStatus: TEnvelope['status'];
|
||||
envelopeType: TEnvelope['type'];
|
||||
|
|
@ -46,7 +61,26 @@ type EnvelopeRenderProviderValue = {
|
|||
interface EnvelopeRenderProviderProps {
|
||||
children: React.ReactNode;
|
||||
|
||||
envelope: Pick<TEnvelope, 'envelopeItems' | 'status' | 'type'>;
|
||||
/**
|
||||
* The envelope item version to render.
|
||||
*/
|
||||
version: DocumentDataVersion;
|
||||
|
||||
envelope: Pick<TEnvelope, 'id' | 'status' | 'type'>;
|
||||
|
||||
/**
|
||||
* The envelope items to render.
|
||||
*
|
||||
* If data is optional then we build the URL based of the IDs.
|
||||
*/
|
||||
envelopeItems: {
|
||||
id: string;
|
||||
title: string;
|
||||
order: number;
|
||||
envelopeId: string;
|
||||
documentDataId: string;
|
||||
data?: Uint8Array | string;
|
||||
}[];
|
||||
|
||||
/**
|
||||
* Optional fields which are passed down to renderers for custom rendering needs.
|
||||
|
|
@ -70,6 +104,13 @@ interface EnvelopeRenderProviderProps {
|
|||
*/
|
||||
token: string | undefined;
|
||||
|
||||
/**
|
||||
* The presign token to access the envelope.
|
||||
*
|
||||
* If not provided, it will be assumed that the current user can access the document.
|
||||
*/
|
||||
presignToken?: string | undefined;
|
||||
|
||||
/**
|
||||
* Custom override settings for generic page renderers.
|
||||
*/
|
||||
|
|
@ -89,81 +130,51 @@ export const useCurrentEnvelopeRender = () => {
|
|||
};
|
||||
|
||||
/**
|
||||
* Manages fetching and storing PDF files to render on the client.
|
||||
* Manages fetching the data required to render an envelope and it's items.
|
||||
*/
|
||||
export const EnvelopeRenderProvider = ({
|
||||
children,
|
||||
envelope,
|
||||
envelopeItems: envelopeItemsFromProps,
|
||||
fields,
|
||||
token,
|
||||
presignToken,
|
||||
recipients = [],
|
||||
version,
|
||||
overrideSettings,
|
||||
}: EnvelopeRenderProviderProps) => {
|
||||
// Indexed by documentDataId.
|
||||
const [files, setFiles] = useState<Record<string, FileData>>({});
|
||||
|
||||
const [currentItem, setCurrentItem] = useState<EnvelopeRenderItem | null>(null);
|
||||
|
||||
const [renderError, setRenderError] = useState<boolean>(false);
|
||||
|
||||
const envelopeItems = useMemo(
|
||||
() => envelope.envelopeItems.sort((a, b) => a.order - b.order),
|
||||
[envelope.envelopeItems],
|
||||
() =>
|
||||
[...envelopeItemsFromProps]
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((item) => {
|
||||
const pdfUrl = getDocumentDataUrl({
|
||||
envelopeId: envelope.id,
|
||||
envelopeItemId: item.id,
|
||||
documentDataId: item.documentDataId,
|
||||
version,
|
||||
token,
|
||||
presignToken,
|
||||
});
|
||||
|
||||
const data = item.data || pdfUrl;
|
||||
|
||||
return {
|
||||
...item,
|
||||
data,
|
||||
};
|
||||
}),
|
||||
[envelopeItemsFromProps, envelope.id, token, version, presignToken],
|
||||
);
|
||||
|
||||
const loadEnvelopeItemPdfFile = async (envelopeItem: EnvelopeRenderItem) => {
|
||||
if (files[envelopeItem.id]?.status === 'loading') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!files[envelopeItem.id]) {
|
||||
setFiles((prev) => ({
|
||||
...prev,
|
||||
[envelopeItem.id]: {
|
||||
status: 'loading',
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
try {
|
||||
const downloadUrl = getEnvelopeItemPdfUrl({
|
||||
type: 'view',
|
||||
envelopeItem: envelopeItem,
|
||||
token,
|
||||
});
|
||||
|
||||
const blob = await fetch(downloadUrl).then(async (res) => await res.blob());
|
||||
|
||||
const file = await blob.arrayBuffer();
|
||||
|
||||
setFiles((prev) => ({
|
||||
...prev,
|
||||
[envelopeItem.id]: {
|
||||
file: new Uint8Array(file),
|
||||
status: 'loaded',
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
setFiles((prev) => ({
|
||||
...prev,
|
||||
[envelopeItem.id]: {
|
||||
status: 'error',
|
||||
},
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const getPdfBuffer = useCallback(
|
||||
(envelopeItemId: string) => {
|
||||
return files[envelopeItemId] || null;
|
||||
},
|
||||
[files],
|
||||
const [currentItem, setCurrentItem] = useState<EnvelopeRenderItem | null>(
|
||||
envelopeItems[0] ?? null,
|
||||
);
|
||||
|
||||
const setCurrentEnvelopeItem = (envelopeItemId: string) => {
|
||||
const foundItem = envelope.envelopeItems.find((item) => item.id === envelopeItemId);
|
||||
const foundItem = envelopeItems.find((item) => item.id === envelopeItemId);
|
||||
|
||||
setCurrentItem(foundItem ?? null);
|
||||
};
|
||||
|
|
@ -179,15 +190,6 @@ export const EnvelopeRenderProvider = ({
|
|||
}
|
||||
}, [currentItem, envelopeItems]);
|
||||
|
||||
// Look for any missing pdf files and load them.
|
||||
useEffect(() => {
|
||||
const missingFiles = envelope.envelopeItems.filter((item) => !files[item.id]);
|
||||
|
||||
for (const item of missingFiles) {
|
||||
void loadEnvelopeItemPdfFile(item);
|
||||
}
|
||||
}, [envelope.envelopeItems]);
|
||||
|
||||
const recipientIds = useMemo(
|
||||
() => recipients.map((recipient) => recipient.id).sort(),
|
||||
[recipients],
|
||||
|
|
@ -207,7 +209,7 @@ export const EnvelopeRenderProvider = ({
|
|||
return (
|
||||
<EnvelopeRenderContext.Provider
|
||||
value={{
|
||||
getPdfBuffer,
|
||||
version,
|
||||
envelopeItems,
|
||||
envelopeStatus: envelope.status,
|
||||
envelopeType: envelope.type,
|
||||
|
|
|
|||
22
packages/lib/constants/pdf-viewer-i18n.ts
Normal file
22
packages/lib/constants/pdf-viewer-i18n.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
// This is separate from the pdf-viewer.ts constant file due to parsing issues during testing.
|
||||
export const PDF_VIEWER_ERROR_MESSAGES = {
|
||||
editor: {
|
||||
title: msg`Configuration Error`,
|
||||
description: msg`There was an issue rendering some fields, please review the fields and try again.`,
|
||||
},
|
||||
preview: {
|
||||
title: msg`Configuration Error`,
|
||||
description: msg`Something went wrong while rendering the document, some fields may be missing or corrupted.`,
|
||||
},
|
||||
signing: {
|
||||
title: msg`Configuration Error`,
|
||||
description: msg`Something went wrong while rendering the document, some fields may be missing or corrupted.`,
|
||||
},
|
||||
default: {
|
||||
title: msg`Configuration Error`,
|
||||
description: msg`Something went wrong while rendering the document, please try again or contact our support.`,
|
||||
},
|
||||
} satisfies Record<string, { title: MessageDescriptor; description: MessageDescriptor }>;
|
||||
|
|
@ -1,2 +1,19 @@
|
|||
export const PDF_VIEWER_CONTAINER_SELECTOR = '.react-pdf__Document';
|
||||
// Keep these two constants in sync.
|
||||
export const PDF_VIEWER_PAGE_SELECTOR = '.react-pdf__Page';
|
||||
export const PDF_VIEWER_PAGE_CLASSNAME = 'react-pdf__Page z-0';
|
||||
|
||||
export const PDF_VIEWER_CONTENT_SELECTOR = '[data-pdf-content]';
|
||||
|
||||
export const getPdfPagesCount = () => {
|
||||
const pageCountAttr = document
|
||||
.querySelector(PDF_VIEWER_CONTENT_SELECTOR)
|
||||
?.getAttribute('data-page-count');
|
||||
|
||||
const totalPages = Number(pageCountAttr);
|
||||
|
||||
if (!Number.isInteger(totalPages) || totalPages < 1) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return totalPages;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -285,18 +285,13 @@ export const run = async ({
|
|||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const { oldDocumentDataId, newDocumentDataId } of newDocumentData) {
|
||||
const newData = await tx.documentData.findFirstOrThrow({
|
||||
await tx.envelopeItem.update({
|
||||
where: {
|
||||
id: newDocumentDataId,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentData.update({
|
||||
where: {
|
||||
id: oldDocumentDataId,
|
||||
envelopeId: envelope.id,
|
||||
documentDataId: oldDocumentDataId,
|
||||
},
|
||||
data: {
|
||||
data: newData.data,
|
||||
documentDataId: newDocumentDataId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -496,11 +491,14 @@ const decorateAndSignPdf = async ({
|
|||
// Add suffix based on document status
|
||||
const suffix = isRejected ? '_rejected.pdf' : '_signed.pdf';
|
||||
|
||||
const newDocumentData = await putPdfFileServerSide({
|
||||
name: `${name}${suffix}`,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(pdfBytes),
|
||||
});
|
||||
const newDocumentData = await putPdfFileServerSide(
|
||||
{
|
||||
name: `${name}${suffix}`,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(pdfBytes),
|
||||
},
|
||||
envelopeItem.documentData.initialData,
|
||||
);
|
||||
|
||||
return {
|
||||
oldDocumentDataId: envelopeItem.documentData.id,
|
||||
|
|
|
|||
|
|
@ -55,7 +55,6 @@
|
|||
"posthog-js": "^1.297.2",
|
||||
"posthog-node": "4.18.0",
|
||||
"react": "^18",
|
||||
"react-pdf": "^10.3.0",
|
||||
"remeda": "^2.32.0",
|
||||
"sharp": "0.34.5",
|
||||
"skia-canvas": "^3.0.8",
|
||||
|
|
|
|||
|
|
@ -5,14 +5,25 @@ import { prisma } from '@documenso/prisma';
|
|||
export type CreateDocumentDataOptions = {
|
||||
type: DocumentDataType;
|
||||
data: string;
|
||||
|
||||
/**
|
||||
* The initial data that was used to create the document data.
|
||||
*
|
||||
* If not provided, the current data will be used.
|
||||
*/
|
||||
initialData?: string;
|
||||
};
|
||||
|
||||
export const createDocumentData = async ({ type, data }: CreateDocumentDataOptions) => {
|
||||
export const createDocumentData = async ({
|
||||
type,
|
||||
data,
|
||||
initialData,
|
||||
}: CreateDocumentDataOptions) => {
|
||||
return await prisma.documentData.create({
|
||||
data: {
|
||||
type,
|
||||
data,
|
||||
initialData: data,
|
||||
initialData: initialData || data,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ export const getDocumentAndSenderByToken = async ({
|
|||
title: true,
|
||||
order: true,
|
||||
envelopeId: true,
|
||||
documentDataId: true,
|
||||
documentData: true,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ export const ZEnvelopeForSigningResponse = z.object({
|
|||
id: true,
|
||||
title: true,
|
||||
order: true,
|
||||
documentDataId: true,
|
||||
}).array(),
|
||||
|
||||
team: TeamSchema.pick({
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ export const getTemplateByDirectLinkToken = async ({
|
|||
envelopeItems: envelope.envelopeItems.map((item) => ({
|
||||
id: item.id,
|
||||
envelopeId: item.envelopeId,
|
||||
documentDataId: item.documentDataId,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -181,3 +181,5 @@ export const ZDocumentManySchema = LegacyDocumentSchema.pick({
|
|||
});
|
||||
|
||||
export type TDocumentMany = z.infer<typeof ZDocumentManySchema>;
|
||||
|
||||
export type DocumentDataVersion = 'initial' | 'current';
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ export const ZEnvelopeSchema = EnvelopeSchema.pick({
|
|||
fields: ZEnvelopeFieldSchema.array(),
|
||||
envelopeItems: EnvelopeItemSchema.pick({
|
||||
envelopeId: true,
|
||||
documentDataId: true,
|
||||
id: true,
|
||||
title: true,
|
||||
order: true,
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ type File = {
|
|||
* Uploads a document file to the appropriate storage location and creates
|
||||
* a document data record.
|
||||
*/
|
||||
export const putPdfFileServerSide = async (file: File) => {
|
||||
export const putPdfFileServerSide = async (file: File, initialData?: string) => {
|
||||
const isEncryptedDocumentsAllowed = false; // Was feature flag.
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
|
|
@ -41,7 +41,7 @@ export const putPdfFileServerSide = async (file: File) => {
|
|||
|
||||
const { type, data } = await putFileServerSide(file);
|
||||
|
||||
return await createDocumentData({ type, data });
|
||||
return await createDocumentData({ type, data, initialData });
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import type { EnvelopeItem } from '@prisma/client';
|
||||
|
||||
import type { DocumentDataVersion } from '@documenso/lib/types/document';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
|
||||
|
||||
export type EnvelopeItemPdfUrlOptions =
|
||||
|
|
@ -34,3 +36,39 @@ export const getEnvelopeItemPdfUrl = (options: EnvelopeItemPdfUrlOptions) => {
|
|||
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/token/${token}/envelopeItem/${id}${presignToken ? `?presignToken=${presignToken}` : ''}`
|
||||
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/envelope/${envelopeId}/envelopeItem/${id}${presignToken ? `?token=${presignToken}` : ''}`;
|
||||
};
|
||||
|
||||
export type DocumentDataUrlOptions = {
|
||||
envelopeId: string;
|
||||
envelopeItemId: string;
|
||||
documentDataId: string;
|
||||
token: string | undefined;
|
||||
presignToken?: string | undefined;
|
||||
version: DocumentDataVersion;
|
||||
};
|
||||
|
||||
/**
|
||||
* The difference between this and `getEnvelopeItemPdfUrl` is that this will
|
||||
* hard cache since we add the `documentDataId` to the URL.
|
||||
*
|
||||
* Since `documentDataId` should change when the document is changed/signed, this is a
|
||||
* good way to cache an envelope item by.
|
||||
*/
|
||||
export const getDocumentDataUrl = (options: DocumentDataUrlOptions) => {
|
||||
const { envelopeId, envelopeItemId, documentDataId, token, presignToken, version } = options;
|
||||
|
||||
const partialUrl = `envelope/${envelopeId}/envelopeItem/${envelopeItemId}/dataId/${documentDataId}/${version}/item.pdf`;
|
||||
|
||||
// Recipient token endpoint.
|
||||
if (token) {
|
||||
return `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/token/${token}/${partialUrl}`;
|
||||
}
|
||||
|
||||
// Endpoint authenticated by session or presigned token.
|
||||
const baseUrl = `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/${partialUrl}`;
|
||||
|
||||
if (presignToken) {
|
||||
return `${baseUrl}?presignToken=${presignToken}`;
|
||||
}
|
||||
|
||||
return baseUrl;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import type { I18n } from '@lingui/core';
|
|||
import { msg } from '@lingui/core/macro';
|
||||
import { type Envelope, type Field, FieldType } from '@prisma/client';
|
||||
|
||||
import { PDF_VIEWER_CONTENT_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
|
||||
import { extractLegacyIds } from '../universal/id';
|
||||
|
||||
/**
|
||||
|
|
@ -23,25 +25,44 @@ export const sortFieldsByPosition = (fields: Field[]): Field[] => {
|
|||
*/
|
||||
export const validateFieldsInserted = (fields: Field[]): boolean => {
|
||||
const fieldCardElements = document.getElementsByClassName('field-card-container');
|
||||
const pdfContent = document.querySelector(PDF_VIEWER_CONTENT_SELECTOR);
|
||||
|
||||
// Attach validate attribute on all fields.
|
||||
const uninsertedFields = sortFieldsByPosition(fields.filter((field) => !field.inserted));
|
||||
|
||||
// All fields are inserted — clear the validation signal.
|
||||
if (uninsertedFields.length === 0) {
|
||||
pdfContent?.removeAttribute('data-validate-fields');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Attach validate attribute on all fields currently in the DOM.
|
||||
Array.from(fieldCardElements).forEach((element) => {
|
||||
element.setAttribute('data-validate', 'true');
|
||||
});
|
||||
|
||||
const uninsertedFields = sortFieldsByPosition(fields.filter((field) => !field.inserted));
|
||||
// Also set a signal on the PDF viewer container so that field elements that
|
||||
// mount later (e.g. after the virtual list scrolls to a new page) can pick
|
||||
// up the validation state.
|
||||
pdfContent?.setAttribute('data-validate-fields', 'true');
|
||||
|
||||
const firstUninsertedField = uninsertedFields[0];
|
||||
|
||||
const firstUninsertedFieldElement =
|
||||
firstUninsertedField && document.getElementById(`field-${firstUninsertedField.id}`);
|
||||
if (firstUninsertedField) {
|
||||
// Try direct element scroll first (works if the field's page is currently rendered).
|
||||
const firstUninsertedFieldElement = document.getElementById(`field-${firstUninsertedField.id}`);
|
||||
|
||||
if (firstUninsertedFieldElement) {
|
||||
firstUninsertedFieldElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
return false;
|
||||
if (firstUninsertedFieldElement) {
|
||||
firstUninsertedFieldElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
} else {
|
||||
// Field not in DOM (page virtualized away) — signal the PDF viewer to
|
||||
// scroll to the correct page via the data attribute.
|
||||
if (pdfContent) {
|
||||
pdfContent.setAttribute('data-scroll-to-page', String(firstUninsertedField.page));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return uninsertedFields.length === 0;
|
||||
return false;
|
||||
};
|
||||
|
||||
export const validateFieldsUninserted = (): boolean => {
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ export const mapEnvelopeToTemplateLite = (envelope: Envelope): TTemplateLite =>
|
|||
|
||||
return {
|
||||
id: legacyTemplateId,
|
||||
envelopeId: envelope.secondaryId,
|
||||
envelopeId: envelope.id,
|
||||
type: envelope.templateType,
|
||||
visibility: envelope.visibility,
|
||||
externalId: envelope.externalId,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ export const ZCreateEnvelopeItemsResponseSchema = z.object({
|
|||
title: true,
|
||||
envelopeId: true,
|
||||
order: true,
|
||||
documentDataId: true,
|
||||
}).array(),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -98,10 +98,8 @@ export const DocumentReadOnlyFields = ({
|
|||
setHiddenFieldIds((prev) => ({ ...prev, [fieldId]: true }));
|
||||
};
|
||||
|
||||
const highestPageNumber = Math.max(...fields.map((field) => field.page));
|
||||
|
||||
return (
|
||||
<ElementVisible target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}>
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
{fields.map(
|
||||
(field) =>
|
||||
!hiddenFieldIds[field.secondaryId] && (
|
||||
|
|
@ -163,7 +161,7 @@ export const DocumentReadOnlyFields = ({
|
|||
</span>
|
||||
</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)}
|
||||
</p>
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,11 @@ import { createPortal } from 'react-dom';
|
|||
|
||||
import { useElementBounds } from '@documenso/lib/client-only/hooks/use-element-bounds';
|
||||
import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { useIsPageInDom } from '@documenso/lib/client-only/hooks/use-is-page-in-dom';
|
||||
import {
|
||||
PDF_VIEWER_CONTENT_SELECTOR,
|
||||
PDF_VIEWER_PAGE_SELECTOR,
|
||||
} from '@documenso/lib/constants/pdf-viewer';
|
||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
|
||||
import type { RecipientColorStyles } from '../../lib/recipient-colors';
|
||||
|
|
@ -81,6 +85,8 @@ export function FieldRootContainer({
|
|||
readonly,
|
||||
}: FieldRootContainerProps) {
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const isPageInDom = useIsPageInDom(field.page);
|
||||
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -88,6 +94,21 @@ export function FieldRootContainer({
|
|||
return;
|
||||
}
|
||||
|
||||
// Check the validation signal on the PDF viewer container. When a field
|
||||
// mounts after the virtual list scrolls to its page, the per-element
|
||||
// `data-validate` attribute will not have been set yet. The signal on the
|
||||
// `[data-pdf-content]` container bridges this gap so newly-rendered fields
|
||||
// pick up the validation state immediately.
|
||||
const pdfContent = document.querySelector(PDF_VIEWER_CONTENT_SELECTOR);
|
||||
|
||||
if (
|
||||
pdfContent?.getAttribute('data-validate-fields') === 'true' &&
|
||||
isFieldUnsignedAndRequired(field)
|
||||
) {
|
||||
ref.current.setAttribute('data-validate', 'true');
|
||||
setIsValidating(true);
|
||||
}
|
||||
|
||||
const observer = new MutationObserver((_mutations) => {
|
||||
if (ref.current) {
|
||||
setIsValidating(ref.current.getAttribute('data-validate') === 'true');
|
||||
|
|
@ -101,7 +122,11 @@ export function FieldRootContainer({
|
|||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
}, [isPageInDom]);
|
||||
|
||||
if (!isPageInDom) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FieldContainerPortal field={field}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -66,14 +66,13 @@
|
|||
"framer-motion": "^12.23.24",
|
||||
"lucide-react": "^0.554.0",
|
||||
"luxon": "^3.7.2",
|
||||
"perfect-freehand": "^1.2.2",
|
||||
"pdfjs-dist": "5.4.296",
|
||||
"perfect-freehand": "^1.2.2",
|
||||
"react": "^18",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18",
|
||||
"react-hook-form": "^7.66.1",
|
||||
"react-pdf": "^10.3.0",
|
||||
"react-rnd": "^10.5.2",
|
||||
"remeda": "^2.32.0",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
|
|||
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
||||
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
|
||||
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR, getPdfPagesCount } from '@documenso/lib/constants/pdf-viewer';
|
||||
import {
|
||||
type TFieldMetaSchema as FieldMeta,
|
||||
ZFieldMetaSchema,
|
||||
|
|
@ -431,13 +431,15 @@ export const AddFieldsFormPartial = ({
|
|||
}
|
||||
|
||||
if (duplicateAll) {
|
||||
const pages = Array.from(document.querySelectorAll(PDF_VIEWER_PAGE_SELECTOR));
|
||||
const totalPages = getPdfPagesCount();
|
||||
|
||||
pages.forEach((_, index) => {
|
||||
const pageNumber = index + 1;
|
||||
if (totalPages < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) {
|
||||
if (pageNumber === lastActiveField.pageNumber) {
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
|
||||
const newField: TAddFieldsFormSchema['fields'][0] = {
|
||||
|
|
@ -450,7 +452,7 @@ export const AddFieldsFormPartial = ({
|
|||
};
|
||||
|
||||
append(newField);
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { Rnd } from 'react-rnd';
|
|||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { useElementBounds } from '@documenso/lib/client-only/hooks/use-element-bounds';
|
||||
import { useIsPageInDom } from '@documenso/lib/client-only/hooks/use-is-page-in-dom';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import type { TFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import { ZCheckboxFieldMeta, ZRadioFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
|
|
@ -50,7 +51,17 @@ export type FieldItemProps = {
|
|||
/**
|
||||
* The item when editing fields??
|
||||
*/
|
||||
export const FieldItem = ({
|
||||
export const FieldItem = (props: FieldItemProps) => {
|
||||
const isPageInDom = useIsPageInDom(props.field.pageNumber);
|
||||
|
||||
if (!isPageInDom) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <FieldItemInner {...props} />;
|
||||
};
|
||||
|
||||
const FieldItemInner = ({
|
||||
fieldClassName,
|
||||
field,
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
|
@ -24,7 +24,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
|
|||
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
||||
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
|
||||
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR, getPdfPagesCount } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
|
||||
import {
|
||||
|
|
@ -188,13 +188,15 @@ export const AddTemplateFieldsFormPartial = ({
|
|||
}
|
||||
|
||||
if (duplicateAll) {
|
||||
const pages = Array.from(document.querySelectorAll(PDF_VIEWER_PAGE_SELECTOR));
|
||||
const totalPages = getPdfPagesCount();
|
||||
|
||||
pages.forEach((_, index) => {
|
||||
const pageNumber = index + 1;
|
||||
if (totalPages < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) {
|
||||
if (pageNumber === lastActiveField.pageNumber) {
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
|
||||
const newField: TAddTemplateFieldsFormSchema['fields'][0] = {
|
||||
|
|
@ -208,7 +210,7 @@ export const AddTemplateFieldsFormPartial = ({
|
|||
};
|
||||
|
||||
append(newField);
|
||||
});
|
||||
}
|
||||
|
||||
void handleAutoSave();
|
||||
return;
|
||||
|
|
|
|||
Loading…
Reference in a new issue