Compare commits

...
217 changed files with 21747 additions and 1799 deletions
@@ -4,6 +4,7 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { getContentsMissingImages, resolveEnvelopeContentLimits } from '@documenso/lib/utils/envelope-content';
import { hasOverlappingFields } from '@documenso/lib/utils/fields-overlap';
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
import { zEmail } from '@documenso/lib/utils/zod';
@@ -31,7 +32,7 @@ import { Textarea } from '@documenso/ui/primitives/textarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { Plural, Trans, useLingui } from '@lingui/react/macro';
import { DocumentDistributionMethod, DocumentStatus, EnvelopeType } from '@prisma/client';
import { AnimatePresence, motion } from 'framer-motion';
import { AlertTriangleIcon, InfoIcon } from 'lucide-react';
@@ -162,6 +163,25 @@ export const EnvelopeDistributeDialog = ({
[envelope.fields],
);
/**
* Image related contents without an image render nothing once sent.
*/
const contentsMissingImages = useMemo(() => getContentsMissingImages(envelope.contents), [envelope.contents]);
/**
* An envelope can hold more contents than the organisation's plan allows,
* e.g. after the plan was lowered or a template was copied, so this is
* reported here rather than letting the send fail.
*/
const contentLimits = useMemo(
() =>
resolveEnvelopeContentLimits(
envelope.contents.map((content) => content.contentMeta.type),
organisation.organisationClaim,
),
[envelope.contents, organisation.organisationClaim],
);
const invalidEnvelopeCode = useMemo(() => {
if (recipientsMissingSignatureFields.length > 0) {
return 'MISSING_SIGNATURES';
@@ -175,8 +195,26 @@ export const EnvelopeDistributeDialog = ({
return 'MISSING_REQUIRED_EMAIL';
}
if (contentsMissingImages.length > 0) {
return 'MISSING_CONTENT_IMAGES';
}
if (contentLimits.isContentLimitExceeded) {
return 'ENVELOPE_CONTENT_LIMIT_EXCEEDED';
}
if (contentLimits.isImageLimitExceeded) {
return 'ENVELOPE_CONTENT_IMAGE_LIMIT_EXCEEDED';
}
return null;
}, [envelope.recipients, recipientsMissingRequiredEmail, recipientsMissingSignatureFields]);
}, [
envelope.recipients,
recipientsMissingRequiredEmail,
recipientsMissingSignatureFields,
contentsMissingImages,
contentLimits,
]);
const onFormSubmit = async ({ meta }: TEnvelopeDistributeFormSchema) => {
try {
@@ -520,6 +558,33 @@ export const EnvelopeDistributeDialog = ({
</ul>
</AlertDescription>
))
.with('MISSING_CONTENT_IMAGES', () => (
<AlertDescription>
<Plural
value={contentsMissingImages.length}
one="An image content has no image. Upload an image for it, or remove it."
other="# image contents have no image. Upload an image for each, or remove them."
/>
</AlertDescription>
))
.with('ENVELOPE_CONTENT_LIMIT_EXCEEDED', () => (
<AlertDescription>
<Plural
value={contentLimits.contentLimit}
one="This envelope cannot have more than # content item. Remove some, or contact support if you need more."
other="This envelope cannot have more than # content items. Remove some, or contact support if you need more."
/>
</AlertDescription>
))
.with('ENVELOPE_CONTENT_IMAGE_LIMIT_EXCEEDED', () => (
<AlertDescription>
<Plural
value={contentLimits.imageLimit}
one="This envelope cannot have more than # image content item. Remove some, or contact support if you need more."
other="This envelope cannot have more than # image content items. Remove some, or contact support if you need more."
/>
</AlertDescription>
))
.exhaustive()}
</Alert>
@@ -44,6 +44,7 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E
defaultValues: {
includeRecipients: true,
includeFields: true,
includeContents: true,
},
});
@@ -67,13 +68,14 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E
});
const onDuplicate = async () => {
const { includeRecipients, includeFields } = form.getValues();
const { includeRecipients, includeFields, includeContents } = form.getValues();
try {
await duplicateEnvelope({
envelopeId,
includeRecipients,
includeFields: includeRecipients && includeFields,
includeContents,
});
} catch {
toast({
@@ -159,6 +161,23 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E
</div>
)}
/>
<Controller
control={form.control}
name="includeContents"
render={({ field }) => (
<div className="flex items-center space-x-2">
<Checkbox
id="envelopeDuplicateIncludeContents"
checked={field.value}
onCheckedChange={(checked) => field.onChange(checked === true)}
/>
<Label htmlFor="envelopeDuplicateIncludeContents">
<Trans>Include Contents</Trans>
</Label>
</div>
)}
/>
</div>
<DialogFooter>
@@ -1,6 +1,6 @@
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { formatFileSize, megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { trpc } from '@documenso/trpc/react';
import { ZDocumentTitleSchema } from '@documenso/trpc/server/document-router/schema';
import type { TReplaceEnvelopeItemPdfPayload } from '@documenso/trpc/server/envelope-router/replace-envelope-item-pdf.types';
@@ -195,18 +195,6 @@ export const EnvelopeItemEditDialog = ({
}
}, [isOpen, form, envelopeItem.title]);
const formatFileSize = (bytes: number) => {
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
return (
<Dialog {...props} open={isOpen} onOpenChange={(value) => !form.formState.isSubmitting && setIsOpen(value)}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
@@ -6,9 +6,9 @@ import {
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error';
import { type TRecipientLite, ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { trpc } from '@documenso/trpc/react';
import { DOCUMENT_TITLE_MAX_LENGTH } from '@documenso/trpc/server/document-router/schema';
import type { TUseEnvelopePayload } from '@documenso/trpc/server/envelope-router/use-envelope.types';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
@@ -66,6 +66,7 @@ const isUploadedFileNameUsable = (file?: File): file is File => {
const ZAddRecipientsForNewDocumentSchema = z
.object({
distributeDocument: z.boolean(),
includeContents: z.boolean().default(true),
useCustomDocument: z.boolean().default(false),
documentNameSource: z.enum([
DOCUMENT_NAME_SOURCE.TEMPLATE,
@@ -103,7 +104,6 @@ type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDoc
export type TemplateUseDialogProps = {
envelopeId: string;
templateId: number;
templateSigningOrder?: DocumentSigningOrder | null;
recipients: TRecipientLite[];
documentDistributionMethod?: DocumentDistributionMethod;
@@ -116,7 +116,6 @@ export function TemplateUseDialog({
documentDistributionMethod = DocumentDistributionMethod.EMAIL,
documentRootPath,
envelopeId,
templateId,
templateSigningOrder,
trigger,
}: TemplateUseDialogProps) {
@@ -145,6 +144,7 @@ export function TemplateUseDialog({
const generateDefaultFormValues = () => {
return {
distributeDocument: false,
includeContents: true,
useCustomDocument: false,
documentNameSource: DOCUMENT_NAME_SOURCE.TEMPLATE,
customDocumentName: '',
@@ -180,7 +180,7 @@ export function TemplateUseDialog({
name: 'customDocumentData',
});
const { mutateAsync: createDocumentFromTemplate } = trpc.template.createDocumentFromTemplate.useMutation();
const { mutateAsync: createDocumentFromTemplate } = trpc.envelope.use.useMutation();
/**
* Track the most recently uploaded file so its name can be used as the document name.
@@ -217,24 +217,29 @@ export function TemplateUseDialog({
(item): item is typeof item & { data: File } => item.data !== undefined,
);
const customDocumentData = await Promise.all(
customFilesToUpload.map(async (item) => {
const customDocumentData = await putPdfFile(item.data);
return {
documentDataId: customDocumentData.id,
envelopeItemId: item.envelopeItemId,
};
}),
);
const { envelopeId } = await createDocumentFromTemplate({
templateId,
// The files are sent alongside the payload and mapped to their envelope
// items by index, which is robust against duplicate file names.
const payload: TUseEnvelopePayload = {
envelopeId,
recipients: data.recipients,
distributeDocument: data.distributeDocument,
customDocumentData,
includeContents: data.includeContents,
customDocumentData: customFilesToUpload.map((item, index) => ({
identifier: index,
envelopeItemId: item.envelopeItemId,
})),
...(documentTitle ? { override: { title: documentTitle } } : {}),
});
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
for (const item of customFilesToUpload) {
formData.append('files', item.data);
}
const { id: createdEnvelopeId } = await createDocumentFromTemplate(formData);
toast({
title: _(msg`Document created`),
@@ -242,7 +247,7 @@ export function TemplateUseDialog({
duration: 5000,
});
let documentPath = `${documentRootPath}/${envelopeId}`;
let documentPath = `${documentRootPath}/${createdEnvelopeId}`;
if (data.distributeDocument && documentDistributionMethod === DocumentDistributionMethod.NONE) {
documentPath += '?action=view-signing-links';
@@ -464,6 +469,46 @@ export function TemplateUseDialog({
</div>
)}
<FormField
control={form.control}
name="includeContents"
render={({ field }) => (
<FormItem>
<div className="flex flex-row items-center">
<Checkbox
id="includeContents"
className="h-5 w-5"
checked={field.value}
onCheckedChange={(checked) => field.onChange(checked === true)}
/>
<label
className="ml-2 flex items-center text-muted-foreground text-sm"
htmlFor="includeContents"
>
<Trans>Include contents</Trans>
<Tooltip>
<TooltipTrigger type="button">
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="z-[99999] max-w-md space-y-2 p-4 text-muted-foreground">
<p>
<Trans>
Copy the text, shapes, images and other contents from the template onto the new
document.
</Trans>
</p>
<p>
<Trans>Uncheck this to create the document without them.</Trans>
</p>
</TooltipContent>
</Tooltip>
</label>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="useCustomDocument"
@@ -1,4 +1,5 @@
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { formatFileSize } from '@documenso/lib/universal/unit-convertions';
import { buildDropzoneRejectionDescription } from '@documenso/ui/lib/handle-dropzone-rejection';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -98,16 +99,6 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
form.unregister('documentData');
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) {
return '0 Bytes';
}
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${parseFloat((bytes / 1024 ** i).toFixed(2))} ${sizes[i]}`;
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({
accept: {
'application/pdf': ['.pdf'],
@@ -0,0 +1,175 @@
import {
CONTENT_MAX_STROKE_WIDTH,
CONTENT_MIN_STROKE_WIDTH,
DEFAULT_CONTENT_TEXT_COLOR,
} from '@documenso/lib/types/envelope-content-meta';
import { ColorPicker } from '@documenso/ui/primitives/color-picker';
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { Slider } from '@documenso/ui/primitives/slider';
import { Trans, useLingui } from '@lingui/react/macro';
import type { Control } from 'react-hook-form';
// Can't seem to get the non-any type to work with correct types.
// Eg Control<{ fontSize?: number } doesn't seem to work when there are required items.
// biome-ignore lint/suspicious/noExplicitAny: See above
type FormControlType = Control<any>;
type GenericContentFieldProps = {
formControl: FormControlType;
className?: string;
};
export const EditorContentColorField = ({
formControl,
className,
name = 'color',
label,
defaultColor = DEFAULT_CONTENT_TEXT_COLOR,
}: GenericContentFieldProps & {
name?: string;
label?: React.ReactNode;
defaultColor?: string;
}) => {
return (
<FormField
control={formControl}
name={name}
render={({ field }) => (
<FormItem className={className}>
<FormLabel>{label ?? <Trans>Color</Trans>}</FormLabel>
<FormControl>
<div className="flex items-center gap-x-3">
<ColorPicker
data-testid={`content-form-${name}`}
value={field.value ?? ''}
defaultValue={defaultColor}
onChange={(color) => field.onChange(color)}
/>
<span className="font-mono text-muted-foreground text-xs uppercase">{field.value ?? defaultColor}</span>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
};
export const EditorContentStrokeWidthField = ({ formControl, className }: GenericContentFieldProps) => {
const { t } = useLingui();
return (
<FormField
control={formControl}
name="strokeWidth"
render={({ field }) => (
<FormItem className={className}>
<FormLabel>
<Trans>Thickness</Trans>
</FormLabel>
<FormControl>
<Input
data-testid="content-form-strokeWidth"
type="number"
min={CONTENT_MIN_STROKE_WIDTH}
max={CONTENT_MAX_STROKE_WIDTH}
step={0.5}
className="bg-background"
placeholder={t`Thickness`}
{...field}
onChange={(e) => {
field.onChange(Number(e.target.value));
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
};
export const EditorContentStrokeStyleField = ({ formControl, className }: GenericContentFieldProps) => {
const { t } = useLingui();
return (
<FormField
control={formControl}
name="strokeStyle"
render={({ field }) => (
<FormItem className={className}>
<FormLabel>
<Trans>Style</Trans>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger data-testid="content-form-strokeStyle">
<SelectValue placeholder={t`Select style`} />
</SelectTrigger>
<SelectContent>
<SelectItem value="solid">
<Trans>Solid</Trans>
</SelectItem>
<SelectItem value="dashed">
<Trans>Dashed</Trans>
</SelectItem>
<SelectItem value="dotted">
<Trans>Dotted</Trans>
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
};
/**
* An opacity slider. The form value is a fraction (0-1) while the slider and
* label display a percentage.
*/
export const EditorContentOpacityField = ({
formControl,
className,
name = 'fillOpacity',
label,
}: GenericContentFieldProps & {
name?: string;
label?: React.ReactNode;
}) => {
return (
<FormField
control={formControl}
name={name}
render={({ field }) => {
const percentage = Math.round((field.value ?? 1) * 100);
return (
<FormItem className={className}>
<FormLabel className="flex items-center justify-between">
{label ?? <Trans>Opacity</Trans>}
<span className="font-mono text-muted-foreground tabular-nums">{percentage}%</span>
</FormLabel>
<FormControl>
<Slider
data-testid={`content-form-${name}`}
className="py-2"
min={0}
max={100}
step={1}
value={[percentage]}
onValueChange={([value]) => field.onChange(value / 100)}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
);
};
@@ -0,0 +1,31 @@
import { DEFAULT_CONTENT_HIGHLIGHT_COLOR } from '@documenso/lib/types/envelope-content-meta';
import {
type TContentHighlightFormSchema,
useContentSettingsForm,
} from '~/components/general/envelope-editor/content-settings-form-provider';
import { EditorContentColorField, EditorContentOpacityField } from './editor-content-generic-field-forms';
/**
* Settings form for highlight contents, bound to the selected content's
* settings form. Only the presentational settings are editable here;
* geometry (page, position and size) is managed on the canvas.
*/
export const EditorContentHighlightForm = () => {
const { form, isReady } = useContentSettingsForm<TContentHighlightFormSchema>();
// Mount the inputs only once the form holds this content's values.
if (!isReady) {
return null;
}
return (
<div>
<fieldset className="flex flex-col gap-2">
<EditorContentColorField formControl={form.control} defaultColor={DEFAULT_CONTENT_HIGHLIGHT_COLOR} />
<EditorContentOpacityField formControl={form.control} name="fillOpacity" />
</fieldset>
</div>
);
};
@@ -0,0 +1,136 @@
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import {
APP_CONTENT_IMAGE_MIME_TYPES,
APP_CONTENT_IMAGE_UPLOAD_SIZE_LIMIT,
} from '@documenso/lib/constants/envelope-content';
import { formatFileSize, megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Trans, useLingui } from '@lingui/react/macro';
import { FileImageIcon, ImageUpIcon, RefreshCwIcon, TrashIcon } from 'lucide-react';
import { useDropzone } from 'react-dropzone';
import { ContentImageUploadDialog } from '~/components/general/envelope-editor/content-image-upload-dialog';
type EditorContentImageSettingsProps = {
formId: string;
dataContentId?: string | null;
};
/**
* Settings for image contents: the image itself.
*
* Uploads go through the shared upload dialog. This panel only offers the
* ways to start one (drop, click, replace) and to remove the current image.
* Geometry (page, position, size and rotation) is managed on the canvas.
*/
export const EditorContentImageSettings = ({ formId, dataContentId }: EditorContentImageSettingsProps) => {
const { t } = useLingui();
const { editorContents } = useCurrentEnvelopeEditor();
const { contentImages } = useCurrentEnvelopeRender();
const image = dataContentId ? contentImages.images.get(dataContentId) : undefined;
const details = dataContentId ? contentImages.details.get(dataContentId) : undefined;
const { getRootProps, getInputProps, isDragActive } = useDropzone({
accept: Object.fromEntries(APP_CONTENT_IMAGE_MIME_TYPES.map((mimeType) => [mimeType, []])),
multiple: false,
maxSize: megabytesToBytes(APP_CONTENT_IMAGE_UPLOAD_SIZE_LIMIT),
// A dropped file goes straight to the dialog; a click opens the picker
// through it instead of the dropzone's own input.
noClick: true,
onDrop: (acceptedFiles) => {
const [file] = acceptedFiles;
if (file) {
void ContentImageUploadDialog.call({ formId, file });
}
},
// A file which is too large or not a supported image goes to the dialog
// as well, which shows why it cannot be used. When several files were
// dropped, the first is used.
onDropRejected: (fileRejections) => {
const [rejection] = fileRejections;
if (rejection) {
void ContentImageUploadDialog.call({ formId, file: rejection.file });
}
},
});
if (dataContentId) {
return (
<div className="mt-2 flex items-center gap-3 rounded-lg border border-border p-2">
<div className="flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded-md bg-muted/40">
<FileImageIcon className="h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-foreground text-xs">
<Trans>Image</Trans>
</p>
<p className="truncate text-muted-foreground text-xs">
{[details ? formatFileSize(details.fileSize) : null, image ? `${image.width} * ${image.height}` : null]
.filter((part) => part !== null)
.join(' · ')}
</p>
</div>
<div className="flex shrink-0 flex-row gap-1">
<Button
type="button"
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
title={t`Replace image`}
onClick={() => void ContentImageUploadDialog.call({ formId })}
>
<RefreshCwIcon className="h-4 w-4" strokeWidth={1.5} />
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
title={t`Remove image`}
onClick={() => editorContents.updateContentByFormId(formId, { dataContentId: null, data: undefined })}
>
<TrashIcon className="h-4 w-4" strokeWidth={1.5} />
</Button>
</div>
</div>
);
}
return (
<div
{...getRootProps({
role: 'button',
tabIndex: 0,
onClick: () => void ContentImageUploadDialog.call({ formId }),
onKeyDown: (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
void ContentImageUploadDialog.call({ formId });
}
},
})}
className={cn(
'mt-2 flex cursor-pointer flex-col items-center justify-center gap-1.5 rounded-lg border border-border border-dashed px-4 py-6 text-center transition-colors hover:border-primary/60',
isDragActive && 'border-primary bg-primary/5',
)}
>
<input {...getInputProps()} />
<ImageUpIcon className="h-6 w-6 text-muted-foreground" strokeWidth={1.5} />
<p className="text-foreground text-sm">
<Trans>Click to upload or drag and drop</Trans>
</p>
<p className="text-muted-foreground text-xs">
<Trans>PNG, JPEG or WebP up to {APP_CONTENT_IMAGE_UPLOAD_SIZE_LIMIT} MB</Trans>
</p>
</div>
);
};
@@ -0,0 +1,43 @@
import { DEFAULT_CONTENT_STROKE_COLOR } from '@documenso/lib/types/envelope-content-meta';
import {
type TContentLineFormSchema,
useContentSettingsForm,
} from '~/components/general/envelope-editor/content-settings-form-provider';
import {
EditorContentColorField,
EditorContentStrokeStyleField,
EditorContentStrokeWidthField,
} from './editor-content-generic-field-forms';
/**
* Settings form for line contents, bound to the selected content's settings
* form. Only the presentational settings are editable here; geometry (page
* and endpoints) is managed on the canvas.
*/
export const EditorContentLineForm = () => {
const { form, isReady } = useContentSettingsForm<TContentLineFormSchema>();
// Mount the inputs only once the form holds this content's values.
if (!isReady) {
return null;
}
return (
<div>
<fieldset className="flex flex-col gap-2">
<div className="flex w-full flex-row gap-x-4">
<EditorContentStrokeWidthField className="w-full" formControl={form.control} />
<EditorContentStrokeStyleField className="w-full" formControl={form.control} />
</div>
<EditorContentColorField
formControl={form.control}
name="strokeColor"
defaultColor={DEFAULT_CONTENT_STROKE_COLOR}
/>
</fieldset>
</div>
);
};
@@ -0,0 +1,105 @@
import {
DEFAULT_CONTENT_STROKE_COLOR,
EnvelopeContentShapeType,
EnvelopeContentType,
} from '@documenso/lib/types/envelope-content-meta';
import { FormControl, FormItem, FormLabel } from '@documenso/ui/primitives/form/form';
import { Switch } from '@documenso/ui/primitives/switch';
import { Trans } from '@lingui/react/macro';
import { useWatch } from 'react-hook-form';
import { match } from 'ts-pattern';
import {
type TContentShapeFormSchema,
useContentSettingsForm,
} from '~/components/general/envelope-editor/content-settings-form-provider';
import {
EditorContentColorField,
EditorContentOpacityField,
EditorContentStrokeStyleField,
EditorContentStrokeWidthField,
} from './editor-content-generic-field-forms';
/**
* The fill color used when the fill is first enabled.
*
* Todo: Contents
*/
export const DEFAULT_ENABLED_FILL_COLOR = '#e5e7eb';
/**
* Settings form for shape contents, bound to the selected content's settings
* form, with the fields for the content's shape. Only the presentational
* settings are editable here; geometry (page, position and size) is managed
* on the canvas.
*/
export const EditorContentShapeForm = () => {
const { form, content, isReady } = useContentSettingsForm<TContentShapeFormSchema>();
const fillColor = useWatch({ control: form.control, name: 'fillColor' });
const hasFill = Boolean(fillColor);
const onFillToggle = (enabled: boolean) => {
// The fill is represented purely by the fill color, so toggling sets it
// or clears it to `null`.
form.setValue('fillColor', enabled ? DEFAULT_ENABLED_FILL_COLOR : null, {
shouldDirty: true,
shouldValidate: true,
});
};
// Mount the inputs only once the form holds this content's values.
if (!isReady || content?.contentMeta.type !== EnvelopeContentType.SHAPE) {
return null;
}
return (
<div>
{match(content.contentMeta.shape)
.with(EnvelopeContentShapeType.RECTANGLE, () => (
<fieldset className="flex flex-col gap-2">
<div className="flex w-full flex-row gap-x-4">
<EditorContentStrokeWidthField className="w-full" formControl={form.control} />
<EditorContentStrokeStyleField className="w-full" formControl={form.control} />
</div>
<EditorContentColorField
formControl={form.control}
name="strokeColor"
label={<Trans>Border Color</Trans>}
defaultColor={DEFAULT_CONTENT_STROKE_COLOR}
/>
<FormItem className="mt-1 flex flex-row items-center justify-between">
<FormLabel>
<Trans>Fill</Trans>
</FormLabel>
<FormControl>
<Switch data-testid="content-form-fill" checked={hasFill} onCheckedChange={onFillToggle} />
</FormControl>
</FormItem>
{hasFill && (
<>
<EditorContentColorField
formControl={form.control}
name="fillColor"
label={<Trans>Fill Color</Trans>}
defaultColor={DEFAULT_ENABLED_FILL_COLOR}
/>
<EditorContentOpacityField
formControl={form.control}
name="fillOpacity"
label={<Trans>Fill Opacity</Trans>}
/>
</>
)}
</fieldset>
))
.exhaustive()}
</div>
);
};
@@ -0,0 +1,78 @@
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { Trans, useLingui } from '@lingui/react/macro';
import {
type TContentTextFormSchema,
useContentSettingsForm,
} from '~/components/general/envelope-editor/content-settings-form-provider';
import { EditorContentColorField } from './editor-content-generic-field-forms';
import {
EditorGenericFontSizeField,
EditorGenericLetterSpacingField,
EditorGenericLineHeightField,
EditorGenericTextAlignField,
EditorGenericVerticalAlignField,
} from './editor-field-generic-field-forms';
/**
* Settings form for text contents, bound to the selected content's settings
* form. Only the presentational settings are editable here; geometry (page,
* position and size) is managed on the canvas.
*/
export const EditorContentTextForm = () => {
const { t } = useLingui();
const { form, isReady } = useContentSettingsForm<TContentTextFormSchema>();
// Mount the inputs only once the form holds this content's values.
if (!isReady) {
return null;
}
return (
<div>
<fieldset className="flex flex-col gap-2">
<FormField
control={form.control}
name="text"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Text</Trans>
</FormLabel>
<FormControl>
<Textarea
data-testid="content-form-text"
className="h-auto"
placeholder={t`Add text to the document`}
rows={3}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex w-full flex-row gap-x-4">
<EditorGenericFontSizeField className="w-full" formControl={form.control} />
<EditorContentColorField className="w-full" formControl={form.control} />
</div>
<div className="flex w-full flex-row gap-x-4">
<EditorGenericTextAlignField className="w-full" formControl={form.control} />
<EditorGenericVerticalAlignField className="w-full" formControl={form.control} />
</div>
<div className="flex w-full flex-row gap-x-4">
<EditorGenericLineHeightField className="w-full" formControl={form.control} />
<EditorGenericLetterSpacingField className="w-full" formControl={form.control} />
</div>
</fieldset>
</div>
);
};
@@ -15,7 +15,7 @@ import { type Control, useFormContext } from 'react-hook-form';
// Can't seem to get the non-any type to work with correct types.
// Eg Control<{ fontSize?: number } doesn't seem to work when there are required items.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// biome-ignore lint/suspicious/noExplicitAny: See above
type FormControlType = Control<any>;
export const EditorGenericFontSizeField = ({
@@ -54,6 +54,8 @@ export const SubscriptionClaimForm = ({
memberCount: subscriptionClaim.memberCount,
envelopeItemCount: subscriptionClaim.envelopeItemCount,
recipientCount: subscriptionClaim.recipientCount,
envelopeContentCount: subscriptionClaim.envelopeContentCount,
envelopeContentImageCount: subscriptionClaim.envelopeContentImageCount,
flags: subscriptionClaim.flags,
documentRateLimits: subscriptionClaim.documentRateLimits,
documentQuota: subscriptionClaim.documentQuota,
@@ -185,6 +187,54 @@ export const SubscriptionClaimForm = ({
)}
/>
<FormField
control={form.control}
name="envelopeContentCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Envelope Content Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Maximum number of contents per envelope allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="envelopeContentImageCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Envelope Content Image Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Maximum number of image contents per envelope allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div>
<FormLabel>
<Trans>Feature Flags</Trans>
@@ -1,7 +1,18 @@
import { Trans } from '@lingui/react/macro';
import { AlertTriangleIcon } from 'lucide-react';
import { match } from 'ts-pattern';
export const DirectTemplateInvalidPageView = () => {
/**
* Why a direct link template cannot be used. Each reason would otherwise only
* surface once the signer submits, since the template renders normally.
*/
export type DirectTemplateInvalidReason = 'MISSING_SIGNATURE_FIELD' | 'MISSING_CONTENT_IMAGE';
export type DirectTemplateInvalidPageViewProps = {
reason: DirectTemplateInvalidReason;
};
export const DirectTemplateInvalidPageView = ({ reason }: DirectTemplateInvalidPageViewProps) => {
return (
<div className="mx-auto flex h-[70vh] w-full max-w-md flex-col items-center justify-center">
<div>
@@ -12,10 +23,20 @@ export const DirectTemplateInvalidPageView = () => {
</h1>
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
This direct link template cannot be used because one or more signers do not have a signature field assigned.
Please contact the sender to update the template.
</Trans>
{match(reason)
.with('MISSING_SIGNATURE_FIELD', () => (
<Trans>
This direct link template cannot be used because one or more signers do not have a signature field
assigned. Please contact the sender to update the template.
</Trans>
))
.with('MISSING_CONTENT_IMAGE', () => (
<Trans>
This direct link template cannot be used because one or more image contents have no image attached.
Please contact the sender to update the template.
</Trans>
))
.exhaustive()}
</p>
</div>
</div>
@@ -235,7 +235,10 @@ export const DocumentSigningPageViewV2 = () => {
</div>
</div>
<div className="embed--DocumentContainer min-w-0 flex-1 overflow-y-auto" ref={scrollableContainerRef}>
<div
className="embed--DocumentContainer min-w-0 flex-1 overflow-x-auto overflow-y-auto"
ref={scrollableContainerRef}
>
<div className="flex flex-col">
{/* Horizontal envelope item selector */}
{envelopeItems.length > 1 && (
@@ -267,6 +270,9 @@ export const DocumentSigningPageViewV2 = () => {
customPageRenderer={EnvelopeSignerPageRenderer}
scrollParentRef={scrollableContainerRef}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.signing}
toolbar={['zoom']}
// Todo: Content - Decide how to manage zooming on mobile.
toolbarClassName="hidden lg:flex"
/>
) : (
<div className="flex flex-col items-center justify-center py-32">
@@ -0,0 +1,131 @@
import type Konva from 'konva';
import type { LucideIcon } from 'lucide-react';
/**
* The gap between a selection and its floating action bar.
*/
const ACTION_BAR_OFFSET_PX = 5;
/**
* Resolve the absolute CSS position for an action bar hanging below the
* bottom center of a set of nodes.
*
* Uses the union of the nodes' own client rects rather than the transformer's,
* since the transformer rect includes its handles (e.g. the rotate anchor and
* its stem above the selection) which would otherwise push the bar away from
* the selection.
*
* Client rects are in scaled stage coordinates, matching the CSS pixel space
* of the konva container.
*/
const getNodesActionBarPosition = (nodes: Konva.Node[]) => {
let minX = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
for (const node of nodes) {
const rect = node.getClientRect({ skipStroke: true, skipShadow: true });
minX = Math.min(minX, rect.x);
maxX = Math.max(maxX, rect.x + rect.width);
maxY = Math.max(maxY, rect.y + rect.height);
}
if (!Number.isFinite(minX) || !Number.isFinite(maxY)) {
return null;
}
return {
top: `${maxY + ACTION_BAR_OFFSET_PX}px`,
left: `${minX + (maxX - minX) / 2}px`,
};
};
type EnvelopeCanvasActionBarProps = {
/**
* The Konva nodes the bar hangs below.
*/
nodes: Konva.Node[];
/**
* Hide the bar, e.g. while the nodes are being transformed.
*/
hidden?: boolean;
children: React.ReactNode;
};
/**
* A floating container positioned below the bottom center of a set of canvas
* nodes, for action bars and menus attached to a selection.
*
* Must be rendered within the page's relatively positioned container.
*/
export const EnvelopeCanvasActionBar = ({ nodes, hidden = false, children }: EnvelopeCanvasActionBarProps) => {
const position = nodes.length > 0 ? getNodesActionBarPosition(nodes) : null;
if (!position || hidden) {
return null;
}
return (
<div
data-testid="envelope-canvas-action-bar"
className="flex flex-col items-center"
style={{
position: 'absolute',
top: position.top,
left: position.left,
transform: 'translateX(-50%)',
gap: '8px',
pointerEvents: 'auto',
zIndex: 50,
}}
>
{children}
</div>
);
};
/**
* The floating pill which groups action buttons, themed like the viewer
* toolbar so the two float over the page consistently.
*
* The ring and shadow are stronger than the toolbar's since the bar sits
* directly on the page, which is usually white, rather than on the viewer
* background.
*/
export const EnvelopeCanvasActionButtonGroup = ({ children }: { children: React.ReactNode }) => {
return (
<div className="flex w-fit items-center gap-x-0.5 rounded-lg bg-popover p-1 text-popover-foreground shadow-black/25 shadow-lg ring-1 ring-black/15 dark:shadow-black/60 dark:ring-white/20">
{children}
</div>
);
};
/**
* A thin vertical separator between groups of actions within the pill.
*/
export const EnvelopeCanvasActionDivider = () => {
return <div className="mx-0.5 h-4 w-px bg-border" />;
};
type EnvelopeCanvasActionButtonProps = {
title: string;
icon: LucideIcon;
onClick: () => void;
};
export const EnvelopeCanvasActionButton = ({ title, icon: Icon, onClick }: EnvelopeCanvasActionButtonProps) => {
return (
<button
type="button"
title={title}
className="rounded-md p-1.5 text-foreground/70 transition-colors hover:bg-muted hover:text-foreground"
onClick={onClick}
onTouchEnd={onClick}
>
<Icon className="h-3.5 w-3.5" />
</button>
);
};
@@ -0,0 +1,423 @@
import type { TLocalContent } from '@documenso/lib/client-only/hooks/use-editor-contents';
import {
CONTENT_MAX_FONT_SIZE,
CONTENT_MAX_STROKE_WIDTH,
CONTENT_MIN_FONT_SIZE,
CONTENT_MIN_STROKE_WIDTH,
DEFAULT_CONTENT_FONT_SIZE,
DEFAULT_CONTENT_HIGHLIGHT_COLOR,
DEFAULT_CONTENT_STROKE_COLOR,
DEFAULT_CONTENT_STROKE_WIDTH,
DEFAULT_CONTENT_TEXT_ALIGN,
DEFAULT_CONTENT_TEXT_COLOR,
DEFAULT_CONTENT_VERTICAL_ALIGN,
EnvelopeContentType,
} from '@documenso/lib/types/envelope-content-meta';
import { ColorPicker } from '@documenso/ui/primitives/color-picker';
import { FormField } from '@documenso/ui/primitives/form/form';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { Slider } from '@documenso/ui/primitives/slider';
import { Trans, useLingui } from '@lingui/react/macro';
import type { LucideIcon } from 'lucide-react';
import {
ALargeSmallIcon,
AlignCenterIcon,
AlignLeftIcon,
AlignRightIcon,
AlignVerticalJustifyCenterIcon,
AlignVerticalJustifyEndIcon,
AlignVerticalJustifyStartIcon,
ImageUpIcon,
} from 'lucide-react';
import { match } from 'ts-pattern';
import { DEFAULT_ENABLED_FILL_COLOR } from '~/components/forms/editor/editor-content-shape-form';
import { ContentImageUploadDialog } from '~/components/general/envelope-editor/content-image-upload-dialog';
import {
type TContentHighlightFormSchema,
type TContentLineFormSchema,
type TContentShapeFormSchema,
type TContentTextFormSchema,
useContentSettingsForm,
} from '~/components/general/envelope-editor/content-settings-form-provider';
import { EnvelopeCanvasActionButton, EnvelopeCanvasActionDivider } from './envelope-canvas-action-bar';
type EnvelopeCanvasContentActionsProps = {
/**
* The single selected content.
*/
content: TLocalContent;
};
/**
* The type specific quick actions for a single selected content, shown at the
* start of the content action bar: the frequently adjusted styles (colors,
* stroke width) and the image upload for image contents.
*
* The style actions are fields of the selected content's settings form, the
* same form the sidebar edits, so the two never disagree. The full set of
* settings remains in the sidebar. Renders nothing for types without quick
* actions.
*/
export const EnvelopeCanvasContentActions = ({ content }: EnvelopeCanvasContentActionsProps) => {
const { t } = useLingui();
const { content: formContent, isReady } = useContentSettingsForm();
// The canvas selection and the settings form follow each other, but guard
// against a render in between so a stale selection never edits another
// content's form, and wait for the form to hold this content's values.
if (formContent?.formId !== content.formId || !isReady) {
return null;
}
const actions = match(content.contentMeta)
.with({ type: EnvelopeContentType.TEXT }, () => <TextContentActions />)
.with({ type: EnvelopeContentType.LINE }, () => <LineContentActions />)
.with({ type: EnvelopeContentType.SHAPE }, () => <ShapeContentActions />)
.with({ type: EnvelopeContentType.HIGHLIGHT }, () => <HighlightContentActions />)
.with({ type: EnvelopeContentType.IMAGE }, () => (
<EnvelopeCanvasActionButton
title={content.dataContentId ? t`Replace image` : t`Upload image`}
icon={ImageUpIcon}
onClick={() => void ContentImageUploadDialog.call({ formId: content.formId })}
/>
))
.exhaustive();
return (
<>
{actions}
<EnvelopeCanvasActionDivider />
</>
);
};
const TextContentActions = () => {
const { t } = useLingui();
const { form } = useContentSettingsForm<TContentTextFormSchema>();
return (
<>
<FormField
control={form.control}
name="color"
render={({ field }) => (
<ColorAction
title={t`Text color`}
value={field.value ?? DEFAULT_CONTENT_TEXT_COLOR}
onChange={field.onChange}
/>
)}
/>
<FormField
control={form.control}
name="fontSize"
render={({ field }) => (
<FontSizeAction value={field.value ?? DEFAULT_CONTENT_FONT_SIZE} onChange={field.onChange} />
)}
/>
<FormField
control={form.control}
name="textAlign"
render={({ field }) => (
<ChoiceAction
title={t`Text align`}
value={field.value ?? DEFAULT_CONTENT_TEXT_ALIGN}
options={[
{ value: 'left', icon: AlignLeftIcon, label: t`Left` },
{ value: 'center', icon: AlignCenterIcon, label: t`Center` },
{ value: 'right', icon: AlignRightIcon, label: t`Right` },
]}
onChange={field.onChange}
/>
)}
/>
<FormField
control={form.control}
name="verticalAlign"
render={({ field }) => (
<ChoiceAction
title={t`Vertical align`}
value={field.value ?? DEFAULT_CONTENT_VERTICAL_ALIGN}
options={[
{ value: 'top', icon: AlignVerticalJustifyStartIcon, label: t`Top` },
{ value: 'middle', icon: AlignVerticalJustifyCenterIcon, label: t`Middle` },
{ value: 'bottom', icon: AlignVerticalJustifyEndIcon, label: t`Bottom` },
]}
onChange={field.onChange}
/>
)}
/>
</>
);
};
const LineContentActions = () => {
const { t } = useLingui();
const { form } = useContentSettingsForm<TContentLineFormSchema>();
return (
<>
<FormField
control={form.control}
name="strokeColor"
render={({ field }) => (
<ColorAction
title={t`Line color`}
value={field.value ?? DEFAULT_CONTENT_STROKE_COLOR}
onChange={field.onChange}
/>
)}
/>
<FormField
control={form.control}
name="strokeWidth"
render={({ field }) => (
<StrokeWidthAction value={field.value ?? DEFAULT_CONTENT_STROKE_WIDTH} onChange={field.onChange} />
)}
/>
</>
);
};
const ShapeContentActions = () => {
const { t } = useLingui();
const { form } = useContentSettingsForm<TContentShapeFormSchema>();
return (
<>
<FormField
control={form.control}
name="strokeColor"
render={({ field }) => (
<ColorAction
title={t`Border color`}
value={field.value ?? DEFAULT_CONTENT_STROKE_COLOR}
onChange={field.onChange}
/>
)}
/>
<FormField
control={form.control}
name="fillColor"
render={({ field }) => (
<ColorAction
title={field.value ? t`Fill color` : t`Add fill`}
value={field.value}
defaultValue={DEFAULT_ENABLED_FILL_COLOR}
onChange={field.onChange}
/>
)}
/>
<FormField
control={form.control}
name="strokeWidth"
render={({ field }) => (
<StrokeWidthAction value={field.value ?? DEFAULT_CONTENT_STROKE_WIDTH} onChange={field.onChange} />
)}
/>
</>
);
};
const HighlightContentActions = () => {
const { t } = useLingui();
const { form } = useContentSettingsForm<TContentHighlightFormSchema>();
return (
<FormField
control={form.control}
name="color"
render={({ field }) => (
<ColorAction
title={t`Highlight color`}
value={field.value ?? DEFAULT_CONTENT_HIGHLIGHT_COLOR}
onChange={field.onChange}
/>
)}
/>
);
};
type ColorActionProps = {
title: string;
/**
* The current color, or null when none is set (e.g. no fill).
*/
value: string | null;
/**
* The color the picker starts from when none is set.
*/
defaultValue?: string;
onChange: (color: string) => void;
};
/**
* A swatch button which opens a color picker. An unset color shows a struck
* through swatch.
*/
const ColorAction = ({ title, value, defaultValue, onChange }: ColorActionProps) => {
return (
<ColorPicker
value={value ?? ''}
defaultValue={defaultValue}
onChange={onChange}
trigger={
<button type="button" title={title} className="rounded-md p-1.5 transition-colors hover:bg-muted">
<span
className="relative block h-3.5 w-3.5 overflow-hidden rounded-full ring-1 ring-black/20 ring-inset dark:ring-white/30"
style={{ backgroundColor: value ?? 'transparent' }}
>
{!value && <span className="absolute -inset-x-1 top-1/2 h-px -translate-y-1/2 rotate-45 bg-destructive" />}
</span>
</button>
}
/>
);
};
type FontSizeActionProps = {
value: number;
onChange: (fontSize: number) => void;
};
/**
* A button showing the current font size which opens a slider.
*/
const FontSizeAction = ({ value, onChange }: FontSizeActionProps) => {
const { t } = useLingui();
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
title={t`Font size`}
className="rounded-md p-1.5 text-foreground/70 transition-colors hover:bg-muted hover:text-foreground"
>
<ALargeSmallIcon className="h-3.5 w-3.5" />
</button>
</PopoverTrigger>
<PopoverContent className="w-48 p-3" onOpenAutoFocus={(event) => event.preventDefault()}>
<div className="mb-2 flex items-center justify-between text-xs">
<span className="text-muted-foreground">
<Trans>Font size</Trans>
</span>
<span className="tabular-nums">{value}</span>
</div>
<Slider
value={[value]}
min={CONTENT_MIN_FONT_SIZE}
max={CONTENT_MAX_FONT_SIZE}
step={1}
onValueChange={([fontSize]) => onChange(fontSize)}
/>
</PopoverContent>
</Popover>
);
};
type ChoiceActionProps<T extends string> = {
title: string;
value: T;
options: { value: T; icon: LucideIcon; label: string }[];
onChange: (value: T) => void;
};
/**
* A button showing the icon of the current choice which opens the other
* choices, e.g. text alignment.
*/
const ChoiceAction = <T extends string>({ title, value, options, onChange }: ChoiceActionProps<T>) => {
const current = options.find((option) => option.value === value) ?? options[0];
const CurrentIcon = current.icon;
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
title={title}
className="rounded-md p-1.5 text-foreground/70 transition-colors hover:bg-muted hover:text-foreground"
>
<CurrentIcon className="h-3.5 w-3.5" />
</button>
</PopoverTrigger>
<PopoverContent className="flex w-auto gap-0.5 p-1" onOpenAutoFocus={(event) => event.preventDefault()}>
{options.map((option) => {
const OptionIcon = option.icon;
return (
<button
key={option.value}
type="button"
title={option.label}
aria-pressed={option.value === value}
onClick={() => onChange(option.value)}
className="rounded-md p-1.5 text-foreground/70 transition-colors hover:bg-muted hover:text-foreground aria-pressed:bg-muted aria-pressed:text-foreground"
>
<OptionIcon className="h-4 w-4" />
</button>
);
})}
</PopoverContent>
</Popover>
);
};
type StrokeWidthActionProps = {
value: number;
onChange: (width: number) => void;
};
/**
* A button showing the current stroke width which opens a slider.
*/
const StrokeWidthAction = ({ value, onChange }: StrokeWidthActionProps) => {
const { t } = useLingui();
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
title={t`Thickness`}
className="flex h-[26px] w-[26px] items-center justify-center rounded-md text-foreground/70 transition-colors hover:bg-muted hover:text-foreground"
>
{/* A bar whose thickness follows the value, so the current width is still glanceable. */}
<span className="block w-3.5 rounded-full bg-current" style={{ height: Math.max(1.5, Math.min(value, 6)) }} />
</button>
</PopoverTrigger>
<PopoverContent className="w-48 p-3" onOpenAutoFocus={(event) => event.preventDefault()}>
<div className="mb-2 flex items-center justify-between text-xs">
<span className="text-muted-foreground">
<Trans>Thickness</Trans>
</span>
<span className="tabular-nums">{value}</span>
</div>
<Slider
value={[value]}
min={CONTENT_MIN_STROKE_WIDTH}
max={CONTENT_MAX_STROKE_WIDTH}
step={0.5}
onValueChange={([width]) => onChange(width)}
/>
</PopoverContent>
</Popover>
);
};
@@ -0,0 +1,169 @@
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@documenso/ui/primitives/command';
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
import { useLingui } from '@lingui/react/macro';
import type { FieldType } from '@prisma/client';
import { CopyPlusIcon, ShapesIcon, SquareStackIcon, TrashIcon, UserCircleIcon } from 'lucide-react';
import { useMemo, useState } from 'react';
import { fieldButtonList } from '../envelope-editor-fields-drag-drop';
import { EnvelopeRecipientSelectorCommand } from '../envelope-recipient-selector';
import { EnvelopeCanvasActionButton, EnvelopeCanvasActionButtonGroup } from './envelope-canvas-action-bar';
type EnvelopeCanvasFieldActionButtonsProps = {
selectedFieldFormIds: string[];
onDuplicate: () => void;
onDuplicateOnAllPages: () => void;
onDelete: () => void;
onChangeRecipient: (recipientId: number) => void;
onChangeFieldType: (type: FieldType) => void;
};
/**
* The floating actions for the selected fields.
*/
export const EnvelopeCanvasFieldActionButtons = ({
selectedFieldFormIds,
onDuplicate,
onDuplicateOnAllPages,
onDelete,
onChangeRecipient,
onChangeFieldType,
}: EnvelopeCanvasFieldActionButtonsProps) => {
const { t } = useLingui();
const [showRecipientSelector, setShowRecipientSelector] = useState(false);
const [showFieldTypeSelector, setShowFieldTypeSelector] = useState(false);
const { editorFields, envelope } = useCurrentEnvelopeEditor();
const selectedFields = useMemo(
() => editorFields.localFields.filter((field) => selectedFieldFormIds.includes(field.formId)),
[editorFields.localFields, selectedFieldFormIds],
);
/**
* Decide the preselected field type in the command input.
*
* If all fields share the same type, use that as the default selection.
* Otherwise show no preselection.
*/
const preselectedFieldType = useMemo(() => {
if (selectedFields.length === 0) {
return null;
}
const firstType = selectedFields[0].type;
const isTypesSame = selectedFields.every((field) => field.type === firstType);
return isTypesSame ? firstType : null;
}, [selectedFields]);
/**
* Decide the preselected recipient in the command input.
*
* If all fields belong to the same recipient then use that recipient as the default.
*
* Otherwise show the placeholder.
*/
const preselectedRecipient = useMemo(() => {
if (selectedFields.length === 0) {
return null;
}
const recipient = envelope.recipients.find((recipient) => recipient.id === selectedFields[0].recipientId);
if (!recipient) {
return null;
}
const isRecipientsSame = selectedFields.every((field) => field.recipientId === recipient.id);
return isRecipientsSame ? recipient : null;
}, [selectedFields, envelope.recipients]);
return (
<>
<EnvelopeCanvasActionButtonGroup>
<EnvelopeCanvasActionButton
title={t`Change Recipient`}
icon={UserCircleIcon}
onClick={() => setShowRecipientSelector(true)}
/>
<EnvelopeCanvasActionButton
title={t`Change Field Type`}
icon={ShapesIcon}
onClick={() => setShowFieldTypeSelector(true)}
/>
<EnvelopeCanvasActionButton title={t`Duplicate`} icon={CopyPlusIcon} onClick={onDuplicate} />
<EnvelopeCanvasActionButton
title={t`Duplicate on all pages`}
icon={SquareStackIcon}
onClick={onDuplicateOnAllPages}
/>
<EnvelopeCanvasActionButton title={t`Remove`} icon={TrashIcon} onClick={onDelete} />
</EnvelopeCanvasActionButtonGroup>
<CommandDialog position="start" open={showRecipientSelector} onOpenChange={setShowRecipientSelector}>
<EnvelopeRecipientSelectorCommand
placeholder={t`Select a recipient`}
selectedRecipient={preselectedRecipient}
onSelectedRecipientChange={(recipient) => {
editorFields.setSelectedRecipient(recipient.id);
onChangeRecipient(recipient.id);
setShowRecipientSelector(false);
}}
recipients={envelope.recipients}
fields={envelope.fields}
/>
</CommandDialog>
<CommandDialog position="start" open={showFieldTypeSelector} onOpenChange={setShowFieldTypeSelector}>
<Command defaultValue={preselectedFieldType ? t(FRIENDLY_FIELD_TYPE[preselectedFieldType]) : undefined}>
<CommandInput placeholder={t`Select a field type`} />
<CommandList>
<CommandEmpty>
<span className="inline-block px-4 text-muted-foreground">
{t`No field type matching this description was found.`}
</span>
</CommandEmpty>
<CommandGroup>
{fieldButtonList.map((field) => {
const FieldIcon = field.icon;
const label = t(FRIENDLY_FIELD_TYPE[field.type]);
return (
<CommandItem
key={field.type}
className="px-2"
onSelect={() => {
onChangeFieldType(field.type);
setShowFieldTypeSelector(false);
}}
>
<FieldIcon className="mr-2 h-4 w-4" />
<span className="truncate">{label}</span>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</CommandDialog>
</>
);
};
@@ -0,0 +1,40 @@
import { useLingui } from '@lingui/react/macro';
import type { ContentDragDropItem } from '../envelope-editor-content-drag-drop';
type EnvelopeCanvasPendingContentMenuProps = {
/**
* The palette items on offer, e.g. without images once the organisation's
* image allowance is used up.
*/
items: ContentDragDropItem[];
onSelectItem: (item: ContentDragDropItem) => void;
};
/**
* The content type picker shown after drawing a marquee on an empty area of
* the page while editing contents, to create a content of that size.
*/
export const EnvelopeCanvasPendingContentMenu = ({ items, onSelectItem }: EnvelopeCanvasPendingContentMenuProps) => {
const { t } = useLingui();
return (
<div
// Don't use darkmode for this component, it should look the same for both light/dark modes.
className="flex w-max items-center gap-x-1 rounded-md border border-gray-300 bg-white p-1 text-gray-500 shadow-sm"
>
{items.map((item) => (
<button
key={item.key}
type="button"
onClick={() => onSelectItem(item)}
className="flex flex-shrink-0 items-center gap-x-1.5 rounded-sm px-2 py-1 text-xs hover:bg-gray-100 hover:text-gray-600"
>
<item.icon className="h-3.5 w-3.5" />
{t(item.name)}
</button>
))}
</div>
);
};
@@ -0,0 +1,34 @@
import { useLingui } from '@lingui/react/macro';
import type { FieldType } from '@prisma/client';
import { fieldButtonList } from '../envelope-editor-fields-drag-drop';
type EnvelopeCanvasPendingFieldMenuProps = {
onSelectType: (type: FieldType) => void;
};
/**
* The field type picker shown after drawing a marquee on an empty area of the
* page, to create a field of that size.
*/
export const EnvelopeCanvasPendingFieldMenu = ({ onSelectType }: EnvelopeCanvasPendingFieldMenuProps) => {
const { t } = useLingui();
return (
<div
// Don't use darkmode for this component, it should look the same for both light/dark modes.
className="grid w-max grid-cols-5 gap-x-1 gap-y-0.5 rounded-md border border-gray-300 bg-white p-1 text-gray-500 shadow-sm"
>
{fieldButtonList.map((field) => (
<button
key={field.type}
type="button"
onClick={() => onSelectType(field.type)}
className="col-span-1 w-full flex-shrink-0 rounded-sm px-2 py-1 text-xs hover:bg-gray-100 hover:text-gray-600"
>
{t(field.name)}
</button>
))}
</div>
);
};
@@ -0,0 +1,59 @@
import { CONTENT_GROUP_NODE_NAME } from '@documenso/lib/universal/content-renderer/content-renderer';
import type Konva from 'konva';
import type { RefObject } from 'react';
/**
* The shared canvas handles and page metrics passed to the envelope canvas
* hooks, sourced from `usePageRenderer` and the page render data.
*/
export type EnvelopeCanvas = {
stage: RefObject<Konva.Stage | null>;
pageLayer: RefObject<Konva.Layer | null>;
/**
* The scale of the stage relative to the unscaled page.
*/
scale: number;
pageNumber: number;
/**
* The raw page size in pixels.
*/
unscaledViewport: { width: number; height: number };
/**
* The page size in pixels as rendered on the stage.
*/
scaledViewport: { width: number; height: number };
};
export type EnvelopeCanvasSelectionKind = 'field' | 'content';
/**
* The current canvas selection. Fields and contents share one transformer, so
* only one kind can be selected at a time.
*/
export type EnvelopeCanvasSelection = {
kind: EnvelopeCanvasSelectionKind;
groups: Konva.Group[];
} | null;
/**
* The Konva group names for each selectable kind.
*/
export const ENVELOPE_CANVAS_GROUP_NAMES: Record<EnvelopeCanvasSelectionKind, string> = {
// Defined by the field renderer in `field-generic-items.ts`.
field: 'field-group',
content: CONTENT_GROUP_NODE_NAME,
};
/**
* A box in scaled stage coordinates, e.g. from a client rect.
*/
export type EnvelopeCanvasBox = {
x: number;
y: number;
width: number;
height: number;
};
@@ -0,0 +1,112 @@
import Konva from 'konva';
import type { EnvelopeCanvasSelectionKind } from './envelope-canvas-types';
type ReconcileEnvelopeCanvasGroupsOptions<T> = {
layer: Konva.Layer;
/**
* The Konva group name of the items being reconciled.
*/
groupName: string;
items: T[];
getRenderId: (item: T) => string;
render: (item: T) => void;
};
/**
* Sync a layer's groups of a given name with a list of items.
*
* Groups whose ID no longer matches an item are destroyed, then every item is
* (re)rendered so existing groups pick up any changes.
*/
export const reconcileEnvelopeCanvasGroups = <T>({
layer,
groupName,
items,
getRenderId,
render,
}: ReconcileEnvelopeCanvasGroupsOptions<T>) => {
const renderIds = new Set(items.map(getRenderId));
layer.find('Group').forEach((group) => {
if (group.name() === groupName && !renderIds.has(group.id())) {
group.destroy();
}
});
for (const item of items) {
render(item);
}
};
/**
* Filter selected groups down to those which are still attached to the stage
* and still correspond to an item on the page, e.g. after items are deleted
* or the page is resynced.
*/
export const getLiveEnvelopeCanvasGroups = (groups: Konva.Group[], isOnPage: (renderId: string) => boolean) => {
return groups.filter((group) => Boolean(group.getStage()) && Boolean(group.getParent()) && isOnPage(group.id()));
};
type SyncEditorSelectionToCanvasOptions = {
layer: Konva.Layer;
kind: EnvelopeCanvasSelectionKind;
/**
* The form ID of the item selected within the editor, if any.
*/
editorFormId: string | null;
isOnPage: (renderId: string) => boolean;
/**
* The currently selected groups of this kind.
*/
selectedGroups: Konva.Group[];
select: (kind: EnvelopeCanvasSelectionKind, nodes: Konva.Node[]) => void;
clear: () => void;
};
/**
* Sync the editor's single selected item onto the canvas selection.
*
* Creating an item marks it as selected within the editor, so this makes a
* newly placed item show its transformer immediately without a second click.
* It also clears the canvas selection when the editor selection is cleared,
* so a stale transformer can't linger.
*
* Must run after the groups have been rendered so the item's group exists.
*/
export const syncEditorSelectionToCanvas = ({
layer,
kind,
editorFormId,
isOnPage,
selectedGroups,
select,
clear,
}: SyncEditorSelectionToCanvasOptions) => {
const isSingleSelection = selectedGroups.length === 1;
if (editorFormId && isOnPage(editorFormId)) {
const isAlreadySelected = isSingleSelection && selectedGroups[0].id() === editorFormId;
if (isAlreadySelected) {
return;
}
const groupToSelect = layer.findOne(`#${editorFormId}`);
if (groupToSelect instanceof Konva.Group) {
select(kind, [groupToSelect]);
}
return;
}
if (editorFormId === null && isSingleSelection) {
clear();
}
};
@@ -0,0 +1,448 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import type { TLocalContent } from '@documenso/lib/client-only/hooks/use-editor-contents';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import type { TEnvelopeContentMeta } from '@documenso/lib/types/envelope-content-meta';
import {
CONTENT_META_DEFAULT_VALUES,
CONTENT_SHAPE_META_DEFAULT_VALUES_BY_SHAPE,
EnvelopeContentShapeType,
EnvelopeContentType,
} from '@documenso/lib/types/envelope-content-meta';
import {
readContentGroupTransform,
resolveContentMetaFromTransform,
} from '@documenso/lib/universal/content-renderer/content-geometry';
import {
MIN_CONTENT_HEIGHT_PX,
MIN_CONTENT_WIDTH_PX,
} from '@documenso/lib/universal/content-renderer/content-renderer';
import { renderContent } from '@documenso/lib/universal/content-renderer/render-content';
import { getLocalContentOrderId, sortContentsForRender } from '@documenso/lib/utils/envelope-content';
import { getClientSideContentTranslations } from '@documenso/lib/utils/envelope-content-translations';
import type { PercentageBox } from '@documenso/lib/utils/geometry';
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
import { useLingui } from '@lingui/react/macro';
import Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node';
import { useEffect, useMemo } from 'react';
import type { ContentDragDropItem } from '../envelope-editor-content-drag-drop';
import type { EnvelopeCanvas, EnvelopeCanvasBox } from './envelope-canvas-types';
import { ENVELOPE_CANVAS_GROUP_NAMES } from './envelope-canvas-types';
import {
getLiveEnvelopeCanvasGroups,
reconcileEnvelopeCanvasGroups,
syncEditorSelectionToCanvas,
} from './reconcile-envelope-canvas-groups';
import { findEnvelopeCanvasGroupsInBox } from './use-envelope-canvas-marquee';
import { useEnvelopeCanvasPendingCreation } from './use-envelope-canvas-pending-creation';
import type { EnvelopeCanvasSelectionApi } from './use-envelope-canvas-selection';
const PENDING_CONTENT_NODE_NAME = 'pending-content-creation';
type UseEnvelopeCanvasContentsLayerOptions = {
canvas: EnvelopeCanvas;
selection: EnvelopeCanvasSelectionApi;
/**
* Whether contents can currently be edited on the canvas.
*/
isEditable: boolean;
/**
* Whether the envelope has used up the organisation's content allowance,
* in which case no further contents can be created from the canvas.
*/
isContentLimitReached: boolean;
applyPageItemsVisibility: () => void;
};
/**
* Renders and manages the contents of the current page on the canvas.
*
* Owns content rendering, z-ordering relative to fields, geometry write back,
* the marquee-to-create flow and the selection actions for contents.
*/
export const useEnvelopeCanvasContentsLayer = ({
canvas,
selection,
isEditable,
isContentLimitReached,
applyPageItemsVisibility,
}: UseEnvelopeCanvasContentsLayerOptions) => {
const { i18n } = useLingui();
const analytics = useAnalytics();
const { envelope, editorContents, selectedEditorTab } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem, contentImages, setRenderError } = useCurrentEnvelopeRender();
const { stage, pageLayer, scale, pageNumber, unscaledViewport } = canvas;
const { contentGroups: selectedGroups } = selection;
/**
* The rectangle drawn via the marquee which is pending a content type choice.
*/
const pending = useEnvelopeCanvasPendingCreation({ canvas, nodeName: PENDING_CONTENT_NODE_NAME });
/**
* The page's contents in stacking order, so rendering them in sequence
* puts the last one on top. Local contents are ordered by their form ID as
* the tiebreak, the same way persisted contents are ordered by their ID.
*/
const localPageContents = useMemo(
() =>
sortContentsForRender(
editorContents.localContents
.filter(
(content) => content.contentMeta.page === pageNumber && content.envelopeItemId === currentEnvelopeItem?.id,
)
.map((content) => ({ ...content, id: getLocalContentOrderId(content) })),
(content) => content.contentMeta.zIndex,
),
[editorContents.localContents, pageNumber, currentEnvelopeItem?.id],
);
const isOnPage = (formId: string) => localPageContents.some((content) => content.formId === formId);
/**
* Write the new geometry of a content back into its content meta after a drag
* or resize/rotate gesture.
*/
const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
const isDragEvent = event.type === 'dragend';
const contentGroup = event.target as Konva.Group;
const contentFormId = contentGroup.id();
const content = editorContents.getContentByFormId(contentFormId);
if (!content) {
return;
}
editorContents.updateContentByFormId(contentFormId, {
contentMeta: resolveContentMetaFromTransform(
content.contentMeta,
readContentGroupTransform(contentGroup),
isDragEvent ? 'drag' : 'transform',
unscaledViewport.width,
unscaledViewport.height,
),
});
// Select the content if it is not already selected.
if (isDragEvent && !selection.isSelected(contentGroup)) {
selection.select('content', [contentGroup]);
}
pageLayer.current?.batchDraw();
};
const unsafeRenderContent = (content: TLocalContent) => {
if (!pageLayer.current) {
return;
}
const { contentGroup } = renderContent(
{
renderId: content.formId,
contentMeta: content.contentMeta,
dataContentId: content.dataContentId,
},
{
pageLayer: pageLayer.current,
pageWidth: unscaledViewport.width,
pageHeight: unscaledViewport.height,
scale,
mode: 'edit',
editable: isEditable,
translations: getClientSideContentTranslations(i18n),
hoverOutlineColor: getRecipientColorStyles('green').baseRing,
images: contentImages.images,
},
);
// Contents are inert while the fields tab is active so they behave as part
// of the document. Their stacking relative to the fields is applied to
// all of them together afterwards, see `applyContentsStacking`.
contentGroup.listening(isEditable);
if (!isEditable) {
return;
}
contentGroup.off('click');
contentGroup.off('transformend');
contentGroup.off('dragend');
// A plain click selects just this content, shift + click toggles it
// in/out of the current selection.
contentGroup.on('click', (event) => {
if (event.evt.shiftKey) {
selection.toggle('content', contentGroup);
} else {
selection.select('content', [contentGroup]);
}
pageLayer.current?.batchDraw();
});
contentGroup.on('transformend', handleResizeOrMove);
contentGroup.on('dragend', handleResizeOrMove);
};
const renderContentOnLayer = (content: TLocalContent) => {
try {
unsafeRenderContent(content);
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'editor',
location: 'envelope_page_render',
envelopeId: envelope.id,
});
setRenderError(true);
}
};
/**
* Place the contents as a band relative to the fields, keeping their
* stacking order intact: above the muted fields while the contents tab is
* active so they are fully visible and clickable, beneath the fields
* otherwise as part of the document.
*
* Konva stacks by child order, so the band is moved one group at a time.
* Sending to the bottom iterates in reverse so the last content stays on
* top, as when sending to the top in order.
*/
const applyContentsStacking = () => {
const layer = pageLayer.current;
if (!layer) {
return;
}
const groups = localPageContents.flatMap((content) => {
const group = layer.findOne(`#${content.formId}`);
return group instanceof Konva.Group ? [group] : [];
});
if (selectedEditorTab === 'contents') {
for (const group of groups) {
group.moveToTop();
}
} else {
for (const group of [...groups].reverse()) {
group.moveToBottom();
}
}
};
const renderAll = () => {
for (const content of localPageContents) {
renderContentOnLayer(content);
}
applyContentsStacking();
};
/**
* Resolve a marquee selection box into a content selection, or into a
* pending content creation when nothing was selected and the box is large
* enough.
*/
const selectInBox = (box: EnvelopeCanvasBox) => {
const currentStage = stage.current;
if (!currentStage || !isEditable) {
return;
}
const groupsInBox = findEnvelopeCanvasGroupsInBox(currentStage, ENVELOPE_CANVAS_GROUP_NAMES.content, box);
selection.select('content', groupsInBox);
const unscaledBoxWidth = box.width / scale;
const unscaledBoxHeight = box.height / scale;
// Create a content if no items are selected or the size is too small.
if (
groupsInBox.length === 0 &&
!isContentLimitReached &&
unscaledBoxWidth > MIN_CONTENT_WIDTH_PX &&
unscaledBoxHeight > MIN_CONTENT_HEIGHT_PX
) {
pending.setPendingFromBox(box);
}
};
/**
* Create a content of the given palette item from the pending creation
* rectangle.
*
* Unlike a palette drop, which places a content at its default size, the
* content is fitted to the box the author drew.
*/
const createFromPending = (item: ContentDragDropItem) => {
const box = pending.getPendingBox();
pending.clearPending();
if (!box || !currentEnvelopeItem) {
return;
}
editorContents.addContent({
envelopeItemId: currentEnvelopeItem.id,
contentMeta: buildContentMetaFromBox(item, pageNumber, box),
});
};
/**
* Render contents when they are added, removed or updated, and when an
* image arrives after the page was created (a failed image simply keeps
* its placeholder so the author can re-upload).
*/
useEffect(() => {
const layer = pageLayer.current;
if (!layer || !stage.current) {
return;
}
reconcileEnvelopeCanvasGroups({
layer,
groupName: ENVELOPE_CANVAS_GROUP_NAMES.content,
items: localPageContents,
getRenderId: (content) => content.formId,
render: renderContentOnLayer,
});
applyContentsStacking();
// Reconcile selection state with live content nodes after flush/sync updates.
const liveSelectedGroups = getLiveEnvelopeCanvasGroups(selectedGroups, isOnPage);
if (liveSelectedGroups.length !== selectedGroups.length) {
selection.select('content', liveSelectedGroups);
}
// Only sync while editable, otherwise a transformer would be attached
// to an inert group.
if (isEditable) {
syncEditorSelectionToCanvas({
layer,
kind: 'content',
editorFormId: editorContents.selectedContent?.formId ?? null,
isOnPage,
selectedGroups,
select: selection.select,
clear: selection.clear,
});
}
applyPageItemsVisibility();
selection.refreshTransformer();
layer.batchDraw();
}, [
localPageContents,
selectedGroups,
selectedEditorTab,
isEditable,
editorContents.selectedContent?.formId,
contentImages.images,
]);
/**
* Selecting a single content brings it to the front of its page, so the
* content being worked on is never hidden behind another.
*/
useEffect(() => {
if (isEditable && selectedGroups.length === 1) {
editorContents.bringContentToFront(selectedGroups[0].id());
}
}, [isEditable, selectedGroups]);
/**
* Clear any active content selection and pending content creation when
* contents are no longer editable, e.g. when switching back to the fields
* tab or hiding contents via the viewer toolbar.
*/
useEffect(() => {
if (!isEditable && (selectedGroups.length > 0 || pending.pendingCreation)) {
pending.clearPending();
selection.clear();
pageLayer.current?.batchDraw();
}
}, [isEditable, selectedGroups, pending.pendingCreation]);
const getSelectedContents = () =>
selectedGroups
.map((group) => editorContents.getContentByFormId(group.id()))
.filter((content) => content !== undefined);
const deleteSelected = () => {
editorContents.removeContentsByFormId(selectedGroups.map((group) => group.id()));
selection.clear();
};
const duplicateSelected = () => {
for (const content of getSelectedContents()) {
editorContents.duplicateContent(content);
}
};
return {
localPageContents,
renderAll,
selectInBox,
pendingCreation: pending.pendingCreation,
createFromPending,
clearPending: pending.clearPending,
deleteSelected,
duplicateSelected,
};
};
/**
* Build the meta of a content created from a box drawn on the page, merging
* the box into the default values of the item.
*
* Box contents take the box as their bounds. Lines have no box of their own,
* so they run horizontally along its top edge, from the top left to the top
* right corner.
*/
const buildContentMetaFromBox = (item: ContentDragDropItem, page: number, box: PercentageBox): TEnvelopeContentMeta => {
// Shapes share a content type and are told apart by `shape`.
const defaultMeta =
item.type === EnvelopeContentType.SHAPE
? CONTENT_SHAPE_META_DEFAULT_VALUES_BY_SHAPE[item.shape ?? EnvelopeContentShapeType.RECTANGLE]
: CONTENT_META_DEFAULT_VALUES[item.type];
const meta = { ...structuredClone(defaultMeta), page };
if (meta.type === EnvelopeContentType.LINE) {
return {
...meta,
x1: box.positionX,
y1: box.positionY,
x2: box.positionX + box.width,
y2: box.positionY,
};
}
return {
...meta,
positionX: box.positionX,
positionY: box.positionY,
width: box.width,
height: box.height,
};
};
@@ -0,0 +1,472 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import type { EnvelopePageItemsVisibility } from '@documenso/lib/types/envelope-page-items-visibility';
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
import { MIN_FIELD_HEIGHT_PX, MIN_FIELD_WIDTH_PX } from '@documenso/lib/universal/field-renderer/field-renderer';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
import { getOverlappingFieldPairs } from '@documenso/lib/utils/fields-overlap';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { useLingui } from '@lingui/react/macro';
import type { FieldType } from '@prisma/client';
import Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node';
import { useEffect, useMemo } from 'react';
import type { EnvelopeCanvas, EnvelopeCanvasBox } from './envelope-canvas-types';
import { ENVELOPE_CANVAS_GROUP_NAMES } from './envelope-canvas-types';
import {
getLiveEnvelopeCanvasGroups,
reconcileEnvelopeCanvasGroups,
syncEditorSelectionToCanvas,
} from './reconcile-envelope-canvas-groups';
import { findEnvelopeCanvasGroupsInBox } from './use-envelope-canvas-marquee';
import { useEnvelopeCanvasPendingCreation } from './use-envelope-canvas-pending-creation';
import type { EnvelopeCanvasSelectionApi } from './use-envelope-canvas-selection';
const PENDING_FIELD_NODE_NAME = 'pending-field-creation';
type UseEnvelopeCanvasFieldsLayerOptions = {
canvas: EnvelopeCanvas;
selection: EnvelopeCanvasSelectionApi;
fieldsVisibility: EnvelopePageItemsVisibility;
applyPageItemsVisibility: () => void;
};
/**
* Renders and manages the fields of the current page on the canvas.
*
* Owns field rendering, overlap highlighting, geometry write back, the
* marquee-to-create flow and the selection actions for fields.
*/
export const useEnvelopeCanvasFieldsLayer = ({
canvas,
selection,
fieldsVisibility,
applyPageItemsVisibility,
}: UseEnvelopeCanvasFieldsLayerOptions) => {
const { i18n } = useLingui();
const analytics = useAnalytics();
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
const { stage, pageLayer, scale, pageNumber, unscaledViewport, scaledViewport } = canvas;
const { fieldGroups: selectedGroups, isTransforming } = selection;
/**
* The rectangle drawn via the marquee which is pending a field type choice.
*/
const pending = useEnvelopeCanvasPendingCreation({ canvas, nodeName: PENDING_FIELD_NODE_NAME });
const localPageFields = useMemo(
() =>
editorFields.localFields.filter(
(field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
),
[editorFields.localFields, pageNumber, currentEnvelopeItem?.id],
);
const isOnPage = (formId: string) => localPageFields.some((field) => field.formId === formId);
/**
* Debounce the fields used for overlap highlighting so we don't recompute on every
* small drag/resize tick. Overlaps only occur within the same page and envelope
* item, so computing from this page's fields alone is sufficient.
*/
const debouncedPageFields = useDebouncedValue(localPageFields, 300);
const overlappingFieldFormIds = useMemo(() => {
const formIds = new Set<string>();
const pairs = getOverlappingFieldPairs(
debouncedPageFields.map((field) => ({
id: field.formId,
envelopeItemId: field.envelopeItemId,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
})),
);
for (const pair of pairs) {
formIds.add(pair.fieldA.id);
formIds.add(pair.fieldB.id);
}
return formIds;
}, [debouncedPageFields]);
/**
* Write the new geometry of a field back after a drag or resize gesture.
*/
const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
const isDragEvent = event.type === 'dragend';
const fieldGroup = event.target as Konva.Group;
const fieldFormId = fieldGroup.id();
// Note: This values are scaled.
const {
width: fieldPixelWidth,
height: fieldPixelHeight,
x: fieldX,
y: fieldY,
} = fieldGroup.getClientRect({
skipStroke: true,
skipShadow: true,
});
const pageHeight = scaledViewport.height;
const pageWidth = scaledViewport.width;
// Calculate x and y as a percentage of the page width and height
const positionPercentX = (fieldX / pageWidth) * 100;
const positionPercentY = (fieldY / pageHeight) * 100;
// Get the bounds as a percentage of the page width and height
const fieldPageWidth = (fieldPixelWidth / pageWidth) * 100;
const fieldPageHeight = (fieldPixelHeight / pageHeight) * 100;
const fieldUpdates: Partial<TLocalField> = {
positionX: positionPercentX,
positionY: positionPercentY,
};
// Do not update the width/height unless the field has actually been resized.
// This is because our calculations will shift the width/height slightly
// due to the way we convert between pixel and percentage.
if (!isDragEvent) {
fieldUpdates.width = fieldPageWidth;
fieldUpdates.height = fieldPageHeight;
}
editorFields.updateFieldByFormId(fieldFormId, fieldUpdates);
// Select the field if it is not already selected.
if (isDragEvent && !selection.isSelected(fieldGroup)) {
selection.select('field', [fieldGroup]);
}
pageLayer.current?.batchDraw();
};
/**
* Draws (or removes) a dashed warning outline over a field that significantly
* overlaps another field. The highlight is a child of the field group so it moves
* and resizes with the field, and sits on top of the field's own rect (which is
* re-styled on every render and would otherwise clobber a direct stroke change).
*/
const syncOverlapHighlight = (fieldGroup: Konva.Group, isOverlapping: boolean) => {
const existingHighlight = fieldGroup.findOne('.field-overlap-highlight');
// Skip while a field is actively being dragged/resized. The highlight is driven
// by debounced field data, so it would lag behind and distort during the gesture.
// It is repainted once the gesture settles (the effect re-runs on isTransforming).
if (isTransforming || !isOverlapping) {
existingHighlight?.destroy();
return;
}
const fieldRect = fieldGroup.findOne('.field-rect');
if (!fieldRect) {
return;
}
const highlightAttrs = {
x: 0,
y: 0,
width: fieldRect.width(),
height: fieldRect.height(),
stroke: '#f59e0b',
strokeWidth: 2,
dash: [6, 4],
cornerRadius: 2,
strokeScaleEnabled: false,
listening: false,
} satisfies Partial<Konva.RectConfig>;
if (existingHighlight instanceof Konva.Rect) {
existingHighlight.setAttrs(highlightAttrs);
existingHighlight.moveToTop();
return;
}
const highlight = new Konva.Rect({
name: 'field-overlap-highlight',
...highlightAttrs,
});
fieldGroup.add(highlight);
highlight.moveToTop();
};
const unsafeRenderField = (field: TLocalField) => {
if (!pageLayer.current) {
return;
}
// Muted fields are rendered with the read-only styling and cannot be
// edited, mirroring how other recipients' fields look during signing.
const isMuted = fieldsVisibility === 'muted';
const recipient = envelope.recipients.find((r) => r.id === field.recipientId);
const isFieldEditable =
!isMuted && recipient !== undefined && canRecipientFieldsBeModified(recipient, envelope.fields);
const { fieldGroup } = renderField({
scale,
pageLayer: pageLayer.current,
field: {
renderId: field.formId,
...field,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta,
},
translations: getClientSideFieldTranslations(i18n),
pageWidth: unscaledViewport.width,
pageHeight: unscaledViewport.height,
color: isMuted ? 'readOnly' : getRecipientColorKey(field.recipientId),
editable: isFieldEditable,
mode: 'edit',
});
syncOverlapHighlight(fieldGroup, overlappingFieldFormIds.has(field.formId));
if (!isFieldEditable) {
return;
}
fieldGroup.off('click');
fieldGroup.off('transformend');
fieldGroup.off('dragend');
// A plain click selects just this field, shift + click toggles it in/out
// of the current selection.
fieldGroup.on('click', (event) => {
pending.clearPending();
if (event.evt.shiftKey) {
selection.toggle('field', fieldGroup);
} else {
selection.select('field', [fieldGroup]);
}
pageLayer.current?.batchDraw();
});
fieldGroup.on('transformend', handleResizeOrMove);
fieldGroup.on('dragend', handleResizeOrMove);
};
const renderFieldOnLayer = (field: TLocalField) => {
try {
unsafeRenderField(field);
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'editor',
location: 'envelope_page_render',
envelopeId: envelope.id,
});
setRenderError(true);
}
};
/**
* Render every field on the page. Called when the page canvas is created.
*/
const renderAll = () => {
for (const field of localPageFields) {
renderFieldOnLayer(field);
}
};
/**
* Resolve a marquee selection box into a field selection, or into a pending
* field creation when nothing was selected and the box is large enough.
*/
const selectInBox = (box: EnvelopeCanvasBox) => {
const currentStage = stage.current;
if (!currentStage) {
return;
}
// While fields are hidden they cannot be selected or created.
if (fieldsVisibility !== 'visible') {
return;
}
const groupsInBox = findEnvelopeCanvasGroupsInBox(currentStage, ENVELOPE_CANVAS_GROUP_NAMES.field, box);
selection.select('field', groupsInBox);
const unscaledBoxWidth = box.width / scale;
const unscaledBoxHeight = box.height / scale;
// Create a field if no items are selected or the size is too small.
if (
groupsInBox.length === 0 &&
unscaledBoxWidth > MIN_FIELD_WIDTH_PX &&
unscaledBoxHeight > MIN_FIELD_HEIGHT_PX &&
editorFields.selectedRecipient &&
canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields)
) {
pending.setPendingFromBox(box);
}
};
/**
* Create a field of the given type from the pending creation rectangle.
*/
const createFromPending = (type: FieldType) => {
const box = pending.getPendingBox();
pending.clearPending();
if (!box || !currentEnvelopeItem || !editorFields.selectedRecipient) {
return;
}
editorFields.addField({
envelopeItemId: currentEnvelopeItem.id,
page: pageNumber,
type,
positionX: box.positionX,
positionY: box.positionY,
width: box.width,
height: box.height,
recipientId: editorFields.selectedRecipient.id,
fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[type]),
});
};
/**
* Render fields when they are added, removed or updated.
*/
useEffect(() => {
const layer = pageLayer.current;
if (!layer || !stage.current) {
return;
}
reconcileEnvelopeCanvasGroups({
layer,
groupName: ENVELOPE_CANVAS_GROUP_NAMES.field,
items: localPageFields,
getRenderId: (field) => field.formId,
render: renderFieldOnLayer,
});
// Reconcile selection state with live field nodes after flush/sync updates.
const liveSelectedGroups = getLiveEnvelopeCanvasGroups(selectedGroups, isOnPage);
if (liveSelectedGroups.length !== selectedGroups.length) {
selection.select('field', liveSelectedGroups);
}
syncEditorSelectionToCanvas({
layer,
kind: 'field',
editorFormId: editorFields.selectedField?.formId ?? null,
isOnPage,
selectedGroups,
select: selection.select,
clear: selection.clear,
});
applyPageItemsVisibility();
selection.refreshTransformer();
layer.batchDraw();
}, [
localPageFields,
selectedGroups,
overlappingFieldFormIds,
isTransforming,
editorFields.selectedField?.formId,
fieldsVisibility,
]);
/**
* Clear any active selection and pending field creation when fields are no
* longer visible, since the transformer and floating toolbars would
* otherwise remain anchored to hidden fields.
*/
useEffect(() => {
if (fieldsVisibility !== 'visible' && (selectedGroups.length > 0 || pending.pendingCreation)) {
pending.clearPending();
selection.clear();
pageLayer.current?.batchDraw();
}
}, [fieldsVisibility, selectedGroups, pending.pendingCreation]);
const getSelectedFields = () =>
selectedGroups.map((group) => editorFields.getFieldByFormId(group.id())).filter((field) => field !== undefined);
const deleteSelected = () => {
editorFields.removeFieldsByFormId(selectedGroups.map((group) => group.id()));
selection.clear();
};
const duplicateSelected = () => {
for (const field of getSelectedFields()) {
editorFields.duplicateField(field);
}
};
const duplicateSelectedOnAllPages = () => {
for (const field of getSelectedFields()) {
editorFields.duplicateFieldToAllPages(field);
}
selection.clear();
};
const changeSelectedRecipient = (recipientId: number) => {
for (const field of getSelectedFields()) {
if (field.recipientId !== recipientId) {
editorFields.updateFieldByFormId(field.formId, { recipientId, id: undefined });
}
}
};
const changeSelectedType = (type: FieldType) => {
for (const field of getSelectedFields()) {
if (field.type !== type) {
editorFields.updateFieldByFormId(field.formId, {
type,
fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[type]),
id: undefined,
});
}
}
};
return {
localPageFields,
renderAll,
selectInBox,
pendingCreation: pending.pendingCreation,
createFromPending,
clearPending: pending.clearPending,
deleteSelected,
duplicateSelected,
duplicateSelectedOnAllPages,
changeSelectedRecipient,
changeSelectedType,
};
};
@@ -0,0 +1,195 @@
import type { TLocalContent } from '@documenso/lib/client-only/hooks/use-editor-contents';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { EnvelopeContentType } from '@documenso/lib/types/envelope-content-meta';
import { resolveLineMetaFromPoints } from '@documenso/lib/universal/content-renderer/content-geometry';
import {
CONTENT_LINE_NODE_NAME,
calculateContentLineGeometry,
} from '@documenso/lib/universal/content-renderer/content-renderer';
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
import Konva from 'konva';
import { useEffect, useMemo, useState } from 'react';
import type { EnvelopeCanvas } from './envelope-canvas-types';
import type { EnvelopeCanvasSelectionApi } from './use-envelope-canvas-selection';
const LINE_ANCHOR_NODE_NAME = 'content-line-anchor';
/**
* The screen size radius of the line endpoint anchors.
*/
const LINE_ANCHOR_RADIUS = 5;
type UseEnvelopeCanvasLineAnchorsOptions = {
canvas: EnvelopeCanvas;
selection: EnvelopeCanvasSelectionApi;
isEditable: boolean;
localPageContents: TLocalContent[];
};
/**
* Draggable endpoint anchors for a single selected line content.
*
* Lines are move-only within the shared transformer, so the endpoints are
* edited through these custom anchors instead. Anchors are hidden while the
* line body itself is being dragged, and recreated afterwards.
*/
export const useEnvelopeCanvasLineAnchors = ({
canvas,
selection,
isEditable,
localPageContents,
}: UseEnvelopeCanvasLineAnchorsOptions) => {
const { editorContents } = useCurrentEnvelopeEditor();
const { pageLayer, scale, unscaledViewport, scaledViewport } = canvas;
const { contentGroups: selectedGroups, isTransforming } = selection;
/**
* Whether an anchor is being dragged.
*
* Tracked separately from the selection's `isTransforming` since that would
* destroy the anchors mid drag (this effect depends on it), whereas this only
* needs to hide the floating action bar.
*/
const [isDragging, setIsDragging] = useState(false);
/**
* The selected line content, if exactly one line content is selected.
*/
const selectedLineContent = useMemo((): TLocalContent | null => {
if (selectedGroups.length !== 1) {
return null;
}
const content = editorContents.getContentByFormId(selectedGroups[0].id());
return content?.contentMeta.type === EnvelopeContentType.LINE ? content : null;
// Depends on the contents themselves rather than the getter, which is
// stable, so the anchors follow the line as its coordinates change.
}, [selectedGroups, localPageContents, editorContents.getContentByFormId]);
useEffect(() => {
const layer = pageLayer.current;
if (!layer) {
return;
}
const destroyAnchors = () => {
for (const node of layer.find(`.${LINE_ANCHOR_NODE_NAME}`)) {
node.destroy();
}
};
destroyAnchors();
const contentMeta = selectedLineContent?.contentMeta;
if (!selectedLineContent || contentMeta?.type !== EnvelopeContentType.LINE || !isEditable || isTransforming) {
layer.batchDraw();
return;
}
const geometry = calculateContentLineGeometry(contentMeta, unscaledViewport.width, unscaledViewport.height);
const findLineNodes = () => {
const lineGroup = layer.findOne(`#${selectedLineContent.formId}`);
if (!(lineGroup instanceof Konva.Group)) {
return null;
}
const contentLine = lineGroup.findOne(`.${CONTENT_LINE_NODE_NAME}`);
if (!(contentLine instanceof Konva.Line)) {
return null;
}
return { lineGroup, contentLine };
};
const endpoints = [
{ pointIndex: 0, x: geometry.x + geometry.points[0], y: geometry.y + geometry.points[1] },
{ pointIndex: 2, x: geometry.x + geometry.points[2], y: geometry.y + geometry.points[3] },
];
for (const endpoint of endpoints) {
const anchor = new Konva.Circle({
name: LINE_ANCHOR_NODE_NAME,
x: endpoint.x,
y: endpoint.y,
// Compensate for the stage scale so the anchors keep a constant
// screen size, mirroring the transformer anchors.
radius: LINE_ANCHOR_RADIUS / scale,
fill: '#ffffff',
// Matches the transformer anchors, themed to the brand green.
stroke: getRecipientColorStyles('green').baseRing,
strokeWidth: 1.5,
strokeScaleEnabled: false,
draggable: true,
// Keep the anchor within the page bounds. Positions are in scaled
// stage coordinates.
dragBoundFunc: (pos) => ({
x: Math.max(0, Math.min(scaledViewport.width, pos.x)),
y: Math.max(0, Math.min(scaledViewport.height, pos.y)),
}),
});
anchor.on('dragstart', () => setIsDragging(true));
// Live preview while dragging the anchor.
anchor.on('dragmove', () => {
const lineNodes = findLineNodes();
if (!lineNodes) {
return;
}
const points = [...lineNodes.contentLine.points()];
points[endpoint.pointIndex] = anchor.x() - lineNodes.lineGroup.x();
points[endpoint.pointIndex + 1] = anchor.y() - lineNodes.lineGroup.y();
lineNodes.contentLine.points(points);
layer.batchDraw();
});
// Write the new endpoint positions back into the content meta. The
// reconcile effect re-renders the normalized geometry and recreates
// the anchors.
anchor.on('dragend', () => {
setIsDragging(false);
const lineNodes = findLineNodes();
if (!lineNodes) {
return;
}
editorContents.updateContentByFormId(selectedLineContent.formId, {
contentMeta: resolveLineMetaFromPoints(
contentMeta,
lineNodes.lineGroup.x(),
lineNodes.lineGroup.y(),
lineNodes.contentLine.points(),
unscaledViewport.width,
unscaledViewport.height,
),
});
});
layer.add(anchor);
anchor.moveToTop();
}
layer.batchDraw();
return () => {
destroyAnchors();
};
}, [selectedLineContent, isEditable, isTransforming, localPageContents, scale, scaledViewport]);
return { isDragging };
};
@@ -0,0 +1,201 @@
import { useLatestRef } from '@documenso/lib/client-only/hooks/use-latest-ref';
import { KONVA_SELECTION_FILL_COLOR } from '@documenso/lib/universal/konva/constants';
import Konva from 'konva';
import { useCallback } from 'react';
import { clamp } from 'remeda';
import type { EnvelopeCanvasBox } from './envelope-canvas-types';
type UseEnvelopeCanvasMarqueeOptions = {
/**
* Called when a marquee drag ends, with the selection box in scaled stage
* coordinates. The caller decides what the box selects.
*/
onSelect: (box: EnvelopeCanvasBox) => void;
/**
* Called when an empty area of the stage is clicked without dragging.
*/
onEmptyClick: () => void;
};
/**
* Drag-to-select marquee on an empty area of the stage.
*
* The callbacks are read through refs, so the once-bound stage handlers always
* delegate to the latest render's logic.
*/
export const useEnvelopeCanvasMarquee = ({ onSelect, onEmptyClick }: UseEnvelopeCanvasMarqueeOptions) => {
const onSelectRef = useLatestRef(onSelect);
const onEmptyClickRef = useLatestRef(onEmptyClick);
/**
* Bind the marquee to a stage. Called once when the page canvas is created.
*/
const bind = useCallback((stage: Konva.Stage, layer: Konva.Layer) => {
const selectionRectangle = new Konva.Rect({
name: 'marquee-selection',
fill: KONVA_SELECTION_FILL_COLOR,
visible: false,
});
layer.add(selectionRectangle);
let x1 = 0;
let y1 = 0;
/**
* The pointer in layer coordinates, pinned to the page.
*
* Pointer positions are in scaled stage coordinates, while the rectangle
* lives on the scaled layer, so positions are divided by the stage scale.
*
* The pointer is clamped to the page so the rectangle stops at the page
* edge while the mouse itself is free to leave it, matching how a
* transformer resize behaves.
*/
const getPointer = () => {
const pointerPosition = stage.getPointerPosition();
if (!pointerPosition) {
return null;
}
return {
x: clamp(pointerPosition.x / stage.scaleX(), { min: 0, max: stage.width() / stage.scaleX() }),
y: clamp(pointerPosition.y / stage.scaleY(), { min: 0, max: stage.height() / stage.scaleY() }),
};
};
// The stage only receives pointer events while the pointer is over its
// container, so a drag which leaves the page would freeze the rectangle
// and never finish. Like Konva's own transformer, the move and up events
// are tracked on the window for the duration of a drag instead.
const onWindowPointerMove = (evt: MouseEvent | TouchEvent) => {
// The stage is rebuilt on zoom, which throws away this rectangle.
if (!selectionRectangle.getStage()) {
stopTrackingWindow();
return;
}
// The stage cannot see the pointer once it is outside the container, so
// register its position from the window event.
stage.setPointersPositions(evt);
const pointer = getPointer();
if (!pointer) {
return;
}
selectionRectangle.moveToTop();
selectionRectangle.setAttrs({
x: Math.min(x1, pointer.x),
y: Math.min(y1, pointer.y),
width: Math.abs(pointer.x - x1),
height: Math.abs(pointer.y - y1),
});
};
/**
* Whether the pointer was actually dragged, as opposed to a plain click
* which leaves the rectangle without an area.
*/
const isMarqueeDrawn = () => {
return selectionRectangle.visible() && selectionRectangle.width() > 0 && selectionRectangle.height() > 0;
};
const onWindowPointerUp = () => {
stopTrackingWindow();
if (!selectionRectangle.getStage()) {
return;
}
// Hide in a timeout so the click handler below can still detect that a
// marquee drag just finished.
setTimeout(() => {
selectionRectangle.visible(false);
});
// A plain click is not a selection, the click handler below deals with
// it. Selecting here would also match anything whose bounding box merely
// contains the point (e.g. a diagonal line), and since this runs after
// other window listeners it would override e.g. a content being placed.
if (!isMarqueeDrawn()) {
return;
}
onSelectRef.current(selectionRectangle.getClientRect());
};
const startTrackingWindow = () => {
window.addEventListener('mousemove', onWindowPointerMove);
window.addEventListener('touchmove', onWindowPointerMove);
window.addEventListener('mouseup', onWindowPointerUp);
window.addEventListener('touchend', onWindowPointerUp);
window.addEventListener('touchcancel', onWindowPointerUp);
};
const stopTrackingWindow = () => {
window.removeEventListener('mousemove', onWindowPointerMove);
window.removeEventListener('touchmove', onWindowPointerMove);
window.removeEventListener('mouseup', onWindowPointerUp);
window.removeEventListener('touchend', onWindowPointerUp);
window.removeEventListener('touchcancel', onWindowPointerUp);
};
stage.on('mousedown.marquee touchstart.marquee', (e) => {
// Do nothing if the pointer is down on a shape.
if (e.target !== stage) {
return;
}
const pointer = getPointer();
if (!pointer) {
return;
}
x1 = pointer.x;
y1 = pointer.y;
selectionRectangle.setAttrs({
x: x1,
y: y1,
width: 0,
height: 0,
visible: true,
});
startTrackingWindow();
});
stage.on('click.marquee tap.marquee', (e) => {
// A marquee drag just finished, the selection was handled on mouse up.
if (isMarqueeDrawn()) {
return;
}
if (e.target === stage) {
onEmptyClickRef.current();
}
});
}, []);
return { bind };
};
/**
* Find the groups of a given name which intersect a box, excluding groups
* which are not draggable (i.e. not currently editable).
*/
export const findEnvelopeCanvasGroupsInBox = (stage: Konva.Stage, groupName: string, box: EnvelopeCanvasBox) => {
return stage
.find(`.${groupName}`)
.filter(
(node): node is Konva.Group =>
node instanceof Konva.Group && node.draggable() && Konva.Util.haveIntersection(box, node.getClientRect()),
);
};
@@ -0,0 +1,88 @@
import { KONVA_SELECTION_FILL_COLOR } from '@documenso/lib/universal/konva/constants';
import type { PercentageBox } from '@documenso/lib/utils/geometry';
import { toPercentageBox } from '@documenso/lib/utils/geometry';
import Konva from 'konva';
import { useState } from 'react';
import type { EnvelopeCanvas, EnvelopeCanvasBox } from './envelope-canvas-types';
type UseEnvelopeCanvasPendingCreationOptions = {
canvas: EnvelopeCanvas;
/**
* The Konva name given to the pending rectangle, so it can be found and
* removed from the layer again.
*/
nodeName: string;
};
/**
* The rectangle left on the page after a marquee is drawn over an empty area,
* which is pending a choice of what to create within it.
*
* Shared by the fields and contents layers, which each decide when a marquee
* becomes a pending creation and what gets created from it.
*/
export const useEnvelopeCanvasPendingCreation = ({ canvas, nodeName }: UseEnvelopeCanvasPendingCreationOptions) => {
const { pageLayer, scale, unscaledViewport } = canvas;
const [pendingCreation, setPendingCreation] = useState<Konva.Rect | null>(null);
/**
* Remove any pending creation rectangle from the canvas.
*/
const clearPending = () => {
setPendingCreation(null);
for (const node of pageLayer.current?.find(`.${nodeName}`) ?? []) {
node.destroy();
}
};
/**
* Draw the pending rectangle for a marquee box, given in scaled stage
* coordinates.
*/
const setPendingFromBox = (box: EnvelopeCanvasBox) => {
const layer = pageLayer.current;
if (!layer) {
return;
}
// The rectangle lives on the scaled layer, so the box is unscaled first.
const pendingRect = new Konva.Rect({
name: nodeName,
x: box.x / scale,
y: box.y / scale,
width: box.width / scale,
height: box.height / scale,
fill: KONVA_SELECTION_FILL_COLOR,
});
layer.add(pendingRect);
setPendingCreation(pendingRect);
};
/**
* The pending rectangle as a percentage box of the page, or null when
* nothing is pending.
*/
const getPendingBox = (): PercentageBox | null => {
if (!pendingCreation) {
return null;
}
return toPercentageBox(
{
x: pendingCreation.x(),
y: pendingCreation.y(),
width: pendingCreation.width(),
height: pendingCreation.height(),
},
unscaledViewport,
);
};
return { pendingCreation, clearPending, setPendingFromBox, getPendingBox };
};
@@ -0,0 +1,258 @@
import { useLatestRef } from '@documenso/lib/client-only/hooks/use-latest-ref';
import type { TransformerSelectionConfig } from '@documenso/lib/universal/konva/transformer';
import {
boundTransformerBoxToPage,
DEFAULT_TRANSFORMER_SELECTION_CONFIG,
} from '@documenso/lib/universal/konva/transformer';
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
import Konva from 'konva';
import type { Transformer } from 'konva/lib/shapes/Transformer';
import { useCallback, useMemo, useRef, useState } from 'react';
import type { EnvelopeCanvasSelection, EnvelopeCanvasSelectionKind } from './envelope-canvas-types';
import { ENVELOPE_CANVAS_GROUP_NAMES } from './envelope-canvas-types';
/**
* A stable empty array so consumers of a kind which is not selected keep a
* stable dependency and don't re-render on every selection change.
*/
const EMPTY_GROUPS: Konva.Group[] = [];
/** How far past a resize handle you can still grab it, in screen pixels. */
const TRANSFORMER_ANCHOR_HIT_STROKE_PX = 24;
type UseEnvelopeCanvasSelectionOptions = {
/**
* Resolve the transformer configuration for a selection.
*/
getTransformerConfig: (kind: EnvelopeCanvasSelectionKind, groups: Konva.Group[]) => TransformerSelectionConfig;
/**
* Called synchronously whenever the selection changes, so the editor
* selection can be kept in sync without a frame of drift.
*/
onChange: (selection: EnvelopeCanvasSelection) => void;
};
/**
* The single canvas selection shared by fields and contents, along with the
* Konva transformer which acts upon it.
*
* The exposed functions are stable and read their options through refs, so
* they are safe to call from Konva handlers bound once at stage creation.
*/
export const useEnvelopeCanvasSelection = ({ getTransformerConfig, onChange }: UseEnvelopeCanvasSelectionOptions) => {
const transformerRef = useRef<Transformer | null>(null);
const [selection, setSelectionState] = useState<EnvelopeCanvasSelection>(null);
/**
* Whether a selected item is being dragged, resized or rotated.
*/
const [isTransforming, setIsTransforming] = useState(false);
const getTransformerConfigRef = useLatestRef(getTransformerConfig);
const onChangeRef = useLatestRef(onChange);
const applyTransformerConfig = useCallback((transformer: Transformer, currentSelection: EnvelopeCanvasSelection) => {
const config = currentSelection
? getTransformerConfigRef.current(currentSelection.kind, currentSelection.groups)
: DEFAULT_TRANSFORMER_SELECTION_CONFIG;
transformer.enabledAnchors(config.enabledAnchors);
transformer.rotateEnabled(config.rotateEnabled);
transformer.keepRatio(config.keepRatio);
transformer.borderEnabled(config.borderEnabled);
}, []);
const selectionRef = useLatestRef(selection);
/**
* Re-sync the transformer after the items change without the selection
* changing: re-resolve its configuration (e.g. an image attached to the
* selected content locks its ratio) and raise it back above the items.
*
* The handles straddle the selected item's edges, so any item stacked above
* the transformer hides part of them and takes their clicks. Rendering
* appends new items on top and the contents stacking raises every content,
* so each reconcile calls this afterwards.
*/
const refreshTransformer = useCallback(() => {
const transformer = transformerRef.current;
if (!transformer) {
return;
}
applyTransformerConfig(transformer, selectionRef.current);
transformer.moveToTop();
transformer.forceUpdate();
}, [applyTransformerConfig]);
const applySelection = useCallback((nextSelection: EnvelopeCanvasSelection) => {
const transformer = transformerRef.current;
if (transformer) {
// Configure the transformer before assigning nodes, since assigning
// nodes triggers an update using the current configuration.
applyTransformerConfig(transformer, nextSelection);
transformer.nodes(nextSelection?.groups ?? []);
}
if (nextSelection?.groups.length === 1) {
nextSelection.groups[0].moveToTop();
}
// Above the item just raised, so its handles stay visible and clickable.
transformer?.moveToTop();
setSelectionState(nextSelection);
onChangeRef.current(nextSelection);
}, []);
const select = useCallback(
(kind: EnvelopeCanvasSelectionKind, nodes: Konva.Node[]) => {
const groupName = ENVELOPE_CANVAS_GROUP_NAMES[kind];
const groups = nodes.filter(
(node): node is Konva.Group =>
node instanceof Konva.Group &&
node.hasName(groupName) &&
Boolean(node.getStage()) &&
Boolean(node.getParent()),
);
applySelection(groups.length > 0 ? { kind, groups } : null);
},
[applySelection],
);
/**
* Toggle a node in or out of the current selection (shift-click semantics).
*
* Nodes of a different kind than the toggled node are dropped, since only
* one kind can be selected at a time.
*/
const toggle = useCallback(
(kind: EnvelopeCanvasSelectionKind, node: Konva.Node) => {
const currentNodes = transformerRef.current?.nodes() ?? [];
const isAlreadySelected = currentNodes.includes(node);
select(kind, isAlreadySelected ? currentNodes.filter((current) => current !== node) : [...currentNodes, node]);
},
[select],
);
const clear = useCallback(() => {
applySelection(null);
}, [applySelection]);
const isSelected = useCallback((node: Konva.Node) => {
return (transformerRef.current?.nodes() ?? []).includes(node);
}, []);
/**
* Re-resolve the selection against the groups currently on the layer.
*
* The stage is destroyed and rebuilt whenever the page is rescaled (e.g.
* zooming), which leaves the selection holding detached nodes: the handles
* disappear, the action bar positions itself from an empty rect, and the
* next reconcile drops the selection altogether. Groups keep their render
* ID across the rebuild, so the equivalent new groups are selected instead.
*/
const reattachSelection = useCallback(
(layer: Konva.Layer) => {
const currentSelection = selectionRef.current;
if (!currentSelection) {
return;
}
const selectedIds = new Set(currentSelection.groups.map((group) => group.id()));
const groups = layer
.find(`.${ENVELOPE_CANVAS_GROUP_NAMES[currentSelection.kind]}`)
.filter((group) => selectedIds.has(group.id()));
select(currentSelection.kind, groups);
},
[select],
);
/**
* Create the transformer on a layer. Called once when the page canvas is
* created, and again whenever the stage is recreated.
*/
const attach = useCallback((layer: Konva.Layer) => {
// Match the brand green used for the first recipient's fields rather than
// Konva's default blue.
const selectionColor = getRecipientColorStyles('green').baseRing;
const transformer = new Konva.Transformer({
...DEFAULT_TRANSFORMER_SELECTION_CONFIG,
rotationSnaps: [0, 45, 90, 135, 180, 225, 270, 315],
rotationSnapTolerance: 5,
keepRatio: false,
borderStroke: selectionColor,
anchorStroke: selectionColor,
ignoreStroke: true,
flipEnabled: false,
anchorStyleFunc: (anchor) => {
// The stage is scaled to the page, so the hit area is divided by that
// scale to stay a constant size on screen.
const stageScale = layer.getStage()?.scaleX() ?? 1;
anchor.hitStrokeWidth(TRANSFORMER_ANCHOR_HIT_STROKE_PX / stageScale);
},
boundBoxFunc: (oldBox, newBox) => {
// Boxes are in absolute stage coordinates and the stage is the page,
// so pin the resize to the stage's own size.
const stage = layer.getStage();
const bounded = stage
? boundTransformerBoxToPage(newBox, { width: stage.width(), height: stage.height() })
: newBox;
// Enforce minimum size
if (bounded.width < 30 || bounded.height < 20) {
return oldBox;
}
return bounded;
},
});
layer.add(transformer);
// Konva fires transform events (resize and rotate) directly on the
// transformer and its nodes without bubbling, so they cannot be observed
// on the stage.
transformer.on('transformstart', () => setIsTransforming(true));
transformer.on('transformend', () => setIsTransforming(false));
transformerRef.current = transformer;
return transformer;
}, []);
const fieldGroups = useMemo(() => (selection?.kind === 'field' ? selection.groups : EMPTY_GROUPS), [selection]);
const contentGroups = useMemo(() => (selection?.kind === 'content' ? selection.groups : EMPTY_GROUPS), [selection]);
return {
selection,
fieldGroups,
contentGroups,
isTransforming,
setIsTransforming,
attach,
reattachSelection,
select,
toggle,
clear,
isSelected,
refreshTransformer,
};
};
export type EnvelopeCanvasSelectionApi = ReturnType<typeof useEnvelopeCanvasSelection>;
@@ -0,0 +1,323 @@
import { decodeLocalContentImage } from '@documenso/lib/client-only/load-content-image';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import {
APP_CONTENT_IMAGE_MIME_TYPES,
APP_CONTENT_IMAGE_UPLOAD_SIZE_LIMIT,
} from '@documenso/lib/constants/envelope-content';
import { AppError } from '@documenso/lib/errors/app-error';
import { EnvelopeContentType } from '@documenso/lib/types/envelope-content-meta';
import { resolveAttachedImageBox } from '@documenso/lib/universal/content-renderer/content-image-box';
import { nanoid } from '@documenso/lib/universal/id';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { PRESIGNED_DATA_CONTENT_ID_PREFIX } from '@documenso/lib/utils/embed-config';
import type { Size } from '@documenso/lib/utils/geometry';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter } from '@documenso/ui/primitives/dialog';
import { Trans, useLingui } from '@lingui/react/macro';
import { Loader2Icon } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { createCallable } from 'react-call';
import { match } from 'ts-pattern';
/**
* The DOM id of the dialog's file input, so the e2e suite can find it.
*/
export const getContentImageInputId = (formId: string) => `content-image-input-${formId}`;
type ContentImageUploadDialogProps = {
formId: string;
/**
* A file to upload straight away, e.g. one dropped onto the settings
* panel. Without it the file picker is opened first.
*/
file?: File;
};
type UploadStatus = 'picking' | 'uploading' | 'invalid-image' | 'too-large' | 'failed';
/**
* Why the server would reject the file, if it would, so the user finds out
* before it is uploaded. Applies to dropped and picked files alike.
*/
const getRejectionReason = (file: File): 'too-large' | 'invalid-image' | null => {
if (file.size > megabytesToBytes(APP_CONTENT_IMAGE_UPLOAD_SIZE_LIMIT)) {
return 'too-large';
}
if (!APP_CONTENT_IMAGE_MIME_TYPES.some((accepted) => accepted === file.type)) {
return 'invalid-image';
}
return null;
};
/**
* Attach an image to an image content.
*
* Blocks the editor from the moment it is opened until the upload settles,
* so there is only ever one upload in flight. Pending autosaves are flushed
* before uploading, so the content always has a server id by then.
*
* Invoke with `ContentImageUploadDialog.call({ formId })`; `Root` must be
* mounted inside the envelope editor and render providers.
*/
export const ContentImageUploadDialog = createCallable<ContentImageUploadDialogProps, void>(
({ call, formId, file: droppedFile }) => {
const { t } = useLingui();
const { editorContents, flushAutosave, envelope, isEmbedded } = useCurrentEnvelopeEditor();
const { contentImages, pageSizes } = useCurrentEnvelopeRender();
const { mutateAsync: uploadImage } = trpc.envelope.content.uploadImage.useMutation();
const inputRef = useRef<HTMLInputElement>(null);
const [status, setStatus] = useState<UploadStatus>(() => {
if (!droppedFile) {
return 'picking';
}
return getRejectionReason(droppedFile) ?? 'uploading';
});
/**
* Point the content at its new image, fitting its box to the image.
*
* `data` is the file of an image which has not been uploaded yet.
*/
const attachImage = (dataContentId: string, image: Size, data?: File) => {
// Re-read in case the content moved while the image was on its way.
const latest = editorContents.getContentByFormId(formId);
if (!latest || latest.contentMeta.type !== EnvelopeContentType.IMAGE) {
return;
}
const { contentMeta } = latest;
const page = pageSizes.get(latest.envelopeItemId, contentMeta.page);
const box = page
? resolveAttachedImageBox({
image,
page,
box: {
positionX: contentMeta.positionX,
positionY: contentMeta.positionY,
width: contentMeta.width,
height: contentMeta.height,
},
})
: null;
editorContents.updateContentByFormId(formId, {
dataContentId,
data,
contentMeta: box ? { ...contentMeta, ...box } : contentMeta,
});
};
const uploadToServer = async (file: File) => {
// The content must exist on the server before an image can be
// attached to it.
await flushAutosave();
const content = editorContents.getContentByFormId(formId);
if (!content?.id || content.contentMeta.type !== EnvelopeContentType.IMAGE) {
throw new AppError('CONTENT_NOT_PERSISTED');
}
const formData = new FormData();
formData.append('payload', JSON.stringify({ envelopeId: envelope.id, envelopeContentId: content.id }));
formData.append('file', file);
const { dataContent } = await uploadImage(formData);
// Decode the local file at the size the server normalized it to, so
// the preview matches what the route would serve.
const image = await createImageBitmap(file, {
resizeWidth: dataContent.metadata.width,
resizeHeight: dataContent.metadata.height,
resizeQuality: 'high',
}).catch(() => null);
if (image) {
contentImages.setImage(dataContent.id, image, {
fileSize: dataContent.metadata.fileSize,
});
}
attachImage(dataContent.id, dataContent.metadata);
};
const upload = async (file: File) => {
setStatus('uploading');
try {
if (isEmbedded) {
// Decoded the way the server will store it, which also rejects what
// the server would, since it only sees the image once the envelope is
// saved.
const { image, details } = await decodeLocalContentImage(file);
const dataContentId = `${PRESIGNED_DATA_CONTENT_ID_PREFIX}${nanoid()}`;
contentImages.setImage(dataContentId, image, details);
attachImage(dataContentId, image, file);
} else {
await uploadToServer(file);
}
call.end();
} catch (err) {
const error = AppError.parseError(err);
setStatus(
match(error.code)
.with('INVALID_IMAGE_FILE', (): UploadStatus => 'invalid-image')
.with('FILE_TOO_LARGE', (): UploadStatus => 'too-large')
.otherwise((): UploadStatus => 'failed'),
);
}
};
// Dismissing the picker without choosing closes the dialog too. Browsers
// fire `cancel` on the input for this, but React does not expose it as a
// prop, so it is attached natively.
useEffect(() => {
const input = inputRef.current;
if (!input) {
return;
}
const onCancel = () => call.end();
input.addEventListener('cancel', onCancel);
return () => input.removeEventListener('cancel', onCancel);
}, [call]);
// Start immediately: upload the dropped file, or open the picker so the
// user does not have to click twice. A dropped file the server would
// reject opens on the reason instead.
const hasStartedRef = useRef(false);
useEffect(() => {
if (hasStartedRef.current) {
return;
}
hasStartedRef.current = true;
if (!droppedFile) {
inputRef.current?.click();
return;
}
if (!getRejectionReason(droppedFile)) {
void upload(droppedFile);
}
}, []);
const onFilePicked = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
// Allow the same file to be picked again after a failure.
event.target.value = '';
if (!file) {
return;
}
const rejectionReason = getRejectionReason(file);
if (rejectionReason) {
setStatus(rejectionReason);
return;
}
void upload(file);
};
// Clicked directly rather than via state, since the status may already
// be `picking` and a state change alone would not reopen the picker.
const reopenPicker = () => {
setStatus('picking');
inputRef.current?.click();
};
const isBusy = status === 'uploading';
return (
<>
<input
ref={inputRef}
id={getContentImageInputId(formId)}
type="file"
accept={APP_CONTENT_IMAGE_MIME_TYPES.join(',')}
className="hidden"
onChange={onFilePicked}
/>
<Dialog open={true} onOpenChange={(open) => !open && !isBusy && call.end()}>
<DialogContent
className={cn('max-w-xs', {
hidden: status === 'picking',
})}
hideClose
onPointerDownOutside={(event) => isBusy && event.preventDefault()}
onEscapeKeyDown={(event) => isBusy && event.preventDefault()}
>
{match(status)
.with('picking', () => null) // Only show the overlay since the actual browser uploader should be shown.
.with('uploading', () => (
<div
className="flex flex-col items-center justify-center gap-2 py-4"
data-testid="content-image-uploading"
>
<Loader2Icon className="h-5 w-5 animate-spin text-muted-foreground" />
<DialogDescription>
<Trans>Uploading Image</Trans>
</DialogDescription>
</div>
))
.with('invalid-image', () => (
<DialogDescription className="text-destructive">
<Trans>This image could not be read. Use a PNG, JPEG or WebP file.</Trans>
</DialogDescription>
))
.with('too-large', () => (
<DialogDescription className="text-destructive">
<Trans>This image is larger than {APP_CONTENT_IMAGE_UPLOAD_SIZE_LIMIT} MB.</Trans>
</DialogDescription>
))
.with('failed', () => (
<DialogDescription className="text-destructive">
<Trans>The image could not be uploaded. Please try again.</Trans>
</DialogDescription>
))
.exhaustive()}
{!isBusy && (
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => call.end()}>
<Trans>Close</Trans>
</Button>
<Button type="button" onClick={reopenPicker}>
{status === 'picking' ? t`Choose image` : t`Try again`}
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
</>
);
},
);
@@ -0,0 +1,246 @@
import type { TLocalContent } from '@documenso/lib/client-only/hooks/use-editor-contents';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import {
CONTENT_HIGHLIGHT_META_DEFAULT_VALUES,
CONTENT_LINE_META_DEFAULT_VALUES,
CONTENT_SHAPE_META_DEFAULT_VALUES_BY_SHAPE,
CONTENT_TEXT_META_DEFAULT_VALUES,
EnvelopeContentType,
type TEnvelopeContentMeta,
ZContentHighlightMetaSchema,
ZContentLineMetaSchema,
ZContentShapeMetaSchema,
ZContentTextMetaSchema,
} from '@documenso/lib/types/envelope-content-meta';
import { Form } from '@documenso/ui/primitives/form/form';
import { zodResolver } from '@hookform/resolvers/zod';
import { createContext, useContext, useLayoutEffect, useState } from 'react';
import { type FieldValues, useForm, useFormContext } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
type ContentSettingsFormProviderProps = {
/**
* The content whose settings the form edits, or null when none is selected.
*/
content: TLocalContent | null;
children: React.ReactNode;
};
/**
* Hosts the single settings form for the selected content.
*
* Every surface which edits a content's presentational meta (the sidebar
* settings panel, the canvas action bar) binds to this one form via
* `useContentSettingsForm`, so there is only ever one copy of the values and
* nothing to keep in sync. The form is the sole writer of those keys onto the
* store; geometry is owned by the canvas and is never part of the form.
*
* The form's values are written to the store whenever they change while
* valid, merged onto the content's latest meta.
*
* The provider wraps the whole editor surface (including the PDF viewer), so
* it must not remount when the selection changes. Instead the one form is
* reset to the newly selected content's values.
*/
export const ContentSettingsFormProvider = ({ content, children }: ContentSettingsFormProviderProps) => {
const { editorContents } = useCurrentEnvelopeEditor();
const config = getContentSettingsFormConfig(content?.contentMeta ?? null);
// The resolver is read on every render, so the schema follows the selected
// content's type. The default values are only used for the initial mount;
// later selections are applied via `reset` below.
const form = useForm<FieldValues>({
resolver: zodResolver(config.schema),
mode: 'onChange',
defaultValues: config.defaultValues,
});
const formId = content?.formId ?? null;
/**
* The content the form currently holds the values of.
*
* The reset below runs after the render which changed the selection, so
* for that one render the form still holds the previous content's values.
* Inputs must not mount during it: a Radix select which mounts with one
* value and is immediately given another pushes an empty string back into
* the form (its hidden native select has no options yet), which surfaces
* as a spurious validation error.
*/
const [readyFormId, setReadyFormId] = useState<string | null>(null);
// Reset then subscribe, in that order, so the reset is not observed as an
// edit and selecting a content never writes anything by itself. A layout
// effect so the fields beneath never paint the previous content's values.
useLayoutEffect(() => {
form.reset(config.defaultValues);
setReadyFormId(formId);
if (!formId) {
return;
}
const subscription = form.watch((values) => {
const parsed = config.schema.safeParse(values);
if (!parsed.success) {
return;
}
editorContents.patchContentMeta(formId, parsed.data);
});
return () => subscription.unsubscribe();
// Only the selected content matters; its config is derived from it.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [form, formId, editorContents.patchContentMeta]);
const isReady = formId !== null && formId === readyFormId;
return (
<ContentSettingsFormContext.Provider value={{ content, isReady }}>
<Form {...form}>{children}</Form>
</ContentSettingsFormContext.Provider>
);
};
/**
* The settings form of the selected content, for the surfaces which edit it.
*
* Typed by the caller since the form's shape depends on the content type,
* which the caller has already narrowed on.
*/
export const useContentSettingsForm = <TForm extends FieldValues>() => {
const context = useContext(ContentSettingsFormContext);
if (!context) {
throw new Error('useContentSettingsForm must be used within a ContentSettingsFormProvider');
}
const form = useFormContext<TForm>();
return {
form,
/**
* The content the form is bound to, so a consumer holding a different
* content (e.g. a stale canvas selection) can tell it is not this one.
*/
content: context.content,
/**
* Whether the form holds the selected content's values yet. Inputs must
* only be mounted once it does, see the provider.
*/
isReady: context.isReady,
};
};
type ContentSettingsFormContextValue = {
content: TLocalContent | null;
isReady: boolean;
};
const ContentSettingsFormContext = createContext<ContentSettingsFormContextValue | null>(null);
type ContentSettingsFormConfig = {
schema: z.ZodType<Partial<TEnvelopeContentMeta>>;
defaultValues: FieldValues;
};
/**
* Only the presentational settings are editable in the form. Geometry (page,
* position, size and endpoints) is managed on the canvas.
*/
export const ZContentTextFormSchema = ZContentTextMetaSchema.pick({
text: true,
fontSize: true,
textAlign: true,
verticalAlign: true,
lineHeight: true,
letterSpacing: true,
color: true,
});
export type TContentTextFormSchema = z.infer<typeof ZContentTextFormSchema>;
export const ZContentLineFormSchema = ZContentLineMetaSchema.pick({
strokeWidth: true,
strokeColor: true,
strokeStyle: true,
});
export type TContentLineFormSchema = z.infer<typeof ZContentLineFormSchema>;
export const ZContentShapeFormSchema = ZContentShapeMetaSchema.pick({
strokeWidth: true,
strokeColor: true,
strokeStyle: true,
fillColor: true,
fillOpacity: true,
});
export type TContentShapeFormSchema = z.infer<typeof ZContentShapeFormSchema>;
export const ZContentHighlightFormSchema = ZContentHighlightMetaSchema.pick({
color: true,
fillOpacity: true,
});
export type TContentHighlightFormSchema = z.infer<typeof ZContentHighlightFormSchema>;
/**
* Contents without settings (images, or no selection) get an empty form so
* the form context always exists for whatever is mounted beneath it.
*/
const ZEmptyFormSchema = z.object({});
/**
* The schema and initial values of the settings form for a content, by type.
*/
const getContentSettingsFormConfig = (meta: TEnvelopeContentMeta | null): ContentSettingsFormConfig => {
if (!meta) {
return { schema: ZEmptyFormSchema, defaultValues: {} };
}
return match(meta)
.with({ type: EnvelopeContentType.TEXT }, (value) =>
createFormConfig(ZContentTextFormSchema, CONTENT_TEXT_META_DEFAULT_VALUES, value),
)
.with({ type: EnvelopeContentType.LINE }, (value) =>
createFormConfig(ZContentLineFormSchema, CONTENT_LINE_META_DEFAULT_VALUES, value),
)
.with({ type: EnvelopeContentType.SHAPE }, (value) =>
createFormConfig(ZContentShapeFormSchema, CONTENT_SHAPE_META_DEFAULT_VALUES_BY_SHAPE[value.shape], value),
)
.with({ type: EnvelopeContentType.HIGHLIGHT }, (value) =>
createFormConfig(ZContentHighlightFormSchema, CONTENT_HIGHLIGHT_META_DEFAULT_VALUES, value),
)
.with({ type: EnvelopeContentType.IMAGE }, () => ({ schema: ZEmptyFormSchema, defaultValues: {} }))
.exhaustive();
};
/**
* The form's initial values are the content's meta laid over the type's
* default meta, narrowed to the form's keys by the schema (which strips the
* rest, e.g. geometry).
*/
const createFormConfig = <TSchema extends z.ZodType<Partial<TEnvelopeContentMeta>>>(
schema: TSchema,
defaults: TEnvelopeContentMeta,
meta: TEnvelopeContentMeta,
): ContentSettingsFormConfig => {
const merged = { ...defaults, ...meta };
const parsed = schema.safeParse(merged);
return {
schema,
// The stored meta was validated on load, so this only falls back if the
// defaults themselves are ever inconsistent with the form schema.
defaultValues: parsed.success ? parsed.data : schema.parse(defaults),
};
};
@@ -0,0 +1,411 @@
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
import { useLatestRef } from '@documenso/lib/client-only/hooks/use-latest-ref';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import {
CONTENT_HIGHLIGHT_META_DEFAULT_VALUES,
CONTENT_IMAGE_META_DEFAULT_VALUES,
CONTENT_LINE_META_DEFAULT_VALUES,
CONTENT_SHAPE_META_DEFAULT_VALUES_BY_SHAPE,
CONTENT_TEXT_META_DEFAULT_VALUES,
CONTENT_TYPE_DATA_CONTENT_TYPE,
EnvelopeContentShapeType,
EnvelopeContentType,
type TEnvelopeContentMeta,
} from '@documenso/lib/types/envelope-content-meta';
import { CONTENT_IMAGE_DEFAULT_SIZE } from '@documenso/lib/universal/content-renderer/content-image-box';
import { canContentBeChanged } from '@documenso/lib/utils/envelope';
import { resolveEnvelopeContentLimits } from '@documenso/lib/utils/envelope-content';
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { Plural, Trans, useLingui } from '@lingui/react/macro';
import { DocumentStatus } from '@prisma/client';
import type { LucideIcon } from 'lucide-react';
import { HighlighterIcon, ImageIcon, ScanLineIcon, SquareIcon, TextIcon } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { match } from 'ts-pattern';
const MIN_HEIGHT_PX = 12;
const MIN_WIDTH_PX = 36;
const DEFAULT_HEIGHT_PX = MIN_HEIGHT_PX * 2.5;
const DEFAULT_WIDTH_PX = MIN_WIDTH_PX * 2.5;
export type ContentDragDropItem = {
key: string;
type: EnvelopeContentType;
shape?: EnvelopeContentShapeType;
icon: LucideIcon;
name: MessageDescriptor;
};
export const contentButtonList: ContentDragDropItem[] = [
{
key: EnvelopeContentType.TEXT,
type: EnvelopeContentType.TEXT,
icon: TextIcon,
name: msg`Text`,
},
{
key: EnvelopeContentType.LINE,
type: EnvelopeContentType.LINE,
icon: ScanLineIcon,
name: msg`Line`,
},
{
key: `${EnvelopeContentType.SHAPE}:${EnvelopeContentShapeType.RECTANGLE}`,
type: EnvelopeContentType.SHAPE,
shape: EnvelopeContentShapeType.RECTANGLE,
icon: SquareIcon,
name: msg`Rectangle`,
},
{
key: EnvelopeContentType.HIGHLIGHT,
type: EnvelopeContentType.HIGHLIGHT,
icon: HighlighterIcon,
name: msg`Highlight`,
},
{
key: EnvelopeContentType.IMAGE,
type: EnvelopeContentType.IMAGE,
icon: ImageIcon,
name: msg`Image`,
},
];
type EnvelopeEditorContentDragDropProps = {
selectedEnvelopeItemId: string | null;
};
export const EnvelopeEditorContentDragDrop = ({ selectedEnvelopeItemId }: EnvelopeEditorContentDragDropProps) => {
const { envelope, editorContents, setIsPlacingItem } = useCurrentEnvelopeEditor();
const organisation = useCurrentOrganisation();
const { t } = useLingui();
const contentLimits = resolveEnvelopeContentLimits(
editorContents.localContents.map((content) => content.contentMeta.type),
organisation.organisationClaim,
);
const [selectedContent, setSelectedContent] = useState<ContentDragDropItem | null>(null);
// Let the canvas know a content is being placed, so it can get its selection
// out of the way of the placement click.
useEffect(() => {
setIsPlacingItem(selectedContent !== null);
return () => {
setIsPlacingItem(false);
};
}, [selectedContent, setIsPlacingItem]);
const { isWithinPageBounds, getPage } = useDocumentElement();
const [isContentWithinBounds, setIsContentWithinBounds] = useState(false);
const [coords, setCoords] = useState({
x: 0,
y: 0,
});
const contentBounds = useRef({
height: 0,
width: 0,
});
const onMouseMove = useCallback(
(event: MouseEvent) => {
setIsContentWithinBounds(
isWithinPageBounds(event, PDF_VIEWER_PAGE_SELECTOR, contentBounds.current.width, contentBounds.current.height),
);
setCoords({
x: event.clientX - contentBounds.current.width / 2,
y: event.clientY - contentBounds.current.height / 2,
});
},
[isWithinPageBounds],
);
const onMouseClick = useCallback(
(event: MouseEvent) => {
if (!selectedContent || !selectedEnvelopeItemId) {
return;
}
const $page = getPage(event, PDF_VIEWER_PAGE_SELECTOR);
if (
!$page ||
!isWithinPageBounds(event, PDF_VIEWER_PAGE_SELECTOR, contentBounds.current.width, contentBounds.current.height)
) {
setSelectedContent(null);
return;
}
const { top, left, height, width } = getBoundingClientRect($page);
const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10);
// Calculate x and y as a percentage of the page width and height
let pageX = ((event.pageX - left) / width) * 100;
let pageY = ((event.pageY - top) / height) * 100;
// Get the bounds as a percentage of the page width and height
const contentPageWidth = (contentBounds.current.width / width) * 100;
const contentPageHeight = (contentBounds.current.height / height) * 100;
// And center it based on the bounds
pageX -= contentPageWidth / 2;
pageY -= contentPageHeight / 2;
editorContents.addContent({
envelopeItemId: selectedEnvelopeItemId,
contentMeta: buildContentMeta({
item: selectedContent,
page: pageNumber,
positionX: pageX,
positionY: pageY,
width: contentPageWidth,
height: contentPageHeight,
}),
});
setIsContentWithinBounds(false);
setSelectedContent(null);
},
[isWithinPageBounds, selectedContent, selectedEnvelopeItemId, getPage, editorContents],
);
const selectedContentRef = useLatestRef(selectedContent);
useEffect(() => {
const observer = new MutationObserver((_mutations) => {
const $page = document.querySelector(PDF_VIEWER_PAGE_SELECTOR);
if (!$page) {
return;
}
contentBounds.current = resolveDragBounds(selectedContentRef.current, $page);
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
return () => {
observer.disconnect();
};
}, []);
useEffect(() => {
if (selectedContent) {
const $page = document.querySelector(PDF_VIEWER_PAGE_SELECTOR);
if ($page) {
contentBounds.current = resolveDragBounds(selectedContent, $page);
}
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseClick);
}
return () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseClick);
};
}, [onMouseClick, onMouseMove, selectedContent]);
if (!canContentBeChanged(envelope)) {
return (
<Alert variant="neutral" className="rounded-lg border border-border">
<AlertDescription className="text-sm">
{match(envelope.status)
.with(DocumentStatus.COMPLETED, () => (
<Trans>This document has been completed, so its content can no longer be changed.</Trans>
))
.with(DocumentStatus.REJECTED, () => (
<Trans>This document has been rejected, so its content can no longer be changed.</Trans>
))
.otherwise(() => (
<Trans>Content cannot be changed because the document has already been sent.</Trans>
))}
</AlertDescription>
</Alert>
);
}
// The organisation's plan caps how much content an envelope may hold. Only
// adding is blocked: existing contents stay editable and removable so an
// envelope which went over its limit can be brought back down.
if (contentLimits.isContentLimitReached || contentLimits.isImageLimitReached) {
return (
<Alert variant="neutral" className="rounded-lg border border-border" data-testid="content-limit-reached-alert">
<AlertDescription className="text-sm">
{contentLimits.isContentLimitReached ? (
<Plural
value={contentLimits.contentLimit}
one="This envelope cannot have more than # content. Remove some, or contact support if you need more."
other="This envelope cannot have more than # contents. Remove some, or contact support if you need more."
/>
) : (
<Plural
value={contentLimits.imageLimit}
one="This envelope cannot have more than # image content. Remove some, or contact support if you need more."
other="This envelope cannot have more than # image contents. Remove some, or contact support if you need more."
/>
)}
</AlertDescription>
</Alert>
);
}
return (
<>
<div className="grid grid-cols-2 gap-x-2 gap-y-2.5">
{contentButtonList.map((content) => (
<button
key={content.key}
type="button"
onClick={() => setSelectedContent(content)}
onMouseDown={() => setSelectedContent(content)}
data-selected={selectedContent?.key === content.key ? true : undefined}
className="group flex h-12 cursor-pointer items-center justify-center rounded-lg border border-border px-4 transition-colors"
>
<p className="flex items-center justify-center gap-x-1.5 font-normal font-noto text-muted-foreground text-sm group-data-[selected]:text-foreground">
{<content.icon className="h-4 w-4" />}
{t(content.name)}
</p>
</button>
))}
</div>
{selectedContent && (
<div
className={cn(
'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] dark:text-muted',
// Match the brand green used for the first recipient's fields and
// the rest of the content editor's selection affordances.
getRecipientColorStyles('green').base,
{
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isContentWithinBounds,
'dark:text-black/60': isContentWithinBounds,
},
)}
style={{
top: coords.y,
left: coords.x,
height: contentBounds.current.height,
width: contentBounds.current.width,
}}
>
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">{t(selectedContent.name)}</span>
</div>
)}
</>
);
};
type BuildContentMetaOptions = {
item: ContentDragDropItem;
page: number;
positionX: number;
positionY: number;
width: number;
height: number;
};
/**
* Build the content meta for a newly dropped content, merging the drop
* geometry into the default values for the given content type.
*
* Geometry is handled per content type since it differs, e.g. lines use
* start/end coordinates instead of a position and size.
*/
/**
* The screen size of the drag preview for a content type.
*
* Image contents preview at their page relative default size so the preview
* matches the box which gets created, the rest use a fixed screen size.
*/
const resolveDragBounds = (item: ContentDragDropItem | null, $page: Element) => {
if (item !== null && CONTENT_TYPE_DATA_CONTENT_TYPE[item.type] !== undefined) {
const { width, height } = getBoundingClientRect($page);
return {
width: (width * CONTENT_IMAGE_DEFAULT_SIZE.width) / 100,
height: (height * CONTENT_IMAGE_DEFAULT_SIZE.height) / 100,
};
}
return {
width: DEFAULT_WIDTH_PX,
height: DEFAULT_HEIGHT_PX,
};
};
const buildContentMeta = ({
item,
page,
positionX,
positionY,
width,
height,
}: BuildContentMetaOptions): TEnvelopeContentMeta => {
const { type } = item;
return match(type)
.with(EnvelopeContentType.LINE, () => ({
...structuredClone(CONTENT_LINE_META_DEFAULT_VALUES),
page,
// Lines are placed horizontally across the drop area, centered vertically.
x1: positionX,
x2: positionX + width,
y1: positionY + height / 2,
y2: positionY + height / 2,
}))
.with(EnvelopeContentType.IMAGE, () => ({
...structuredClone(CONTENT_IMAGE_META_DEFAULT_VALUES),
page,
// Image contents use a page relative default size, so whether the
// author has resized the box can be told when an image is attached.
// Centered on the drop point like the other contents.
positionX: positionX + width / 2 - CONTENT_IMAGE_DEFAULT_SIZE.width / 2,
positionY: positionY + height / 2 - CONTENT_IMAGE_DEFAULT_SIZE.height / 2,
...CONTENT_IMAGE_DEFAULT_SIZE,
}))
.with(EnvelopeContentType.SHAPE, () => ({
...structuredClone(CONTENT_SHAPE_META_DEFAULT_VALUES_BY_SHAPE[item.shape ?? EnvelopeContentShapeType.RECTANGLE]),
page,
positionX,
positionY,
width,
height,
}))
.with(EnvelopeContentType.TEXT, () => ({
...structuredClone(CONTENT_TEXT_META_DEFAULT_VALUES),
text: '',
page,
positionX,
positionY,
width,
height,
}))
.with(EnvelopeContentType.HIGHLIGHT, () => ({
...structuredClone(CONTENT_HIGHLIGHT_META_DEFAULT_VALUES),
page,
positionX,
positionY,
width,
height,
}))
.exhaustive();
};
@@ -94,12 +94,22 @@ export const EnvelopeEditorFieldDragDrop = ({
selectedRecipientId,
selectedEnvelopeItemId,
}: EnvelopeEditorFieldDragDropProps) => {
const { envelope, editorFields, isTemplate, getRecipientColorKey } = useCurrentEnvelopeEditor();
const { envelope, editorFields, isTemplate, getRecipientColorKey, setIsPlacingItem } = useCurrentEnvelopeEditor();
const { t } = useLingui();
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
// Let the canvas know a field is being placed, so it can get its selection
// out of the way of the placement click.
useEffect(() => {
setIsPlacingItem(selectedField !== null);
return () => {
setIsPlacingItem(false);
};
}, [selectedField, setIsPlacingItem]);
const { isWithinPageBounds, getPage } = useDocumentElement();
const isFieldsDisabled = useMemo(() => {
@@ -1,8 +1,10 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import type { EnvelopeEditorTab } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { 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 { EnvelopeContentType } from '@documenso/lib/types/envelope-content-meta';
import {
FIELD_META_DEFAULT_VALUES,
type TCheckboxFieldMeta,
@@ -25,20 +27,33 @@ import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator';
import { Tabs, TabsContent } from '@documenso/ui/primitives/tabs';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
import { AlertTriangleIcon, FileTextIcon, PencilIcon, SparklesIcon } from 'lucide-react';
import { TabsList, TabsTrigger } from '@radix-ui/react-tabs';
import {
AlertTriangleIcon,
FileTextIcon,
LayoutGridIcon,
MousePointerIcon,
PencilIcon,
SparklesIcon,
} from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useRevalidator, useSearchParams } from 'react-router';
import { isDeepEqual } from 'remeda';
import { match } from 'ts-pattern';
import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-dialog';
import { AiFieldDetectionDialog } from '~/components/dialogs/ai-field-detection-dialog';
import { EnvelopeItemEditDialog } from '~/components/dialogs/envelope-item-edit-dialog';
import { EditorContentHighlightForm } from '~/components/forms/editor/editor-content-highlight-form';
import { EditorContentImageSettings } from '~/components/forms/editor/editor-content-image-settings';
import { EditorContentLineForm } from '~/components/forms/editor/editor-content-line-form';
import { EditorContentShapeForm } from '~/components/forms/editor/editor-content-shape-form';
import { EditorContentTextForm } from '~/components/forms/editor/editor-content-text-form';
import { EditorFieldCheckboxForm } from '~/components/forms/editor/editor-field-checkbox-form';
import { EditorFieldDateForm } from '~/components/forms/editor/editor-field-date-form';
import { EditorFieldDropdownForm } from '~/components/forms/editor/editor-field-dropdown-form';
@@ -51,7 +66,9 @@ import { EditorFieldSignatureForm } from '~/components/forms/editor/editor-field
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 { ContentImageUploadDialog } from './content-image-upload-dialog';
import { ContentSettingsFormProvider } from './content-settings-form-provider';
import { EnvelopeEditorContentDragDrop } from './envelope-editor-content-drag-drop';
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
import { EnvelopeEditorFieldsPageRenderer } from './envelope-editor-fields-page-renderer';
import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert';
@@ -72,6 +89,14 @@ const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
[FieldType.DROPDOWN]: msg`Dropdown Settings`,
};
const ContentSettingsTypeTranslations: Record<EnvelopeContentType, MessageDescriptor> = {
[EnvelopeContentType.TEXT]: msg`Text Settings`,
[EnvelopeContentType.LINE]: msg`Line Settings`,
[EnvelopeContentType.SHAPE]: msg`Shape Settings`,
[EnvelopeContentType.HIGHLIGHT]: msg`Highlight Settings`,
[EnvelopeContentType.IMAGE]: msg`Image Settings`,
};
export const EnvelopeEditorFieldsPage = () => {
const [searchParams] = useSearchParams();
@@ -79,12 +104,33 @@ export const EnvelopeEditorFieldsPage = () => {
const scrollableContainerRef = useRef<HTMLDivElement>(null);
const { envelope, editorFields, navigateToStep, editorConfig } = useCurrentEnvelopeEditor();
const {
envelope,
editorFields,
editorContents,
navigateToStep,
editorConfig,
selectedEditorTab,
setSelectedEditorTab,
} = useCurrentEnvelopeEditor();
const { currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
const { currentEnvelopeItem, setCurrentEnvelopeItem, viewerControls } = useCurrentEnvelopeRender();
const { _ } = useLingui();
/**
* Switching tabs resets which items are shown to the default for that tab:
* both on the fields tab, and only contents on the contents tab so the page
* reads as the document itself. This drives the same toggles as the viewer
* toolbar, so the user can still override them for the current tab, but any
* override is discarded on the next switch.
*/
const onEditorTabChange = (tab: EnvelopeEditorTab) => {
setSelectedEditorTab(tab);
viewerControls.setFieldsVisibility(tab === 'contents' ? 'hidden' : 'visible');
viewerControls.setContentsVisibility('visible');
};
const [isAiFieldDialogOpen, setIsAiFieldDialogOpen] = useState(false);
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
const { revalidate } = useRevalidator();
@@ -96,6 +142,11 @@ export const EnvelopeEditorFieldsPage = () => {
const selectedField = useMemo(() => structuredClone(editorFields.selectedField), [editorFields.selectedField]);
const selectedContent = useMemo(
() => structuredClone(editorContents.selectedContent),
[editorContents.selectedContent],
);
/**
* Debounce the fields used for overlap detection so we don't recompute on every
* small drag/resize movement, which is expensive on large field counts and can
@@ -186,6 +237,21 @@ export const EnvelopeEditorFieldsPage = () => {
editorFields.setSelectedRecipient(firstSelectableRecipient?.id ?? null);
}, []);
/**
* Deselect a field or content which is not on the current envelope item
* when the item changes, otherwise its settings stay open in the sidebar for
* something which is no longer on screen.
*/
useEffect(() => {
if (editorFields.selectedField && editorFields.selectedField.envelopeItemId !== currentEnvelopeItem?.id) {
editorFields.setSelectedField(null);
}
if (editorContents.selectedContent && editorContents.selectedContent.envelopeItemId !== currentEnvelopeItem?.id) {
editorContents.setSelectedContent(null);
}
}, [currentEnvelopeItem?.id]);
const onDetectClick = () => {
if (!team.preferences.aiFeaturesEnabled) {
setIsAiEnableDialogOpen(true);
@@ -202,319 +268,405 @@ export const EnvelopeEditorFieldsPage = () => {
});
};
if (!editorConfig.general?.allowAddFieldsStep && !editorConfig.general?.allowAddContentsStep) {
return null;
}
return (
<div className="relative flex h-full">
<div className="flex h-full w-full flex-col overflow-y-auto px-2" ref={scrollableContainerRef}>
{/* Horizontal envelope item selector */}
<EnvelopeRendererFileSelector
className="px-0"
fields={editorFields.localFields}
renderItemAction={
editorConfig.envelopeItems !== null &&
editorConfig.envelopeItems.allowReplace &&
envelopeItemPermissions.canFileBeChanged
? (item) => (
<div className="relative flex h-5 w-5 flex-shrink-0 items-center justify-center">
<div
className={cn('h-2 w-2 rounded-full transition-opacity duration-150 group-hover:opacity-0', {
'bg-green-500': currentEnvelopeItem?.id === item.id,
})}
/>
<EnvelopeItemEditDialog
envelopeItem={item}
allowConfigureTitle={editorConfig.envelopeItems?.allowConfigureTitle ?? false}
trigger={
<span
className="absolute inset-0 flex cursor-pointer items-center justify-center opacity-0 transition-opacity duration-150 group-hover:opacity-100"
onClick={(e) => e.stopPropagation()}
data-testid={`envelope-item-edit-button-${item.id}`}
>
<PencilIcon className="h-3.5 w-3.5" />
</span>
}
/>
</div>
)
: undefined
}
/>
<EnvelopeEditorInvalidDirectTemplateAlert />
{/* Document View */}
<div className="mt-4 flex h-full flex-col items-center justify-center">
{envelope.recipients.length === 0 && (
<Alert
variant="neutral"
className="mb-4 flex max-w-[800px] flex-row items-center justify-between space-y-0 rounded-sm border border-border bg-background"
>
<div className="flex flex-col gap-1">
<AlertTitle>
<Trans>Missing Recipients</Trans>
</AlertTitle>
<AlertDescription>
<Trans>You need at least one recipient to add fields</Trans>
</AlertDescription>
</div>
<Button variant="outline" onClick={() => void navigateToStep('upload')}>
<Trans>Add Recipients</Trans>
</Button>
</Alert>
)}
{overlappingFieldPairs.length > 0 && (
<Alert
variant="warning"
className="mt-20 mb-4 flex w-full max-w-[800px] flex-row items-center justify-between space-y-0 rounded-sm"
>
<div className="flex flex-row items-start gap-3">
<AlertTriangleIcon className="mt-0.5 h-5 w-5 flex-shrink-0" />
<div className="flex flex-col gap-1">
<AlertTitle>
<Trans>Overlapping fields detected</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Some fields are placed on top of each other. This may complicate the signing process or cause
fields to not work as expected.
</Trans>
</AlertDescription>
</div>
</div>
</Alert>
)}
{currentEnvelopeItem !== null ? (
<EnvelopePdfViewer
customPageRenderer={EnvelopeEditorFieldsPageRenderer}
scrollParentRef={scrollableContainerRef}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.editor}
<>
{/*
The settings form of the selected content is shared by the sidebar and
the canvas action bar, so it wraps both. Never keyed: remounting it
would remount the PDF viewer beneath it.
*/}
<ContentSettingsFormProvider content={selectedContent ?? null}>
<div className="relative flex h-full">
<div
className="flex h-full w-full flex-col overflow-x-auto overflow-y-auto px-2"
ref={scrollableContainerRef}
>
{/* Horizontal envelope item selector */}
<EnvelopeRendererFileSelector
className="px-0"
fields={editorFields.localFields}
renderItemAction={
editorConfig.envelopeItems !== null &&
editorConfig.envelopeItems.allowReplace &&
envelopeItemPermissions.canFileBeChanged
? (item) => (
<div className="relative flex h-5 w-5 flex-shrink-0 items-center justify-center">
<div
className={cn('h-2 w-2 rounded-full transition-opacity duration-150 group-hover:opacity-0', {
'bg-green-500': currentEnvelopeItem?.id === item.id,
})}
/>
<EnvelopeItemEditDialog
envelopeItem={item}
allowConfigureTitle={editorConfig.envelopeItems?.allowConfigureTitle ?? false}
trigger={
<span
className="absolute inset-0 flex cursor-pointer items-center justify-center opacity-0 transition-opacity duration-150 group-hover:opacity-100"
onClick={(e) => e.stopPropagation()}
data-testid={`envelope-item-edit-button-${item.id}`}
>
<PencilIcon className="h-3.5 w-3.5" />
</span>
}
/>
</div>
)
: undefined
}
/>
) : (
<div className="flex flex-col items-center justify-center py-32">
<FileTextIcon className="h-10 w-10 text-muted-foreground" />
<p className="mt-1 text-foreground text-sm">
<Trans>No documents found</Trans>
</p>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>Please upload a document to continue</Trans>
</p>
<EnvelopeEditorInvalidDirectTemplateAlert />
{/* Document View */}
<div className="mt-4 flex h-full flex-col items-center justify-center">
{envelope.recipients.length === 0 && (
<Alert
variant="neutral"
className="mb-4 flex max-w-[800px] flex-row items-center justify-between space-y-0 rounded-sm border border-border bg-background"
>
<div className="flex flex-col gap-1">
<AlertTitle>
<Trans>Missing Recipients</Trans>
</AlertTitle>
<AlertDescription>
<Trans>You need at least one recipient to add fields</Trans>
</AlertDescription>
</div>
<Button variant="outline" onClick={() => void navigateToStep('upload')}>
<Trans>Add Recipients</Trans>
</Button>
</Alert>
)}
{overlappingFieldPairs.length > 0 && (
<Alert
variant="warning"
className="mt-20 mb-4 flex w-full max-w-[800px] flex-row items-center justify-between space-y-0 rounded-sm"
>
<div className="flex flex-row items-start gap-3">
<AlertTriangleIcon className="mt-0.5 h-5 w-5 flex-shrink-0" />
<div className="flex flex-col gap-1">
<AlertTitle>
<Trans>Overlapping fields detected</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Some fields are placed on top of each other. This may complicate the signing process or cause
fields to not work as expected.
</Trans>
</AlertDescription>
</div>
</div>
</Alert>
)}
{currentEnvelopeItem !== null ? (
<EnvelopePdfViewer
customPageRenderer={EnvelopeEditorFieldsPageRenderer}
scrollParentRef={scrollableContainerRef}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.editor}
toolbar={['zoom', 'fields', 'contents']}
/>
) : (
<div className="flex flex-col items-center justify-center py-32">
<FileTextIcon className="h-10 w-10 text-muted-foreground" />
<p className="mt-1 text-foreground text-sm">
<Trans>No documents found</Trans>
</p>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>Please upload a document to continue</Trans>
</p>
</div>
)}
</div>
</div>
{/* Right Section - Form Fields Panel */}
{currentEnvelopeItem && envelope.recipients.length > 0 && (
<div className="sticky top-0 h-full w-80 flex-shrink-0 overflow-y-auto border-border border-l bg-background py-2">
<Tabs value={selectedEditorTab} onValueChange={(value) => onEditorTabChange(value as EnvelopeEditorTab)}>
{editorConfig.general?.allowAddFieldsStep && editorConfig.general?.allowAddContentsStep && (
<TabsList className="-mt-4 flex w-full flex-row border-b pt-2 text-muted-foreground text-sm">
<TabsTrigger
className="group flex min-h-12 w-1/2 items-center justify-center px-2 text-center hover:text-muted-foreground/80 data-[state=active]:shadow-[inset_0_-2px_0_0_hsl(var(--primary))]"
value="fields"
>
<MousePointerIcon className="mr-2 -ml-1 h-3.5 w-3.5 group-data-[state=active]:text-primary" />
<Trans>Fields</Trans>
</TabsTrigger>
<TabsTrigger
className="group flex min-h-12 w-1/2 items-center justify-center px-2 text-center hover:text-muted-foreground/80 data-[state=active]:shadow-[inset_0_-2px_0_0_hsl(var(--primary))]"
value="contents"
>
<LayoutGridIcon className="mr-2 -ml-1 h-3.5 w-3.5 group-data-[state=active]:text-primary" />
<Trans>Contents</Trans>
</TabsTrigger>
</TabsList>
)}
{/* Fields tab */}
<TabsContent value="fields">
{/* Recipient selector section. */}
<section className="px-4">
<h3 className="mb-2 font-semibold text-foreground text-sm">
<Trans>Selected Recipient</Trans>
</h3>
<EnvelopeRecipientSelector
selectedRecipient={editorFields.selectedRecipient}
onSelectedRecipientChange={(recipient) => editorFields.setSelectedRecipient(recipient.id)}
recipients={envelope.recipients}
fields={envelope.fields}
className="w-full"
align="end"
/>
{editorFields.selectedRecipient &&
!canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields) && (
<Alert className="mt-4" variant="warning">
<AlertDescription>
<Trans>
This recipient can no longer be modified as they have signed a field, or completed the
document.
</Trans>
</AlertDescription>
</Alert>
)}
</section>
<Separator className="my-4" />
{/* Add fields section. */}
<section className="px-4">
<h3 className="mb-2 font-semibold text-foreground text-sm">
<Trans>Add Fields</Trans>
</h3>
<EnvelopeEditorFieldDragDrop
selectedRecipientId={editorFields.selectedRecipient?.id ?? null}
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
/>
{editorConfig.fields?.allowAIDetection && (
<>
<Button
type="button"
variant="outline"
size="sm"
className="mt-4 w-full"
onClick={onDetectClick}
disabled={envelope.status !== DocumentStatus.DRAFT}
title={
envelope.status !== DocumentStatus.DRAFT
? _(msg`You can only detect fields in draft envelopes`)
: undefined
}
>
<SparklesIcon className="mr-2 -ml-1 h-4 w-4" />
<Trans>Detect with AI</Trans>
</Button>
<AiFieldDetectionDialog
open={isAiFieldDialogOpen}
onOpenChange={setIsAiFieldDialogOpen}
onComplete={onFieldDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
<AiFeaturesEnableDialog
open={isAiEnableDialogOpen}
onOpenChange={setIsAiEnableDialogOpen}
onEnabled={onAiFeaturesEnabled}
/>
</>
)}
</section>
{/* Field details section. */}
<AnimateGenericFadeInOut key={editorFields.selectedField?.formId}>
{selectedField && (
<section>
<Separator className="my-4" />
{searchParams.get('devmode') && (
<>
<div className="px-4">
<h3 className="mb-3 font-semibold text-foreground text-sm">
<Trans>Developer Mode</Trans>
</h3>
<div className="space-y-2 rounded-md border border-border bg-muted/50 p-3 text-foreground text-sm">
{selectedField.id && (
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Field ID:</Trans>
</span>{' '}
{selectedField.id}
</p>
)}
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Recipient ID:</Trans>
</span>{' '}
{selectedField.recipientId}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Pos X:</Trans>
</span>{' '}
{selectedField.positionX.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Pos Y:</Trans>
</span>{' '}
{selectedField.positionY.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Width:</Trans>
</span>{' '}
{selectedField.width.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Height:</Trans>
</span>{' '}
{selectedField.height.toFixed(2)}
</p>
</div>
</div>
<Separator className="my-4" />
</>
)}
<div className="px-4 [&_label]:text-foreground/70 [&_label]:text-xs">
<h3 className="font-semibold text-sm">
{_(FieldSettingsTypeTranslations[selectedField.type])}
</h3>
{match(selectedField.type)
.with(FieldType.SIGNATURE, () => (
<EditorFieldSignatureForm
value={selectedField?.fieldMeta as TSignatureFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.CHECKBOX, () => (
<EditorFieldCheckboxForm
value={selectedField?.fieldMeta as TCheckboxFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.DATE, () => (
<EditorFieldDateForm
value={selectedField?.fieldMeta as TDateFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.DROPDOWN, () => (
<EditorFieldDropdownForm
value={selectedField?.fieldMeta as TDropdownFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.EMAIL, () => (
<EditorFieldEmailForm
value={selectedField?.fieldMeta as TEmailFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.INITIALS, () => (
<EditorFieldInitialsForm
value={selectedField?.fieldMeta as TInitialsFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.NAME, () => (
<EditorFieldNameForm
value={selectedField?.fieldMeta as TNameFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.NUMBER, () => (
<EditorFieldNumberForm
value={selectedField?.fieldMeta as TNumberFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.RADIO, () => (
<EditorFieldRadioForm
value={selectedField?.fieldMeta as TRadioFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.TEXT, () => (
<EditorFieldTextForm
value={selectedField?.fieldMeta as TTextFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.otherwise(() => null)}
</div>
</section>
)}
</AnimateGenericFadeInOut>
</TabsContent>
{/* Contents tab */}
<TabsContent value="contents">
<section className="px-4">
<h3 className="mb-2 font-semibold text-foreground text-sm">
<Trans>Add Content</Trans>
</h3>
<EnvelopeEditorContentDragDrop selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null} />
</section>
{/* Content details section. */}
<AnimateGenericFadeInOut key={editorContents.selectedContent?.formId}>
{selectedContent && (
<section>
<Separator className="my-4" />
<div className="px-4 [&_label]:text-foreground/70 [&_label]:text-xs">
<h3 className="font-semibold text-sm">
{_(ContentSettingsTypeTranslations[selectedContent.contentMeta.type])}
</h3>
{match(selectedContent.contentMeta.type)
.with(EnvelopeContentType.TEXT, () => <EditorContentTextForm />)
.with(EnvelopeContentType.LINE, () => <EditorContentLineForm />)
.with(EnvelopeContentType.SHAPE, () => <EditorContentShapeForm />)
.with(EnvelopeContentType.HIGHLIGHT, () => <EditorContentHighlightForm />)
.with(EnvelopeContentType.IMAGE, () => (
<EditorContentImageSettings
formId={selectedContent.formId}
dataContentId={selectedContent.dataContentId ?? null}
/>
))
.exhaustive()}
</div>
</section>
)}
</AnimateGenericFadeInOut>
</TabsContent>
</Tabs>
</div>
)}
</div>
</div>
</ContentSettingsFormProvider>
{/* Right Section - Form Fields Panel */}
{currentEnvelopeItem && envelope.recipients.length > 0 && (
<div className="sticky top-0 h-full w-80 flex-shrink-0 overflow-y-auto border-border border-l bg-background py-4">
{/* Recipient selector section. */}
<section className="px-4">
<h3 className="mb-2 font-semibold text-foreground text-sm">
<Trans>Selected Recipient</Trans>
</h3>
<EnvelopeRecipientSelector
selectedRecipient={editorFields.selectedRecipient}
onSelectedRecipientChange={(recipient) => editorFields.setSelectedRecipient(recipient.id)}
recipients={envelope.recipients}
fields={envelope.fields}
className="w-full"
align="end"
/>
{editorFields.selectedRecipient &&
!canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields) && (
<Alert className="mt-4" variant="warning">
<AlertDescription>
<Trans>
This recipient can no longer be modified as they have signed a field, or completed the document.
</Trans>
</AlertDescription>
</Alert>
)}
</section>
<Separator className="my-4" />
{/* Add fields section. */}
<section className="px-4">
<h3 className="mb-2 font-semibold text-foreground text-sm">
<Trans>Add Fields</Trans>
</h3>
<EnvelopeEditorFieldDragDrop
selectedRecipientId={editorFields.selectedRecipient?.id ?? null}
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
/>
{editorConfig.fields?.allowAIDetection && (
<>
<Button
type="button"
variant="outline"
size="sm"
className="mt-4 w-full"
onClick={onDetectClick}
disabled={envelope.status !== DocumentStatus.DRAFT}
title={
envelope.status !== DocumentStatus.DRAFT
? _(msg`You can only detect fields in draft envelopes`)
: undefined
}
>
<SparklesIcon className="mr-2 -ml-1 h-4 w-4" />
<Trans>Detect with AI</Trans>
</Button>
<AiFieldDetectionDialog
open={isAiFieldDialogOpen}
onOpenChange={setIsAiFieldDialogOpen}
onComplete={onFieldDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
<AiFeaturesEnableDialog
open={isAiEnableDialogOpen}
onOpenChange={setIsAiEnableDialogOpen}
onEnabled={onAiFeaturesEnabled}
/>
</>
)}
</section>
{/* Field details section. */}
<AnimateGenericFadeInOut key={editorFields.selectedField?.formId}>
{selectedField && (
<section>
<Separator className="my-4" />
{searchParams.get('devmode') && (
<>
<div className="px-4">
<h3 className="mb-3 font-semibold text-foreground text-sm">
<Trans>Developer Mode</Trans>
</h3>
<div className="space-y-2 rounded-md border border-border bg-muted/50 p-3 text-foreground text-sm">
{selectedField.id && (
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Field ID:</Trans>
</span>{' '}
{selectedField.id}
</p>
)}
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Recipient ID:</Trans>
</span>{' '}
{selectedField.recipientId}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Pos X:</Trans>
</span>{' '}
{selectedField.positionX.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Pos Y:</Trans>
</span>{' '}
{selectedField.positionY.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Width:</Trans>
</span>{' '}
{selectedField.width.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Height:</Trans>
</span>{' '}
{selectedField.height.toFixed(2)}
</p>
</div>
</div>
<Separator className="my-4" />
</>
)}
<div className="px-4 [&_label]:text-foreground/70 [&_label]:text-xs">
<h3 className="font-semibold text-sm">{_(FieldSettingsTypeTranslations[selectedField.type])}</h3>
{match(selectedField.type)
.with(FieldType.SIGNATURE, () => (
<EditorFieldSignatureForm
value={selectedField?.fieldMeta as TSignatureFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.CHECKBOX, () => (
<EditorFieldCheckboxForm
value={selectedField?.fieldMeta as TCheckboxFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.DATE, () => (
<EditorFieldDateForm
value={selectedField?.fieldMeta as TDateFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.DROPDOWN, () => (
<EditorFieldDropdownForm
value={selectedField?.fieldMeta as TDropdownFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.EMAIL, () => (
<EditorFieldEmailForm
value={selectedField?.fieldMeta as TEmailFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.INITIALS, () => (
<EditorFieldInitialsForm
value={selectedField?.fieldMeta as TInitialsFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.NAME, () => (
<EditorFieldNameForm
value={selectedField?.fieldMeta as TNameFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.NUMBER, () => (
<EditorFieldNumberForm
value={selectedField?.fieldMeta as TNumberFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.RADIO, () => (
<EditorFieldRadioForm
value={selectedField?.fieldMeta as TRadioFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.TEXT, () => (
<EditorFieldTextForm
value={selectedField?.fieldMeta as TTextFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.otherwise(() => null)}
</div>
</section>
)}
</AnimateGenericFadeInOut>
</div>
)}
</div>
<ContentImageUploadDialog.Root />
</>
);
};
@@ -1,5 +1,5 @@
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { getEnvelopeItemPermissions, mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator';
@@ -222,7 +222,6 @@ export default function EnvelopeEditorHeader() {
.with({ isEmbedded: false, isTemplate: true, allowDistributing: true }, () => (
<TemplateUseDialog
envelopeId={envelope.id}
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
templateSigningOrder={envelope.documentMeta?.signingOrder}
recipients={envelope.recipients}
documentRootPath={relativePath.documentRootPath}
@@ -215,6 +215,7 @@ export const EnvelopeEditorPreviewPage = () => {
envelopeItems={envelope.envelopeItems}
token={undefined}
fields={fieldsWithPlaceholders}
contents={envelope.contents}
recipients={envelope.recipients.map((recipient) => ({
...recipient,
signingStatus: SigningStatus.SIGNED,
@@ -225,7 +226,7 @@ export const EnvelopeEditorPreviewPage = () => {
}}
>
<div className="relative flex h-full">
<div className="flex h-full w-full flex-col overflow-y-auto px-2" ref={scrollableContainerRef}>
<div className="flex h-full w-full flex-col overflow-x-auto overflow-y-auto px-2" ref={scrollableContainerRef}>
{/* Horizontal envelope item selector */}
<EnvelopeRendererFileSelector className="px-0" fields={editorFields.localFields} />
@@ -247,6 +248,7 @@ export const EnvelopeEditorPreviewPage = () => {
customPageRenderer={EnvelopeGenericPageRenderer}
scrollParentRef={scrollableContainerRef}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.preview}
toolbar={['zoom']}
/>
) : (
<div className="flex flex-col items-center justify-center py-32">
@@ -18,6 +18,7 @@ export const EnvelopeEditorRenderProviderWrapper = ({
envelope={envelope}
envelopeItems={envelope.envelopeItems}
fields={envelope.fields}
contents={envelope.contents}
recipients={envelope.recipients}
token={token}
presignToken={presignedToken}
@@ -54,6 +54,7 @@ export const EnvelopeEditorUploadPage = () => {
envelope,
setLocalEnvelope,
editorFields,
editorContents,
editorConfig,
isEmbedded,
navigateToStep,
@@ -139,7 +140,7 @@ export const EnvelopeEditorUploadPage = () => {
);
const { mutateAsync: replaceEnvelopeItemPdf } = trpc.envelope.item.replacePdf.useMutation({
onSuccess: ({ data, fields }) => {
onSuccess: ({ data, fields, contents }) => {
// Update the envelope item with the new documentDataId.
setLocalEnvelope({
envelopeItems: envelope.envelopeItems.map((item) =>
@@ -153,6 +154,12 @@ export const EnvelopeEditorUploadPage = () => {
setLocalEnvelope({ fields });
editorFields.resetForm(fields);
}
// Same for contents.
if (contents) {
setLocalEnvelope({ contents });
editorContents.resetForm(contents);
}
},
});
@@ -181,13 +188,18 @@ export const EnvelopeEditorUploadPage = () => {
// Directly commit the files for embedded documents since those are not uploaded
// until the end of the embedded flow.
if (isEmbedded) {
// New items go after the existing ones, in the order they were added.
// Deleting an item does not renumber the rest, so this continues from
// the highest order rather than the item count.
const highestOrder = envelope.envelopeItems.reduce((highest, item) => Math.max(highest, item.order), 0);
setLocalEnvelope({
envelopeItems: [
...envelope.envelopeItems,
...newUploadingFiles.map((file) => ({
...newUploadingFiles.map((file, index) => ({
id: file.envelopeItemId!,
title: file.title,
order: envelope.envelopeItems.length + 1,
order: highestOrder + index + 1,
envelopeId: envelope.id,
data: file.data!,
documentDataId: '',
@@ -273,12 +285,20 @@ export const EnvelopeEditorUploadPage = () => {
(field) => field.envelopeItemId !== envelopeItemId || field.page <= newPageCount,
);
// Contents are pruned by the same rule, matching what the server does
// for non embedded replacements.
const remainingContents = envelope.contents.filter(
(content) => content.envelopeItemId !== envelopeItemId || content.contentMeta.page <= newPageCount,
);
setLocalEnvelope({
envelopeItems: envelope.envelopeItems.map((item) => (item.id === envelopeItemId ? { ...item, data } : item)),
fields: remainingFields,
contents: remainingContents,
});
editorFields.resetForm(remainingFields);
editorContents.resetForm(remainingContents);
return;
}
@@ -326,14 +346,17 @@ export const EnvelopeEditorUploadPage = () => {
setLocalFiles((prev) => prev.filter((uploadingFile) => uploadingFile.envelopeItemId !== envelopeItemId));
const fieldsWithoutDeletedItem = envelope.fields.filter((field) => field.envelopeItemId !== envelopeItemId);
const contentsWithoutDeletedItem = envelope.contents.filter((content) => content.envelopeItemId !== envelopeItemId);
setLocalEnvelope({
envelopeItems: envelope.envelopeItems.filter((item) => item.id !== envelopeItemId),
fields: envelope.fields.filter((field) => field.envelopeItemId !== envelopeItemId),
fields: fieldsWithoutDeletedItem,
contents: contentsWithoutDeletedItem,
});
// Reset editor fields.
// Reset editor fields and contents.
editorFields.resetForm(fieldsWithoutDeletedItem);
editorContents.resetForm(contentsWithoutDeletedItem);
};
/**
@@ -90,7 +90,13 @@ export const EnvelopeEditor = () => {
const [searchParams, setSearchParams] = useSearchParams();
const {
general: { minimizeLeftSidebar, allowUploadAndRecipientStep, allowAddFieldsStep, allowPreviewStep },
general: {
minimizeLeftSidebar,
allowUploadAndRecipientStep,
allowAddFieldsStep,
allowAddContentsStep,
allowPreviewStep,
},
actions: {
allowDistributing,
allowDirectLink,
@@ -108,7 +114,7 @@ export const EnvelopeEditor = () => {
steps.push(UPLOAD_STEP);
}
if (allowAddFieldsStep) {
if (allowAddFieldsStep || allowAddContentsStep) {
steps.push(ADD_FIELDS_STEP);
}
@@ -524,11 +530,16 @@ export const EnvelopeEditor = () => {
pageToRender,
allowUploadAndRecipientStep,
allowAddFieldsStep,
allowAddContentsStep,
allowPreviewStep,
})
.with({ pageToRender: 'loading' }, () => <SpinnerBox className="py-32" />)
.with({ pageToRender: 'upload', allowUploadAndRecipientStep: true }, () => <EnvelopeEditorUploadPage />)
.with({ pageToRender: 'addFields', allowAddFieldsStep: true }, () => <EnvelopeEditorFieldsPage />)
.with(
{ pageToRender: 'addFields', allowAddFieldsStep: true },
{ pageToRender: 'addFields', allowAddContentsStep: true },
() => <EnvelopeEditorFieldsPage />,
)
.with({ pageToRender: 'preview', allowPreviewStep: true }, () => <EnvelopeEditorPreviewPage />)
.otherwise(() => null)}
</div>
@@ -5,7 +5,9 @@ import {
useCurrentEnvelopeRender,
} from '@documenso/lib/client-only/providers/envelope-render-provider';
import type { TEnvelope } from '@documenso/lib/types/envelope';
import { renderStaticContents } from '@documenso/lib/universal/content-renderer/render-static-contents';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
import { areContentsImprinted } from '@documenso/lib/utils/envelope';
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
import { EnvelopeRecipientFieldTooltip } from '@documenso/ui/components/document/envelope-recipient-field-tooltip';
import { useLingui } from '@lingui/react/macro';
@@ -22,9 +24,12 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
const analytics = useAnalytics();
const {
version,
envelopeStatus,
currentEnvelopeItem,
fields,
contents,
contentImages,
signatures,
recipients,
getRecipientColorKey,
@@ -32,16 +37,40 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
overrideSettings,
} = useCurrentEnvelopeRender();
/**
* Once an envelope is sent its contents are inserted into the current PDF, so
* rendering them again would double them up.
*/
const shouldRenderContents = !(version === 'current' && areContentsImprinted(envelopeStatus));
const signaturesByFieldId = useMemo(() => {
return new Map(signatures.map((signature) => [signature.fieldId, signature]));
}, [signatures]);
const { stage, pageLayer, konvaContainer, unscaledViewport } = usePageRenderer(({ stage, pageLayer }) => {
const {
stage,
pageLayer,
konvaContainer,
unscaledViewport,
fieldsVisibility,
applyPageItemsVisibility,
hasFailedImage,
} = usePageRenderer(({ stage, pageLayer }) => {
createPageCanvas(stage, pageLayer);
}, pageData);
const { scale, pageNumber } = pageData;
/**
* A content image which failed to load would render the page differently
* to how it was authored, so it is treated like any other render failure.
*/
useEffect(() => {
if (shouldRenderContents && hasFailedImage) {
setRenderError(true);
}
}, [shouldRenderContents, hasFailedImage]);
const localPageFields = useMemo((): GenericLocalField[] => {
if (envelopeStatus === DocumentStatus.COMPLETED) {
return [];
@@ -105,7 +134,8 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
translations: fieldTranslations,
pageWidth: unscaledViewport.width,
pageHeight: unscaledViewport.height,
color: getRecipientColorKey(field.recipientId),
// Muted fields are rendered with the read-only styling.
color: fieldsVisibility === 'muted' ? 'readOnly' : getRecipientColorKey(field.recipientId),
editable: false,
mode: overrideSettings?.mode ?? 'edit',
});
@@ -131,6 +161,12 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
* Initialize the Konva page canvas and all fields and interactions.
*/
const createPageCanvas = (_currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
// Render the contents beneath the fields. Contents are static, so they
// only need to be rendered when the canvas is created.
if (shouldRenderContents) {
renderContents(currentPageLayer);
}
// Render the fields.
for (const field of localPageFields) {
renderFieldOnLayer(field);
@@ -139,6 +175,28 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
currentPageLayer.batchDraw();
};
/**
* Render the authored contents of the page beneath the fields.
*/
const renderContents = (currentPageLayer: Konva.Layer) => {
try {
renderStaticContents({
contents: contents.filter(
(content) => content.contentMeta.page === pageNumber && content.envelopeItemId === currentEnvelopeItem?.id,
),
pageLayer: currentPageLayer,
pageWidth: unscaledViewport.width,
pageHeight: unscaledViewport.height,
scale,
mode: overrideSettings?.mode ?? 'edit',
images: contentImages.images,
});
} catch (err) {
console.error(err);
setRenderError(true);
}
};
/**
* Render fields when they are added or removed
*/
@@ -159,8 +217,10 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
renderFieldOnLayer(field);
});
applyPageItemsVisibility();
pageLayer.current.batchDraw();
}, [localPageFields, signaturesByFieldId]);
}, [localPageFields, signaturesByFieldId, fieldsVisibility]);
if (!currentEnvelopeItem) {
return null;
@@ -170,6 +230,7 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
<>
{overrideSettings?.showRecipientTooltip &&
pageData.imageLoadingState === 'loaded' &&
fieldsVisibility === 'visible' &&
localPageFields.map((field) => (
<EnvelopeRecipientFieldTooltip
key={field.id}
@@ -10,6 +10,7 @@ import { isBase64Image } from '@documenso/lib/constants/signatures';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import type { TEnvelope } from '@documenso/lib/types/envelope';
import { ZFullFieldSchema } from '@documenso/lib/types/field';
import { renderStaticContents } from '@documenso/lib/universal/content-renderer/render-static-contents';
import {
createFieldCanvasStyleCache,
type FieldCanvasStyleCache,
@@ -17,6 +18,7 @@ import {
import { createSpinner } from '@documenso/lib/universal/field-renderer/field-generic-items';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { areContentsImprinted } from '@documenso/lib/utils/envelope';
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import type { TSignEnvelopeFieldValue } from '@documenso/trpc/server/envelope-router/sign-envelope-field.types';
@@ -49,7 +51,7 @@ type GenericLocalField = TEnvelope['fields'][number] & {
export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderData }) => {
const { t, i18n } = useLingui();
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
const { currentEnvelopeItem, contentImages, setRenderError } = useCurrentEnvelopeRender();
const { sessionData } = useOptionalSession();
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
@@ -535,10 +537,39 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
}
};
/**
* Render the contents when required.
*
* Since contents are imprinted on sent, we still need to render them for direct templates.
*/
const renderContents = () => {
if (!pageLayer.current || areContentsImprinted(envelope.status)) {
return;
}
try {
renderStaticContents({
contents: envelope.contents.filter(
(content) => content.contentMeta.page === pageNumber && content.envelopeItemId === currentEnvelopeItem?.id,
),
pageLayer: pageLayer.current,
pageWidth: unscaledViewport.width,
pageHeight: unscaledViewport.height,
scale,
mode: 'sign',
images: contentImages.images,
});
} catch (err) {
console.error(err);
setRenderError(true);
}
};
/**
* Initialize the Konva page canvas and all fields and interactions.
*/
const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
renderContents();
renderFields();
currentPageLayer.batchDraw();
};
@@ -577,6 +608,7 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
pageLayer.current.destroyChildren();
cachedRenderFields.current.clear();
renderContents();
renderFields();
pageLayer.current.batchDraw();
@@ -8,20 +8,40 @@ import { useRef } from 'react';
import type { PDFViewerProps } from './pdf-viewer';
import PDFViewerLazy from './pdf-viewer-lazy';
import type { EnvelopePdfViewerToolbarControl } from './pdf-viewer-toolbar';
import { EnvelopePdfViewerToolbar } from './pdf-viewer-toolbar';
export type EnvelopePdfViewerProps = {
/**
* The error message to render when there is an error.
*/
errorMessage: { title: MessageDescriptor; description: MessageDescriptor } | null;
/**
* The controls to display in the floating viewer toolbar.
*
* When omitted no toolbar is rendered.
*/
toolbar?: EnvelopePdfViewerToolbarControl[];
/**
* Additional class names for the floating viewer toolbar.
*/
toolbarClassName?: string;
} & Omit<PDFViewerProps, 'data'>;
export const EnvelopePdfViewer = ({ errorMessage, className, ...props }: EnvelopePdfViewerProps) => {
export const EnvelopePdfViewer = ({
errorMessage,
toolbar,
toolbarClassName,
className,
...props
}: EnvelopePdfViewerProps) => {
const { t } = useLingui();
const $el = useRef<HTMLDivElement>(null);
const { currentEnvelopeItem, renderError } = useCurrentEnvelopeRender();
const { currentEnvelopeItem, renderError, viewerControls } = useCurrentEnvelopeRender();
if (renderError || !currentEnvelopeItem) {
return (
@@ -45,12 +65,24 @@ export const EnvelopePdfViewer = ({ errorMessage, className, ...props }: Envelop
}
return (
<PDFViewerLazy
key={`${currentEnvelopeItem.envelopeId}-${currentEnvelopeItem.id}`}
{...props}
className={cn('h-full w-full max-w-[800px]', className)}
data={currentEnvelopeItem.data}
/>
<>
<PDFViewerLazy
key={`${currentEnvelopeItem.envelopeId}-${currentEnvelopeItem.id}`}
{...props}
className={cn('h-full w-full', className)}
data={currentEnvelopeItem.data}
zoom={viewerControls.zoom}
maxPageWidth={800}
/>
{toolbar && toolbar.length > 0 && (
<EnvelopePdfViewerToolbar
controls={toolbar}
scrollParentRef={props.scrollParentRef}
className={toolbarClassName}
/>
)}
</>
);
};
@@ -5,15 +5,26 @@ import { Trans } from '@lingui/react/macro';
type PdfViewerPageImageProps = {
imageLoadingState: ImageLoadingState;
/**
* Whether the page and everything drawn on it are ready to be shown.
*/
isPageReady: boolean;
imageProps: React.ImgHTMLAttributes<HTMLImageElement> & Record<string, unknown> & { alt: '' };
};
export const PdfViewerPageImage = ({ imageLoadingState, imageProps }: PdfViewerPageImageProps) => {
export const PdfViewerPageImage = ({ imageLoadingState, isPageReady, imageProps }: PdfViewerPageImageProps) => {
const isLoading = !isPageReady && imageLoadingState !== 'error';
return (
<>
{/* Loading State */}
{imageLoadingState === 'loading' && (
<div className="absolute inset-0 z-10 flex items-center justify-center text-muted-foreground opacity-20">
{isLoading && (
<div
className="absolute inset-0 z-10 flex items-center justify-center text-muted-foreground opacity-20"
data-testid="page-loader"
>
<Spinner />
</div>
)}
@@ -28,7 +39,12 @@ export const PdfViewerPageImage = ({ imageLoadingState, imageProps }: PdfViewerP
{/* The PDF image. */}
{imageProps.src && (
<img {...imageProps} className={cn(imageProps.className, 'select-none')} draggable={false} alt="" />
<img
{...imageProps}
className={cn(imageProps.className, 'select-none', !isPageReady && 'invisible')}
draggable={false}
alt=""
/>
)}
</>
);
@@ -0,0 +1,222 @@
import {
ENVELOPE_VIEWER_MAX_ZOOM,
ENVELOPE_VIEWER_MIN_ZOOM,
useCurrentEnvelopeRender,
} from '@documenso/lib/client-only/providers/envelope-render-provider';
import { cn } from '@documenso/ui/lib/utils';
import { Trans, useLingui } from '@lingui/react/macro';
import { EyeIcon, EyeOffIcon, ZoomInIcon, ZoomOutIcon } from 'lucide-react';
import { useLayoutEffect, useState } from 'react';
import type { ScrollTarget } from '../virtual-list/use-virtual-list';
/**
* The gap between the toolbar and the bottom of the visible viewer area.
*/
const TOOLBAR_BOTTOM_OFFSET = 24;
export type EnvelopePdfViewerToolbarControl = 'zoom' | 'fields' | 'contents';
export type EnvelopePdfViewerToolbarProps = {
/**
* The controls to display in the toolbar.
*/
controls?: EnvelopePdfViewerToolbarControl[];
/**
* The scroll container the toolbar is aligned against.
*
* The toolbar is fixed to the viewport, horizontally centered over the
* scroll container and pinned to the bottom of its visible area.
*/
scrollParentRef: ScrollTarget;
className?: string;
};
/**
* A floating toolbar for the envelope PDF viewer providing zoom and
* fields/contents visibility controls.
*
* Rendered with fixed positioning aligned to the viewer's scroll container,
* so it can be mounted anywhere within the viewer tree, including inside the
* scroll container itself.
*/
export const EnvelopePdfViewerToolbar = ({
controls = ['zoom'],
scrollParentRef,
className,
}: EnvelopePdfViewerToolbarProps) => {
const { t } = useLingui();
const { viewerControls } = useCurrentEnvelopeRender();
const {
zoom,
setZoom,
zoomIn,
zoomOut,
fieldsVisibility,
setFieldsVisibility,
contentsVisibility,
setContentsVisibility,
} = viewerControls;
const [position, setPosition] = useState<{ left: number; bottom: number } | null>(null);
/**
* Keep the toolbar aligned with the visible area of the scroll container.
*/
useLayoutEffect(() => {
// The toolbar renders inside the scroll container, so the container's ref
// is not attached yet when this first runs. It is therefore observed as
// soon as measuring finds it, which also covers it being replaced.
let observedScrollEl: HTMLElement | null = null;
function measure() {
if (scrollParentRef === 'window') {
setPosition({
left: window.innerWidth / 2,
bottom: TOOLBAR_BOTTOM_OFFSET,
});
return;
}
const scrollEl = scrollParentRef.current;
if (!scrollEl) {
setPosition(null);
return;
}
if (observedScrollEl !== scrollEl) {
if (observedScrollEl) {
resizeObserver.unobserve(observedScrollEl);
}
resizeObserver.observe(scrollEl);
observedScrollEl = scrollEl;
}
const rect = scrollEl.getBoundingClientRect();
const visibleLeft = Math.max(0, rect.left);
const visibleRight = Math.min(window.innerWidth, rect.right);
const visibleBottom = Math.min(window.innerHeight, rect.bottom);
setPosition({
left: visibleLeft + (visibleRight - visibleLeft) / 2,
bottom: window.innerHeight - visibleBottom + TOOLBAR_BOTTOM_OFFSET,
});
}
const resizeObserver = new ResizeObserver(() => measure());
measure();
window.addEventListener('resize', measure);
// Covers viewport and page level layout changes, and delivers an initial
// notification which picks up the scroll container once it is attached.
resizeObserver.observe(document.body);
return () => {
window.removeEventListener('resize', measure);
resizeObserver.disconnect();
};
}, [scrollParentRef]);
const showZoom = controls.includes('zoom');
const showFieldsToggle = controls.includes('fields');
const showContentsToggle = controls.includes('contents');
const isFieldsHidden = fieldsVisibility === 'hidden';
const isContentsHidden = contentsVisibility === 'hidden';
if (!position) {
return null;
}
return (
<div
className={cn(
'fixed z-40 flex w-fit -translate-x-1/2 items-center gap-x-0.5 rounded-xl bg-popover p-1.5 text-popover-foreground shadow-lg ring-1 ring-black/10 dark:ring-white/10',
className,
)}
style={{
left: position.left,
bottom: position.bottom,
}}
>
{showZoom && (
<>
<button
type="button"
title={t`Zoom out`}
disabled={zoom <= ENVELOPE_VIEWER_MIN_ZOOM}
onClick={zoomOut}
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
>
<ZoomOutIcon className="h-4 w-4" />
</button>
<button
type="button"
title={t`Reset zoom`}
onClick={() => setZoom(1)}
className="min-w-12 rounded-md px-1 py-1.5 text-center text-muted-foreground text-xs tabular-nums transition-colors hover:bg-muted hover:text-foreground"
>
{Math.round(zoom * 100)}%
</button>
<button
type="button"
title={t`Zoom in`}
disabled={zoom >= ENVELOPE_VIEWER_MAX_ZOOM}
onClick={zoomIn}
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
>
<ZoomInIcon className="h-4 w-4" />
</button>
</>
)}
{showZoom && (showFieldsToggle || showContentsToggle) && <div className="mx-1 h-5 w-px bg-border" />}
{showFieldsToggle && (
<button
type="button"
title={isFieldsHidden ? t`Show fields` : t`Hide fields`}
onClick={() => setFieldsVisibility(isFieldsHidden ? 'visible' : 'hidden')}
className={cn(
'flex items-center gap-x-1.5 rounded-md px-2 py-1.5 text-muted-foreground text-xs transition-colors hover:bg-muted hover:text-foreground',
{
'text-muted-foreground/60': isFieldsHidden,
},
)}
>
{isFieldsHidden ? <EyeOffIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
<Trans>Fields</Trans>
</button>
)}
{showContentsToggle && (
<button
type="button"
title={isContentsHidden ? t`Show contents` : t`Hide contents`}
onClick={() => setContentsVisibility(isContentsHidden ? 'visible' : 'hidden')}
className={cn(
'flex items-center gap-x-1.5 rounded-md px-2 py-1.5 text-muted-foreground text-xs transition-colors hover:bg-muted hover:text-foreground',
{
'text-muted-foreground/60': isContentsHidden,
},
)}
>
{isContentsHidden ? <EyeOffIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
<Trans>Contents</Trans>
</button>
)}
</div>
);
};
@@ -8,7 +8,7 @@ import pMap from 'p-map';
import * as pdfjsLib from 'pdfjs-dist';
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker?url';
import type React from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { ScrollTarget } from '../virtual-list/use-virtual-list';
import { useVirtualList } from '../virtual-list/use-virtual-list';
@@ -29,6 +29,16 @@ const LOW_RENDER_RESOLUTION = 1;
const HIGH_RENDER_RESOLUTION = 2;
const IDLE_RENDER_DELAY = 200;
/**
* The additional height of each virtual page item on top of the scaled page
* height.
*
* 32px for the page number text and margins (my-2 = 8px * 2 + text height ~16px)
* plus 2px so the page outline ring (drawn outside the page box) does not touch
* the neighbouring items.
*/
const PAGE_ITEM_EXTRA_HEIGHT = 34;
export type PDFViewerProps = {
className?: string;
@@ -52,6 +62,23 @@ export type PDFViewerProps = {
onDocumentLoad?: () => void;
/**
* The zoom factor to render the pages at.
*
* The rendered page width is `min(containerWidth, maxPageWidth) * zoom`,
* derived in the same render pass as the layout so zoom changes apply in a
* single paint without any intermediate layout shift.
*
* Values above 1 can overflow the container horizontally, so the scroll
* parent should allow horizontal scrolling.
*/
zoom?: number;
/**
* The maximum base width of a page before the zoom is applied.
*/
maxPageWidth?: number;
/**
* Additional component to render next to the image, such as a Konva canvas
* for rendering fields.
@@ -64,6 +91,8 @@ export default function PDFViewer({
data,
scrollParentRef,
onDocumentLoad,
zoom = 1,
maxPageWidth,
customPageRenderer,
...props
}: PDFViewerProps) {
@@ -213,6 +242,8 @@ export default function PDFViewer({
numPages={pages.length}
pages={pages}
pdf={pdfRef.current}
zoom={zoom}
maxPageWidth={maxPageWidth}
customPageRenderer={customPageRenderer}
/>
)}
@@ -226,6 +257,8 @@ type VirtualizedPageListProps = {
pages: PageMeta[];
numPages: number;
pdf: pdfjsLib.PDFDocumentProxy;
zoom: number;
maxPageWidth?: number;
customPageRenderer?: React.FunctionComponent<{ pageData: PageRenderData }>;
};
@@ -235,6 +268,8 @@ const VirtualizedPageList = ({
pages,
numPages,
pdf,
zoom,
maxPageWidth,
customPageRenderer,
}: VirtualizedPageListProps) => {
const contentRef = useRef<HTMLDivElement>(null);
@@ -244,22 +279,81 @@ const VirtualizedPageList = ({
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;
},
itemSize: (index, width) => getPageItemSize(pages[index], getDisplayWidth(width, zoom, maxPageWidth)),
overscan: 5,
});
/**
* The width the pages are rendered at.
*
* Derived from the measured available width in the same render pass as the
* zoom, so zoom changes update the page layout and page sizes within a
* single commit, avoiding intermediate layout shifts.
*/
const displayWidth = getDisplayWidth(constraintWidth, zoom, maxPageWidth);
useScrollToPage(contentRef, scrollToItem);
const previousDisplayWidthRef = useRef(displayWidth);
/**
* Anchor the scroll position to the bottom center when the rendered page width
* changes (zoom or container resize) so zooming feels centered on the middle of the visible
* area instead of the top left of the content.
*/
useLayoutEffect(() => {
const previousDisplayWidth = previousDisplayWidthRef.current;
previousDisplayWidthRef.current = displayWidth;
if (previousDisplayWidth === displayWidth || previousDisplayWidth === 0 || displayWidth === 0) {
return;
}
const contentEl = contentRef.current;
const scrollEl = scrollParentRef === 'window' ? document.scrollingElement : scrollParentRef.current;
if (!contentEl || !scrollEl) {
return;
}
const viewportHeight = scrollParentRef === 'window' ? window.innerHeight : scrollEl.clientHeight;
// The offset of the content element from the top of the scrollable content.
// Only depends on the content above the page list, which is unaffected by zoom.
const contentOffsetTop =
contentEl.getBoundingClientRect().top -
(scrollParentRef === 'window' ? 0 : scrollEl.getBoundingClientRect().top) +
scrollEl.scrollTop;
const oldMetrics = computePageMetrics(pages, previousDisplayWidth);
const newMetrics = computePageMetrics(pages, displayWidth);
// The content-space Y coordinate currently at the vertical center of the viewport.
const oldCenterY = Math.min(
Math.max(0, scrollEl.scrollTop + viewportHeight / 2 - contentOffsetTop),
oldMetrics.totalSize,
);
// Locate which page the center point is on, and how far through it.
let pageIndex = 0;
for (let i = 0; i < pages.length; i += 1) {
if (oldMetrics.offsets[i] <= oldCenterY) {
pageIndex = i;
} else {
break;
}
}
const pageFraction =
oldMetrics.sizes[pageIndex] > 0 ? (oldCenterY - oldMetrics.offsets[pageIndex]) / oldMetrics.sizes[pageIndex] : 0;
const newCenterY = newMetrics.offsets[pageIndex] + pageFraction * newMetrics.sizes[pageIndex];
scrollEl.scrollTop = Math.max(0, newCenterY + contentOffsetTop - viewportHeight / 2);
scrollEl.scrollLeft = Math.max(0, (scrollEl.scrollWidth - scrollEl.clientWidth) / 2);
}, [displayWidth, pages, scrollParentRef]);
return (
<div
ref={contentRef}
@@ -268,7 +362,11 @@ const VirtualizedPageList = ({
data-page-count={numPages}
style={{
height: `${totalSize}px`,
width: '100%',
width: displayWidth > 0 ? `${displayWidth}px` : '100%',
// Center the pages when they fit within the container, while safely
// falling back to a start alignment when they overflow so the scroll
// container can reach all of the content.
margin: '0 auto',
position: 'relative',
}}
>
@@ -277,11 +375,11 @@ const VirtualizedPageList = ({
const pageMeta = pages[index];
const pageNumber = index + 1;
// Calculate scale based on constraint width
const scale = constraintWidth / pageMeta.width;
// Calculate scale based on the rendered page width.
const scale = displayWidth / pageMeta.width;
const scaledWidth = Math.floor(pageMeta.width * scale);
const scaledHeight = Math.floor(pageMeta.height * scale);
const scaledWidth = displayWidth;
const scaledHeight = Math.round(pageMeta.height * scale);
return (
<div
@@ -290,7 +388,7 @@ const VirtualizedPageList = ({
position: 'absolute',
top: 0,
left: 0,
width: constraintWidth,
width: displayWidth,
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
@@ -306,7 +404,12 @@ const VirtualizedPageList = ({
customPageRenderer={customPageRenderer}
/>
<p className="my-2 text-center text-[11px] text-muted-foreground/80">
<p
className={cn('my-2 text-center text-[11px] text-muted-foreground/80', {
// Allocate room for the floating viewer toolbar.
'pb-20': index === numPages - 1,
})}
>
<Trans>
Page {pageNumber} of {numPages}
</Trans>
@@ -318,6 +421,54 @@ const VirtualizedPageList = ({
);
};
/**
* The width pages are rendered at for a given available width, zoom and
* maximum base page width.
*/
const getDisplayWidth = (constraintWidth: number, zoom: number, maxPageWidth?: number) => {
if (constraintWidth === 0) {
return 0;
}
const baseWidth = maxPageWidth !== undefined ? Math.min(constraintWidth, maxPageWidth) : constraintWidth;
return Math.floor(baseWidth * zoom);
};
/**
* The height of a single virtual list item for a page rendered at the given
* width: the page height scaled to that width plus the page number footer.
*
* Single source of truth for both the virtual list `itemSize` and
* `computePageMetrics`, so the two can never drift apart.
*/
const getPageItemSize = (pageMeta: PageMeta, displayWidth: number) => {
const aspectRatio = pageMeta.height / pageMeta.width;
return displayWidth * aspectRatio + PAGE_ITEM_EXTRA_HEIGHT;
};
/**
* Compute the virtual list offsets and sizes of every page for a given
* rendered page width.
*/
const computePageMetrics = (pages: PageMeta[], displayWidth: number) => {
const offsets: number[] = [];
const sizes: number[] = [];
let totalSize = 0;
for (const pageMeta of pages) {
const size = getPageItemSize(pageMeta, displayWidth);
offsets.push(totalSize);
sizes.push(size);
totalSize += size;
}
return { offsets, sizes, totalSize };
};
type PdfViewerPageProps = {
pageNumber: number;
pdf: pdfjsLib.PDFDocumentProxy;
@@ -349,8 +500,19 @@ const PdfViewerPage = ({
scale,
});
/**
* A custom page renderer may have to load things of its own before the page
* can be drawn, which it can only begin once the page image is ready. The
* page image is held back until then so both appear at once.
*/
const [isCustomRendererReady, setIsCustomRendererReady] = useState(false);
const isPageReady = imageLoadingState === 'loaded' && (!CustomPageRenderer || isCustomRendererReady);
return (
<div className="relative w-full rounded border border-border" style={{ width: scaledWidth, height: scaledHeight }}>
// Must use ring instead of border since borders take up space inside the box,
// which shrinks the page image (constrained to the box by `max-width: 100%`)
<div className="relative w-full rounded ring-1 ring-border" style={{ width: scaledWidth, height: scaledHeight }}>
{CustomPageRenderer && imageLoadingState === 'loaded' && (
<CustomPageRenderer
pageData={{
@@ -360,11 +522,12 @@ const PdfViewerPage = ({
pageWidth: unscaledWidth,
pageHeight: unscaledHeight,
imageLoadingState,
onReadyChange: setIsCustomRendererReady,
}}
/>
)}
<PdfViewerPageImage imageLoadingState={imageLoadingState} imageProps={imageProps} />
<PdfViewerPageImage imageLoadingState={imageLoadingState} isPageReady={isPageReady} imageProps={imageProps} />
</div>
);
};
@@ -502,8 +665,16 @@ const usePdfPageImage = ({ pageNumber, pdf, scale, scaledWidth, scaledHeight }:
const imageProps = useMemo(
(): React.ImgHTMLAttributes<HTMLImageElement> & Record<string, unknown> & { alt: '' } => ({
className: PDF_VIEWER_PAGE_CLASSNAME,
width: Math.floor(scaledWidth),
height: Math.floor(scaledHeight),
width: scaledWidth,
height: scaledHeight,
// Pin the rendered size to the page size. Tailwind's preflight applies
// `max-width: 100%; height: auto` to images, which would otherwise let
// the container clamp the image out of alignment with the page overlay.
style: {
width: scaledWidth,
height: scaledHeight,
maxWidth: 'none',
},
alt: '',
onLoad: () => setImageLoadingState('loaded'),
onError: () => setImageLoadingState('error'),
@@ -209,7 +209,6 @@ export const TemplatesTable = ({
<div className="flex items-center gap-x-4">
<TemplateUseDialog
envelopeId={row.original.envelopeId}
templateId={row.original.id}
templateSigningOrder={row.original.templateMeta?.signingOrder}
documentDistributionMethod={row.original.templateMeta?.distributionMethod}
recipients={row.original.recipients}
@@ -595,6 +595,8 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
memberCount: organisation.organisationClaim.memberCount,
envelopeItemCount: organisation.organisationClaim.envelopeItemCount,
recipientCount: organisation.organisationClaim.recipientCount,
envelopeContentCount: organisation.organisationClaim.envelopeContentCount,
envelopeContentImageCount: organisation.organisationClaim.envelopeContentImageCount,
flags: organisation.organisationClaim.flags,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
documentRateLimits: organisation.organisationClaim.documentRateLimits as NonNullable<
@@ -820,6 +822,54 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
</FormItem>
)}
/>
<FormField
control={form.control}
name="claims.envelopeContentCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Envelope Content Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Maximum number of contents per envelope allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="claims.envelopeContentImageCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Envelope Content Image Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Maximum number of image contents per envelope allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div>
@@ -30,6 +30,13 @@ export default function Layout() {
(organisation.subscription && organisation.subscription.status === SubscriptionStatus.INACTIVE) ||
isOrganisationPendingPayment(organisation);
// The content allowances come straight from the organisation's claim, as
// they are already known on the client.
const contentLimits = {
maximumEnvelopeContentCount: organisation.organisationClaim.envelopeContentCount,
maximumEnvelopeContentImageCount: organisation.organisationClaim.envelopeContentImageCount,
};
if (isRestricted) {
return {
quota: {
@@ -43,6 +50,7 @@ export default function Layout() {
directTemplates: 0,
},
maximumEnvelopeItemCount: 0,
...contentLimits,
};
}
@@ -50,6 +58,7 @@ export default function Layout() {
quota: PAID_PLAN_LIMITS,
remaining: PAID_PLAN_LIMITS,
maximumEnvelopeItemCount: DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
...contentLimits,
};
}, [organisation]);
@@ -160,6 +160,7 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
envelopeItems={envelope.envelopeItems}
token={undefined}
fields={envelope.fields}
contents={envelope.contents}
signatures={fieldSignatures}
recipients={envelope.recipients}
overrideSettings={{
@@ -176,6 +176,7 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
envelopeItems={envelope.envelopeItems}
token={undefined}
fields={envelope.fields}
contents={envelope.contents}
recipients={envelope.recipients}
overrideSettings={{
showRecipientTooltip: true,
@@ -255,7 +256,6 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
<div className="mt-4 border-t px-4 pt-4">
<TemplateUseDialog
envelopeId={envelope.id}
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
templateSigningOrder={envelope.documentMeta?.signingOrder}
recipients={envelope.recipients}
documentRootPath={documentRootPath}
@@ -15,7 +15,10 @@ import { redirect } from 'react-router';
import { match } from 'ts-pattern';
import { Header as AuthenticatedHeader } from '~/components/general/app-header';
import { DirectTemplateInvalidPageView } from '~/components/general/direct-template/direct-template-invalid-page';
import {
DirectTemplateInvalidPageView,
type DirectTemplateInvalidReason,
} from '~/components/general/direct-template/direct-template-invalid-page';
import { DirectTemplatePageView } from '~/components/general/direct-template/direct-template-page';
import { DirectTemplateAuthPageView } from '~/components/general/direct-template/direct-template-signing-auth-page';
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
@@ -77,13 +80,13 @@ const handleV1Loader = async ({ params, request }: Route.LoaderArgs) => {
if (recipientsWithMissingFields.length > 0) {
return {
isAccessAuthValid: true,
isTemplateMissingSignatures: true,
invalidReason: 'MISSING_SIGNATURE_FIELD',
} as const;
}
return {
isAccessAuthValid: true,
isTemplateMissingSignatures: false,
invalidReason: null,
template: {
...template,
folder: null,
@@ -108,7 +111,7 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
.then((envelopeForSigning) => {
return {
isDocumentAccessValid: true,
isTemplateMissingSignatures: false,
invalidReason: null,
envelopeForSigning,
} as const;
})
@@ -121,10 +124,18 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
} as const;
}
if (error.code === AppErrorCode.MISSING_SIGNATURE_FIELD) {
// Reasons the template renders but cannot be submitted, reported up
// front rather than once the signer has filled everything in.
const invalidReason = match(error.code)
.returnType<DirectTemplateInvalidReason | null>()
.with(AppErrorCode.MISSING_SIGNATURE_FIELD, () => 'MISSING_SIGNATURE_FIELD')
.with('MISSING_CONTENT_IMAGE', () => 'MISSING_CONTENT_IMAGE')
.otherwise(() => null);
if (invalidReason) {
return {
isDocumentAccessValid: true,
isTemplateMissingSignatures: true,
invalidReason,
} as const;
}
@@ -201,8 +212,8 @@ const DirectSigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV
return <DirectTemplateAuthPageView />;
}
if (data.isTemplateMissingSignatures) {
return <DirectTemplateInvalidPageView />;
if (data.invalidReason) {
return <DirectTemplateInvalidPageView reason={data.invalidReason} />;
}
const { template, directTemplateRecipient } = data;
@@ -259,8 +270,8 @@ const DirectSigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV
return <DocumentSigningAuthPageView email={''} emailHasAccount={true} />;
}
if (data.isTemplateMissingSignatures) {
return <DirectTemplateInvalidPageView />;
if (data.invalidReason) {
return <DirectTemplateInvalidPageView reason={data.invalidReason} />;
}
const { envelope, recipient } = data.envelopeForSigning;
@@ -283,6 +294,7 @@ const DirectSigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV
version="current"
envelope={envelope}
envelopeItems={envelope.envelopeItems}
contents={envelope.contents}
token={recipient.token}
>
<DocumentSigningPageViewV2 />
@@ -577,6 +577,7 @@ const SigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV2Loade
version="current"
envelope={envelope}
envelopeItems={envelope.envelopeItems}
contents={envelope.contents}
token={recipient.token}
>
<DocumentSigningPageViewV2 />
@@ -358,6 +358,7 @@ const EmbedDirectTemplatePageV2 = ({ data }: { data: Awaited<ReturnType<typeof h
version="current"
envelope={envelope}
envelopeItems={envelope.envelopeItems}
contents={envelope.contents}
token={recipient.token}
>
<EmbedSignDocumentV2ClientPage
@@ -442,6 +442,7 @@ const EmbedSignDocumentPageV2 = ({ data }: { data: Awaited<ReturnType<typeof han
version="current"
envelope={envelope}
envelopeItems={envelope.envelopeItems}
contents={envelope.contents}
token={token}
>
<EmbedSignDocumentV2ClientPage
@@ -50,6 +50,7 @@ export default function EmbedPlaygroundPage() {
allowConfigureEnvelopeTitle: true,
allowUploadAndRecipientStep: true,
allowAddFieldsStep: true,
allowAddContentsStep: true,
allowPreviewStep: true,
minimizeLeftSidebar: true,
});
@@ -149,6 +149,8 @@ export default function AuthoringLayout() {
quota: PAID_PLAN_LIMITS,
remaining: PAID_PLAN_LIMITS,
maximumEnvelopeItemCount: organisationClaim.envelopeItemCount,
maximumEnvelopeContentCount: organisationClaim.envelopeContentCount,
maximumEnvelopeContentImageCount: organisationClaim.envelopeContentImageCount,
}}
teamId={team.id}
>
@@ -13,7 +13,11 @@ import {
import type { TEnvelopeFieldAndMeta } from '@documenso/lib/types/field-meta';
import { fireAndForget } from '@documenso/lib/universal/fire-and-forget';
import { extractDerivedDocumentMeta } from '@documenso/lib/utils/document';
import { buildEmbeddedEditorOptions, buildEmbeddedFeatures } from '@documenso/lib/utils/embed-config';
import {
buildEmbeddedEditorOptions,
buildEmbeddedFeatures,
getPendingEmbedImagesToUpload,
} from '@documenso/lib/utils/embed-config';
import { prisma } from '@documenso/prisma';
import { trpc } from '@documenso/trpc/react';
import type { TCreateEnvelopePayload } from '@documenso/trpc/server/envelope-router/create-envelope.types';
@@ -172,7 +176,7 @@ const EnvelopeCreatePage = ({ embedAuthoringOptions }: EnvelopeCreatePageProps)
const buildCreateEnvelopeRequest = (
envelope: Omit<TEditorEnvelope, 'id'>,
): { payload: TCreateEnvelopePayload; files: File[] } => {
): { payload: TCreateEnvelopePayload; files: File[]; contentImages: File[] } => {
const sortedItems = [...envelope.envelopeItems].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const itemIdToIndex = new Map<string, number>();
@@ -224,6 +228,26 @@ const EnvelopeCreatePage = ({ embedAuthoringOptions }: EnvelopeCreatePageProps)
};
});
const imagesToUpload = getPendingEmbedImagesToUpload(envelope.contents);
const contents = envelope.contents.map((content) => {
const image = imagesToUpload.find((imageToUpload) => imageToUpload.id === content.dataContentId);
// Nothing is uploaded before the envelope exists, so every image must
// go along with the request.
if (content.dataContentId && !image) {
throw new Error(`Content "${content.id}" has no image data`);
}
return {
identifier: itemIdToIndex.get(content.envelopeItemId),
contentMeta: content.contentMeta,
imageIndex: image?.index,
};
});
const contentImages = imagesToUpload.map((image) => image.file);
const payload: TCreateEnvelopePayload = {
title: envelope.title,
type: envelope.type,
@@ -237,6 +261,7 @@ const EnvelopeCreatePage = ({ embedAuthoringOptions }: EnvelopeCreatePageProps)
: undefined,
folderId: envelope.folderId ?? undefined,
recipients,
contents,
attachments: envelope.attachments,
meta: {
...envelope.documentMeta,
@@ -255,7 +280,7 @@ const EnvelopeCreatePage = ({ embedAuthoringOptions }: EnvelopeCreatePageProps)
},
};
return { payload, files };
return { payload, files, contentImages };
};
const createEmbeddedEnvelope = async (envelopeWithoutId: Omit<TEditorEnvelope, 'id'>) => {
@@ -266,7 +291,7 @@ const EnvelopeCreatePage = ({ embedAuthoringOptions }: EnvelopeCreatePageProps)
setIsCreatingEnvelope(true);
try {
const { payload, files } = buildCreateEnvelopeRequest(envelopeWithoutId);
const { payload, files, contentImages } = buildCreateEnvelopeRequest(envelopeWithoutId);
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
@@ -275,6 +300,10 @@ const EnvelopeCreatePage = ({ embedAuthoringOptions }: EnvelopeCreatePageProps)
formData.append('files', file);
}
for (const contentImage of contentImages) {
formData.append('contentImages', contentImage);
}
const { id } = await createEmbeddingEnvelope(formData);
// Send a message to the parent window with the document details
@@ -377,6 +406,7 @@ const EnvelopeCreatePage = ({ embedAuthoringOptions }: EnvelopeCreatePageProps)
},
recipients,
fields: [],
contents: [],
envelopeItems: [],
directLink: null,
team: {
@@ -11,7 +11,11 @@ import {
ZEmbedEditEnvelopeAuthoringSchema,
} from '@documenso/lib/types/envelope-editor';
import type { TEnvelopeFieldAndMeta } from '@documenso/lib/types/field-meta';
import { buildEmbeddedEditorOptions, PRESIGNED_ENVELOPE_ITEM_ID_PREFIX } from '@documenso/lib/utils/embed-config';
import {
buildEmbeddedEditorOptions,
getPendingEmbedImagesToUpload,
PRESIGNED_ENVELOPE_ITEM_ID_PREFIX,
} from '@documenso/lib/utils/embed-config';
import { prisma } from '@documenso/prisma';
import { trpc } from '@documenso/trpc/react';
import type { TUpdateEmbeddingEnvelopePayload } from '@documenso/trpc/server/embedding-router/update-embedding-envelope.types';
@@ -173,7 +177,7 @@ const EnvelopeEditPage = ({ embedAuthoringOptions }: EnvelopeEditPageProps) => {
const buildUpdateEnvelopeRequest = (
envelope: TEditorEnvelope,
): { payload: TUpdateEmbeddingEnvelopePayload; files: File[] } => {
): { payload: TUpdateEmbeddingEnvelopePayload; files: File[]; contentImages: File[] } => {
const files: File[] = [];
const envelopeItems = envelope.envelopeItems.map((item) => {
@@ -227,6 +231,24 @@ const EnvelopeEditPage = ({ embedAuthoringOptions }: EnvelopeEditPageProps) => {
};
});
// Removes duplicate content images, and gets the files to upload.
const imagesToUpload = getPendingEmbedImagesToUpload(envelope.contents);
const contentImages = imagesToUpload.map((image) => image.file);
const contents = envelope.contents.map((content) => {
const image = imagesToUpload.find((imageToUpload) => imageToUpload.id === content.dataContentId);
// A new image is referenced by its index, an uploaded one by its ID.
return {
id: content.id,
envelopeItemId: content.envelopeItemId,
contentMeta: content.contentMeta,
dataContentId: image ? null : content.dataContentId,
imageIndex: image?.index,
};
});
const payload: TUpdateEmbeddingEnvelopePayload = {
envelopeId: envelope.id,
data: {
@@ -239,6 +261,7 @@ const EnvelopeEditPage = ({ embedAuthoringOptions }: EnvelopeEditPageProps) => {
recipients,
envelopeItems,
attachments: envelope.attachments,
contents,
},
meta: {
...envelope.documentMeta,
@@ -257,7 +280,7 @@ const EnvelopeEditPage = ({ embedAuthoringOptions }: EnvelopeEditPageProps) => {
},
};
return { payload, files };
return { payload, files, contentImages };
};
const updateEmbeddedEnvelope = async (envelope: TEditorEnvelope) => {
@@ -268,7 +291,7 @@ const EnvelopeEditPage = ({ embedAuthoringOptions }: EnvelopeEditPageProps) => {
setIsUpdatingEnvelope(true);
try {
const { payload, files } = buildUpdateEnvelopeRequest(envelope);
const { payload, files, contentImages } = buildUpdateEnvelopeRequest(envelope);
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
@@ -277,6 +300,10 @@ const EnvelopeEditPage = ({ embedAuthoringOptions }: EnvelopeEditPageProps) => {
formData.append('files', file);
}
for (const contentImage of contentImages) {
formData.append('contentImages', contentImage);
}
await updateEmbeddingEnvelope(formData);
// Send a message to the parent window with the document details
@@ -18,10 +18,28 @@ export const FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE = {
description: msg`Your organisation has reached its plan's fair use limit. Please contact your organisation administrator or support to continue.`,
};
const MISSING_CONTENT_IMAGE_ERROR_MESSAGE: ToastMessageDescriptor = {
title: msg`Missing images`,
description: msg`One or more image contents have no image. Upload an image for each of them, or remove them.`,
};
const ENVELOPE_CONTENT_LIMIT_EXCEEDED_ERROR_MESSAGE: ToastMessageDescriptor = {
title: msg`Envelope content limit exceeded`,
description: msg`This envelope has too many content items. Please remove some, or contact support if you need more.`,
};
const ENVELOPE_CONTENT_IMAGE_LIMIT_EXCEEDED_ERROR_MESSAGE: ToastMessageDescriptor = {
title: msg`Envelope image content limit exceeded`,
description: msg`This envelope has too many image content items. Please remove some, or contact support if you need more.`,
};
export const getDistributeErrorMessage = (code: string): ToastMessageDescriptor => {
return match(code)
.with('RECIPIENT_LIMIT_EXCEEDED', () => RECIPIENT_LIMIT_EXCEEDED_ERROR_MESSAGE)
.with(AppErrorCode.TOO_MANY_REQUESTS, () => FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE)
.with('MISSING_CONTENT_IMAGE', () => MISSING_CONTENT_IMAGE_ERROR_MESSAGE)
.with('ENVELOPE_CONTENT_LIMIT_EXCEEDED', () => ENVELOPE_CONTENT_LIMIT_EXCEEDED_ERROR_MESSAGE)
.with('ENVELOPE_CONTENT_IMAGE_LIMIT_EXCEEDED', () => ENVELOPE_CONTENT_IMAGE_LIMIT_EXCEEDED_ERROR_MESSAGE)
.otherwise(() => ({
title: msg`Something went wrong`,
description: msg`An error occurred while distributing the document.`,
@@ -36,6 +54,9 @@ export const getDirectTemplateErrorMessage = (code: string): ToastMessageDescrip
title: msg`Missing signature fields`,
description: msg`This direct link template cannot be used because one or more signers do not have a signature field assigned.`,
}))
.with('MISSING_CONTENT_IMAGE', () => MISSING_CONTENT_IMAGE_ERROR_MESSAGE)
.with('ENVELOPE_CONTENT_LIMIT_EXCEEDED', () => ENVELOPE_CONTENT_LIMIT_EXCEEDED_ERROR_MESSAGE)
.with('ENVELOPE_CONTENT_IMAGE_LIMIT_EXCEEDED', () => ENVELOPE_CONTENT_IMAGE_LIMIT_EXCEEDED_ERROR_MESSAGE)
.otherwise(() => ({
title: msg`Something went wrong`,
description: msg`We were unable to submit this document at this time. Please try again later.`,
@@ -130,6 +151,10 @@ export const getTemplateUseErrorMessage = (code: string): ToastMessageDescriptor
title: msg`Missing signature fields`,
description: msg`The document could not be sent because some signers do not have a signature field. Please edit the template and add a signature field for each signer.`,
}))
.with('MISSING_CONTENT_IMAGE', () => ({
title: msg`Missing images`,
description: msg`The document could not be sent because one or more image contents have no image. Please edit the template and upload an image for each of them, or remove them.`,
}))
.with(AppErrorCode.INVALID_BODY, AppErrorCode.INVALID_REQUEST, () => ({
title: msg`Error`,
description: msg`The document could not be created because of missing or invalid information. Please review the template's recipients and fields.`,
@@ -234,9 +234,16 @@ const handlePendingFileRequest = async ({
return c.json({ error: 'File not found' }, 404);
}
const contents = await prisma.envelopeContent.findMany({
where: {
envelopeItemId,
},
});
const pdf = await generatePartialSignedPdf({
pdfData: file,
fields,
contents,
});
c.get('logger').info({
+6
View File
@@ -18,6 +18,8 @@ import {
ZGetEnvelopeItemFileTokenRequestParamsSchema,
ZUploadPdfRequestSchema,
} from './files.types';
import getDataContentImageRoute from './routes/get-data-content-image';
import getDataContentImageByTokenRoute from './routes/get-data-content-image-by-token';
import getEnvelopeItemPdfRoute from './routes/get-envelope-item-pdf';
import getEnvelopeItemPdfByTokenRoute from './routes/get-envelope-item-pdf-by-token';
@@ -342,3 +344,7 @@ export const filesRoute = new Hono<HonoEnv>()
// Is different to the other file endpoints since it uses documentDataId for hard caching.
filesRoute.route('/', getEnvelopeItemPdfRoute);
filesRoute.route('/', getEnvelopeItemPdfByTokenRoute);
// Content image routes for both tokens and auth based, hard cached by dataContentId.
filesRoute.route('/', getDataContentImageRoute);
filesRoute.route('/', getDataContentImageByTokenRoute);
@@ -0,0 +1,74 @@
import { prisma } from '@documenso/prisma';
import { sValidator } from '@hono/standard-validator';
import type { Prisma } from '@prisma/client';
import { Hono } from 'hono';
import { z } from 'zod';
import type { HonoEnv } from '../../../router';
import { handleDataContentImageRequest } from './get-data-content-image';
const route = new Hono<HonoEnv>();
const ZGetDataContentImageByTokenParamsSchema = z.object({
token: z.string().min(1),
envelopeId: z.string().min(1),
dataContentId: z.string().min(1),
});
/**
* Returns the image of a data content attached to a content of the envelope,
* using a recipient or QR token.
*/
route.get(
'/token/:token/envelope/:envelopeId/dataContent/:dataContentId/image',
sValidator('param', ZGetDataContentImageByTokenParamsSchema),
async (c) => {
const { token, envelopeId, dataContentId } = c.req.valid('param');
if (!token) {
return c.json({ error: 'Not found' }, 404);
}
// Recipient token based query.
let envelopeWhereQuery: Prisma.EnvelopeWhereInput = {
id: envelopeId,
recipients: {
some: {
token,
},
},
};
// QR token based query.
if (token.startsWith('qr_')) {
envelopeWhereQuery = {
id: envelopeId,
qrToken: token,
};
}
// Validate envelope access via a content the data content is attached to.
const envelopeContent = await prisma.envelopeContent.findFirst({
where: {
envelopeId,
dataContentId,
envelope: envelopeWhereQuery,
},
include: {
dataContent: true,
},
});
if (!envelopeContent?.dataContent) {
return c.json({ error: 'Not found' }, 404);
}
return await handleDataContentImageRequest({
c,
dataContent: envelopeContent.dataContent,
cacheStrategy: 'private',
});
},
);
export default route;
@@ -0,0 +1,156 @@
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { DataContentType } from '@documenso/lib/types/data-content-meta';
import { sha256 } from '@documenso/lib/universal/crypto';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { prisma } from '@documenso/prisma';
import { sValidator } from '@hono/standard-validator';
import type { DataContent } from '@prisma/client';
import { type Context, Hono } from 'hono';
import { z } from 'zod';
import type { HonoEnv } from '../../../router';
import { checkEnvelopeFileAccess } from '../files.helpers';
const route = new Hono<HonoEnv>();
const ZGetDataContentImageRequestParamsSchema = z.object({
envelopeId: z.string().min(1),
dataContentId: z.string().min(1),
});
const ZGetDataContentImageRequestQuerySchema = z.object({
presignToken: z.string().optional(),
});
/**
* Returns the image of a data content attached to a content of the envelope.
*/
route.get(
'/envelope/:envelopeId/dataContent/:dataContentId/image',
sValidator('param', ZGetDataContentImageRequestParamsSchema),
sValidator('query', ZGetDataContentImageRequestQuerySchema),
async (c) => {
const { envelopeId, dataContentId } = c.req.valid('param');
const { presignToken } = c.req.valid('query');
const session = await getOptionalSession(c);
let userId = session.user?.id;
// Check presignToken if provided
if (presignToken) {
const verifiedToken = await verifyEmbeddingPresignToken({
token: presignToken,
}).catch(() => undefined);
userId = verifiedToken?.userId;
}
if (!userId) {
return c.json({ error: 'Not found' }, 404);
}
const envelopeContent = await prisma.envelopeContent.findFirst({
where: {
envelopeId,
dataContentId,
},
include: {
dataContent: true,
envelope: {
select: {
teamId: true,
type: true,
templateType: true,
},
},
},
});
if (!envelopeContent?.dataContent) {
return c.json({ error: 'Not found' }, 404);
}
const { dataContent, envelope } = envelopeContent;
// Check whether the user has access to this content via the envelope.
const hasAccess = await checkEnvelopeFileAccess({
userId,
teamId: envelope.teamId,
envelopeType: envelope.type,
templateType: envelope.templateType,
});
if (!hasAccess) {
return c.json({ error: 'Not found' }, 404);
}
return await handleDataContentImageRequest({
c,
dataContent,
cacheStrategy: 'private',
});
},
);
type HandleDataContentImageRequestOptions = {
c: Context<HonoEnv>;
dataContent: DataContent;
/**
* 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';
};
/**
* Serve the bytes of an image data content.
*
* Data contents are immutable, so the response is hard cached like envelope
* item PDFs. The content type comes from the stored metadata and is never
* sniffed from the bytes.
*/
export const handleDataContentImageRequest = async ({
c,
dataContent,
cacheStrategy,
}: HandleDataContentImageRequestOptions) => {
if (dataContent.metadata.type !== DataContentType.IMAGE) {
return c.json({ error: 'Not found' }, 404);
}
const etag = Buffer.from(sha256(dataContent.data)).toString('hex');
if (c.req.header('If-None-Match') === etag) {
return c.body(null, 304);
}
const file = await getFileServerSide({
type: dataContent.type,
data: dataContent.data,
}).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', dataContent.metadata.mimeType);
c.header('X-Content-Type-Options', 'nosniff');
c.header('ETag', etag);
c.header('Cache-Control', `${cacheStrategy}, max-age=31536000, immutable`);
return c.body(file);
};
export default route;
Binary file not shown.
@@ -0,0 +1,376 @@
import fs from 'node:fs';
import path from 'node:path';
import {
EnvelopeContentShapeType,
EnvelopeContentType,
type TContentHighlightMeta,
type TContentImageMeta,
type TContentShapeMeta,
type TContentTextMeta,
type TEnvelopeContentMetaInput,
} from '@documenso/lib/types/envelope-content-meta';
/**
* A content to seed for the visual regression test, laid out on the labelled
* grid of `assets/content-alignment.pdf` so placement can be read against the
* printed boxes. Each content type has its own page.
*
* The PDF is generated by `scripts/generate-content-alignment-test-pdf.mjs`.
* Keep `CONTENT_ALIGNMENT_GRID` and `CONTENT_ALIGNMENT_PAGES` in sync with
* the grid and page order there.
*/
export type ContentTestData = {
page: number;
contentMeta: TEnvelopeContentMetaInput;
/**
* An image to attach, for image contents.
*/
image?: 'logo';
};
export const CONTENT_ALIGNMENT_PDF = fs.readFileSync(path.join(__dirname, '../../../assets/content-alignment.pdf'));
/**
* The Documenso wordmark, wide (about 6.8:1) so fitting, centering and
* rotation are all visible.
*/
export const CONTENT_ALIGNMENT_LOGO_PNG = fs.readFileSync(path.join(__dirname, '../../../packages/assets/logo.png'));
export const CONTENT_ALIGNMENT_PAGES = {
text: 1,
line: 2,
shape: 3,
highlight: 4,
image: 5,
} as const;
/**
* The page is A4, so a square in page units needs the width scaled by this.
*/
const A4_ASPECT = 210 / 297;
/**
* The grid printed on every page of the PDF, as percentages of the page.
*/
export const CONTENT_ALIGNMENT_GRID = {
startX: 8,
startY: 12,
columnWidth: 28,
columnGap: 1,
rowHeight: 7,
rowGap: 1,
};
const cell = (row: number, column: number, { rows = 1, columns = 1 }: { rows?: number; columns?: number } = {}) => {
const { startX, startY, columnWidth, columnGap, rowHeight, rowGap } = CONTENT_ALIGNMENT_GRID;
return {
positionX: startX + column * (columnWidth + columnGap),
positionY: startY + row * (rowHeight + rowGap),
width: columns * columnWidth + (columns - 1) * columnGap,
height: rows * rowHeight + (rows - 1) * rowGap,
};
};
type Box = ReturnType<typeof cell>;
/**
* A box half the size of a cell, placed at the cell's center.
*
* Contents rotate about their top left corner (see `ZContentRotationSchema`),
* so a rotated box placed at a cell's top left would sweep outside the cell.
* Starting from the center keeps every rotation within the cell.
*/
const halfCell = (row: number, column: number, options: { rows?: number; columns?: number } = {}) => {
const box = cell(row, column, options);
return {
positionX: box.positionX + box.width / 2,
positionY: box.positionY + box.height / 2,
width: box.width / 2,
height: box.height / 2,
};
};
/**
* A square box in page units, as tall as the given number of rows.
*/
const square = (row: number, column: number, rows: number) => {
const box = cell(row, column, { rows });
return { ...box, width: box.height * A4_ASPECT };
};
// --- Builders ---
type Overrides<T> = Partial<Omit<T, 'type'>>;
const text = (box: Box, meta: Overrides<TContentTextMeta> & { text: string }): ContentTestData => ({
page: CONTENT_ALIGNMENT_PAGES.text,
contentMeta: { type: EnvelopeContentType.TEXT, page: CONTENT_ALIGNMENT_PAGES.text, rotation: 0, ...box, ...meta },
});
type LinePoints = { x1: number; y1: number; x2: number; y2: number };
const horizontal = (box: Box): LinePoints => ({
x1: box.positionX,
x2: box.positionX + box.width,
y1: box.positionY + box.height / 2,
y2: box.positionY + box.height / 2,
});
const line = (
points: LinePoints,
meta: { strokeWidth: number; strokeColor?: string; strokeStyle?: 'solid' | 'dashed' | 'dotted' },
): ContentTestData => ({
page: CONTENT_ALIGNMENT_PAGES.line,
contentMeta: {
type: EnvelopeContentType.LINE,
page: CONTENT_ALIGNMENT_PAGES.line,
strokeColor: '#000000',
strokeStyle: 'solid',
...points,
...meta,
},
});
const rectangle = (box: Box, meta: Overrides<TContentShapeMeta> = {}): ContentTestData => ({
page: CONTENT_ALIGNMENT_PAGES.shape,
contentMeta: {
type: EnvelopeContentType.SHAPE,
shape: EnvelopeContentShapeType.RECTANGLE,
page: CONTENT_ALIGNMENT_PAGES.shape,
rotation: 0,
strokeWidth: 1,
strokeColor: '#000000',
strokeStyle: 'solid',
...box,
...meta,
},
});
const highlight = (box: Box, meta: Overrides<TContentHighlightMeta> = {}): ContentTestData => ({
page: CONTENT_ALIGNMENT_PAGES.highlight,
contentMeta: {
type: EnvelopeContentType.HIGHLIGHT,
page: CONTENT_ALIGNMENT_PAGES.highlight,
rotation: 0,
color: '#ffe600',
fillOpacity: 0.4,
...box,
...meta,
},
});
const image = (box: Box, meta: Overrides<TContentImageMeta> = {}): ContentTestData => ({
page: CONTENT_ALIGNMENT_PAGES.image,
image: 'logo',
contentMeta: { type: EnvelopeContentType.IMAGE, page: CONTENT_ALIGNMENT_PAGES.image, rotation: 0, ...box, ...meta },
});
// --- Page 1: Text ---
const TEXT_CONTENTS: ContentTestData[] = [
text(cell(0, 0), { text: 'Left aligned', textAlign: 'left' }),
text(cell(0, 1), { text: 'Center aligned', textAlign: 'center' }),
text(cell(0, 2), { text: 'Right aligned', textAlign: 'right' }),
text(cell(1, 0, { rows: 2 }), { text: 'Top', verticalAlign: 'top' }),
text(cell(1, 1, { rows: 2 }), { text: 'Middle', verticalAlign: 'middle' }),
text(cell(1, 2, { rows: 2 }), { text: 'Bottom', verticalAlign: 'bottom' }),
text(cell(3, 0), { text: 'Size 8', fontSize: 8 }),
text(cell(3, 1), { text: 'Size 14', fontSize: 14, color: '#d00000' }),
text(cell(3, 2), { text: 'Size 24 cropped to the box', fontSize: 24 }),
text(cell(4, 0), { text: 'Two lines of text wrap here', lineHeight: 1.6 }),
text(cell(4, 1), { text: 'Spaced', letterSpacing: 3 }),
text(cell(4, 2, { rows: 2 }), {
text: 'A longer paragraph of text which wraps onto several lines within its box, centered both ways.',
textAlign: 'center',
verticalAlign: 'middle',
}),
// Rotation pivots on the box's top left corner, so a 90 degree rotation
// hangs down the left edge of its cell. Inset it so it stays inside.
text(cell(6, 0, { rows: 2 }), { text: 'Rotated 15', rotation: 15 }),
text({ ...cell(6, 1, { rows: 2 }), positionX: cell(6, 1).positionX + 8 }, { text: 'Rotated 90', rotation: 90 }),
];
// --- Page 2: Lines ---
const LINE_CONTENTS: ContentTestData[] = [
line(horizontal(cell(0, 0)), { strokeWidth: 1, strokeStyle: 'solid' }),
line(horizontal(cell(0, 1)), { strokeWidth: 2, strokeStyle: 'dashed' }),
line(horizontal(cell(0, 2)), { strokeWidth: 3, strokeStyle: 'dotted' }),
line(horizontal(cell(1, 0)), { strokeWidth: 0.5 }),
line(horizontal(cell(1, 1)), { strokeWidth: 5 }),
line(horizontal(cell(1, 2)), { strokeWidth: 10 }),
// Diagonals across the full width, corner to corner.
...(() => {
const down = cell(2, 0, { columns: 3, rows: 2 });
const up = cell(4, 0, { columns: 3, rows: 2 });
return [
line(
{
x1: down.positionX,
y1: down.positionY,
x2: down.positionX + down.width,
y2: down.positionY + down.height,
},
{ strokeWidth: 2, strokeColor: '#0055aa' },
),
line(
{
x1: up.positionX,
y1: up.positionY + up.height,
x2: up.positionX + up.width,
y2: up.positionY,
},
{ strokeWidth: 2, strokeColor: '#0055aa', strokeStyle: 'dashed' },
),
];
})(),
// Vertical lines: through the center, along the left edge, and colored.
...(() => {
const center = cell(6, 0, { rows: 2 });
const edge = cell(6, 1, { rows: 2 });
const red = cell(6, 2, { rows: 2 });
return [
line(
{
x1: center.positionX + center.width / 2,
y1: center.positionY,
x2: center.positionX + center.width / 2,
y2: center.positionY + center.height,
},
{ strokeWidth: 2 },
),
line(
{
x1: edge.positionX,
y1: edge.positionY,
x2: edge.positionX,
y2: edge.positionY + edge.height,
},
{ strokeWidth: 4 },
),
line(
{
x1: red.positionX + red.width / 2,
y1: red.positionY,
x2: red.positionX + red.width / 2,
y2: red.positionY + red.height,
},
{ strokeWidth: 3, strokeColor: '#d00000', strokeStyle: 'dotted' },
),
];
})(),
];
/**
* Two overlapping filled rectangles within a cell, for stacking tests. The
* second is offset down and right of the first so both are partly visible
* and the overlap shows which is on top.
*/
const stackedPair = (
row: number,
column: number,
{ first, second }: { first: { fillColor: string; zIndex: number }; second: { fillColor: string; zIndex: number } },
): ContentTestData[] => {
const box = cell(row, column, { rows: 2 });
const size = { width: box.width * 0.6, height: box.height * 0.6 };
return [
rectangle(
{ positionX: box.positionX, positionY: box.positionY, ...size },
{ fillColor: first.fillColor, fillOpacity: 1, strokeWidth: 1, zIndex: first.zIndex },
),
rectangle(
{ positionX: box.positionX + box.width * 0.4, positionY: box.positionY + box.height * 0.4, ...size },
{ fillColor: second.fillColor, fillOpacity: 1, strokeWidth: 1, zIndex: second.zIndex },
),
];
};
// --- Page 3: Shapes ---
const SHAPE_CONTENTS: ContentTestData[] = [
rectangle(cell(0, 0), { strokeWidth: 2, strokeColor: '#d00000' }),
rectangle(cell(0, 1), { strokeWidth: 1, strokeStyle: 'dashed' }),
rectangle(cell(0, 2), { strokeWidth: 4, strokeStyle: 'dotted' }),
rectangle(cell(1, 0), { fillColor: '#ffcc00', fillOpacity: 1 }),
rectangle(cell(1, 1), { fillColor: '#00aa55', fillOpacity: 0.4 }),
rectangle(cell(1, 2), { fillColor: '#0055aa', fillOpacity: 1, strokeWidth: 0.5, strokeColor: '#0055aa' }),
rectangle(square(2, 0, 2), { strokeWidth: 2 }),
rectangle(cell(2, 1, { columns: 2 }), { strokeWidth: 1, fillColor: '#e5e7eb', fillOpacity: 1 }),
// Rotation pivots on the box's top left corner. Half sized boxes placed
// towards the right of their cells keep the swept shape within the cell.
rectangle(halfCell(4, 0, { rows: 2 }), { strokeWidth: 2, strokeColor: '#d00000', rotation: 15 }),
rectangle(halfCell(4, 1, { rows: 2 }), { strokeWidth: 2, strokeColor: '#00aa55', rotation: 45 }),
rectangle(halfCell(4, 2, { rows: 2 }), { strokeWidth: 2, strokeColor: '#0055aa', rotation: 90 }),
// Stacking. Each cell holds two overlapping filled rectangles: the first
// (top left) and the second (offset to the bottom right). The rectangles
// are listed, and therefore created, in that order, and the seed hands out
// ids in that same order so equal zIndexes stack the second on top.
//
// Expected on top: red, blue, green.
...stackedPair(6, 0, { first: { fillColor: '#d00000', zIndex: 2 }, second: { fillColor: '#ffcc00', zIndex: 1 } }),
...stackedPair(6, 1, { first: { fillColor: '#ffcc00', zIndex: 0 }, second: { fillColor: '#0055aa', zIndex: 5 } }),
...stackedPair(6, 2, { first: { fillColor: '#d00000', zIndex: 3 }, second: { fillColor: '#00aa55', zIndex: 3 } }),
];
// --- Page 4: Highlights ---
const HIGHLIGHT_CONTENTS: ContentTestData[] = [
highlight(cell(0, 0), { color: '#ffe600' }),
highlight(cell(0, 1), { color: '#00aa55' }),
highlight(cell(0, 2), { color: '#ff66cc' }),
highlight(cell(1, 0), { fillOpacity: 0.1 }),
highlight(cell(1, 1), { fillOpacity: 0.6 }),
highlight(cell(1, 2), { fillOpacity: 1 }),
highlight(cell(2, 0, { columns: 3 })),
highlight(halfCell(3, 0, { rows: 2 }), { rotation: 15 }),
highlight(halfCell(3, 1, { rows: 2 }), { rotation: 45 }),
];
// --- Page 5: Images ---
const IMAGE_CONTENTS: ContentTestData[] = [
// Fit: the wide logo fills a wide box, letterboxes in a square, centers in a tall one.
image(cell(0, 0, { rows: 2 })),
image(square(0, 1, 2)),
image({ ...cell(0, 2, { rows: 3 }), width: cell(0, 2).width / 2 }),
image(cell(3, 0, { columns: 3, rows: 2 })),
// Rotation pivots on the box's top left corner. Half sized boxes placed
// towards the right of their cells keep the swept image within the cell.
image(halfCell(5, 0, { rows: 2 }), { rotation: 15 }),
image(halfCell(5, 1, { rows: 2 }), { rotation: 45 }),
image(halfCell(5, 2, { rows: 2 }), { rotation: 90 }),
image(halfCell(7, 0, { rows: 2 }), { rotation: 180 }),
image(halfCell(7, 1, { rows: 2 }), { rotation: 270 }),
];
export const CONTENT_ALIGNMENT_TEST_CONTENTS: ContentTestData[] = [
...TEXT_CONTENTS,
...LINE_CONTENTS,
...SHAPE_CONTENTS,
...HIGHLIGHT_CONTENTS,
...IMAGE_CONTENTS,
];
@@ -48,6 +48,8 @@ const seedSubscriptionClaim = (name: string) =>
memberCount: 1,
envelopeItemCount: 10,
recipientCount: 10,
envelopeContentCount: 0,
envelopeContentImageCount: 0,
flags: {},
documentRateLimits: [],
emailRateLimits: [],
@@ -0,0 +1,54 @@
import { prisma } from '@documenso/prisma';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../../fixtures/authentication';
import { expectToastTextToBeVisible } from '../../fixtures/generic';
test('[ADMIN]: update envelope content claims for an organisation', async ({ page }) => {
const { user: adminUser } = await seedUser({
isAdmin: true,
});
const { organisation } = await seedUser({
isPersonalOrganisation: false,
});
await apiSignin({
page,
email: adminUser.email,
redirectPath: `/admin/organisations/${organisation.id}`,
});
await expect(page.getByText('Manage organisation')).toBeVisible();
const contentCountInput = page.getByLabel('Envelope Content Count', { exact: true });
const imageCountInput = page.getByLabel('Envelope Content Image Count', { exact: true });
// Everything is unlimited by default.
await expect(contentCountInput).toHaveValue('0');
await expect(imageCountInput).toHaveValue('0');
await contentCountInput.fill('25');
await imageCountInput.fill('5');
// "Update role" buttons also match a non-exact name.
await page.getByRole('button', { name: 'Update', exact: true }).last().click();
await expectToastTextToBeVisible(page, 'Organisation has been updated successfully');
await expect(async () => {
const claim = await prisma.organisationClaim.findFirstOrThrow({
where: { organisation: { id: organisation.id } },
});
expect(claim.envelopeContentCount).toBe(25);
expect(claim.envelopeContentImageCount).toBe(5);
}).toPass({ timeout: 10000 });
// The saved values are shown when the page is loaded again.
await page.reload();
await expect(page.getByLabel('Envelope Content Count', { exact: true })).toHaveValue('25');
await expect(page.getByLabel('Envelope Content Image Count', { exact: true })).toHaveValue('5');
});
@@ -0,0 +1,296 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { EnvelopeContentType, type TEnvelopeContentMetaInput } from '@documenso/lib/types/envelope-content-meta';
import { prefixedId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe.configure({
mode: 'parallel',
});
/**
* Contents are burned into the signed PDF, so authoring them is recorded the
* same way authoring a field is.
*
* The audit log type names are written out rather than imported because the
* module which declares them carries lingui macros the test runner cannot
* transform.
*/
const textMeta = (overrides: Partial<TEnvelopeContentMetaInput> = {}): TEnvelopeContentMetaInput => ({
type: EnvelopeContentType.TEXT,
page: 1,
rotation: 0,
positionX: 10,
positionY: 10,
width: 20,
height: 6,
text: 'Approved',
...overrides,
});
const setContents = async (
page: Page,
teamId: number,
envelopeId: string,
contents: {
id?: string;
envelopeItemId: string;
contentMeta: TEnvelopeContentMetaInput;
dataContentId?: string | null;
}[],
) =>
await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/envelope.content.set`, {
headers: { 'content-type': 'application/json', 'x-team-id': teamId.toString() },
data: JSON.stringify({
json: { envelopeId, contents: contents.map((content) => ({ dataContentId: null, ...content })) },
}),
});
const getContentAuditLogs = async (envelopeId: string) =>
await prisma.documentAuditLog.findMany({
where: { envelopeId, type: { startsWith: 'CONTENT_' } },
orderBy: { createdAt: 'asc' },
});
const setupEnvelope = async (page: Page, type: 'document' | 'template' = 'document') => {
const { user, team } = await seedUser();
const envelopeRecord =
type === 'document'
? await seedBlankDocument(user, team.id, { internalVersion: 2 })
: await seedBlankTemplate(user, team.id, { internalVersion: 2 });
await apiSignin({ page, email: user.email, redirectPath: `/t/${team.url}/documents` });
const envelope = await prisma.envelope.findFirstOrThrow({
where: { id: envelopeRecord.id },
include: { envelopeItems: true },
});
return { envelope, envelopeItemId: envelope.envelopeItems[0].id, teamId: team.id };
};
test('logs a created content', async ({ page }) => {
const { envelope, envelopeItemId, teamId } = await setupEnvelope(page);
const res = await setContents(page, teamId, envelope.id, [{ envelopeItemId, contentMeta: textMeta() }]);
expect(res.ok()).toBeTruthy();
const content = await prisma.envelopeContent.findFirstOrThrow({ where: { envelopeId: envelope.id } });
const logs = await getContentAuditLogs(envelope.id);
expect(logs).toHaveLength(1);
expect(logs[0].type).toBe('CONTENT_CREATED');
expect(logs[0].data).toMatchObject({
contentId: content.id,
contentType: EnvelopeContentType.TEXT,
envelopeItemId,
contentMeta: content.contentMeta,
dataContentId: null,
});
});
test('logs a moved content as updated, recording the position', async ({ page }) => {
const { envelope, envelopeItemId, teamId } = await setupEnvelope(page);
await setContents(page, teamId, envelope.id, [{ envelopeItemId, contentMeta: textMeta() }]);
const content = await prisma.envelopeContent.findFirstOrThrow({ where: { envelopeId: envelope.id } });
const res = await setContents(page, teamId, envelope.id, [
{ id: content.id, envelopeItemId, contentMeta: textMeta({ positionX: 55, page: 1 }) },
]);
expect(res.ok()).toBeTruthy();
const logs = await getContentAuditLogs(envelope.id);
expect(logs.map((log) => log.type)).toEqual(['CONTENT_CREATED', 'CONTENT_UPDATED']);
expect(logs[1].data).toMatchObject({
contentId: content.id,
changes: [{ type: 'PROPERTY', key: 'positionX', from: 10, to: 55 }],
});
});
test('logs an edited text as updated', async ({ page }) => {
const { envelope, envelopeItemId, teamId } = await setupEnvelope(page);
await setContents(page, teamId, envelope.id, [{ envelopeItemId, contentMeta: textMeta() }]);
const content = await prisma.envelopeContent.findFirstOrThrow({ where: { envelopeId: envelope.id } });
const res = await setContents(page, teamId, envelope.id, [
{ id: content.id, envelopeItemId, contentMeta: textMeta({ text: 'Rejected' }) },
]);
expect(res.ok()).toBeTruthy();
const logs = await getContentAuditLogs(envelope.id);
expect(logs.map((log) => log.type)).toEqual(['CONTENT_CREATED', 'CONTENT_UPDATED']);
expect(logs[1].data).toMatchObject({
contentId: content.id,
changes: [{ type: 'PROPERTY', key: 'text', from: 'Approved', to: 'Rejected' }],
});
});
test('logs a restyled content as updated', async ({ page }) => {
const { envelope, envelopeItemId, teamId } = await setupEnvelope(page);
await setContents(page, teamId, envelope.id, [{ envelopeItemId, contentMeta: textMeta({ color: '#000000' }) }]);
const content = await prisma.envelopeContent.findFirstOrThrow({ where: { envelopeId: envelope.id } });
await setContents(page, teamId, envelope.id, [
{ id: content.id, envelopeItemId, contentMeta: textMeta({ color: '#ff0000' }) },
]);
const logs = await getContentAuditLogs(envelope.id);
expect(logs[1].data).toMatchObject({
changes: [{ type: 'PROPERTY', key: 'color', from: '#000000', to: '#ff0000' }],
});
});
/**
* A content belongs to the item it was created on. The diff which feeds the
* audit log does not cover the item, so a move is refused outright rather
* than applied without a record.
*/
test('rejects moving a content to another envelope item', async ({ page }) => {
const { envelope, envelopeItemId, teamId } = await setupEnvelope(page);
const firstItem = await prisma.envelopeItem.findFirstOrThrow({
where: { id: envelopeItemId },
include: { documentData: true },
});
const secondDocumentData = await prisma.documentData.create({
data: {
type: firstItem.documentData.type,
data: firstItem.documentData.data,
initialData: firstItem.documentData.initialData,
},
});
const secondItem = await prisma.envelopeItem.create({
data: {
id: prefixedId('envelope_item'),
title: `${firstItem.title} - 2`,
documentDataId: secondDocumentData.id,
order: 2,
envelopeId: envelope.id,
},
});
await setContents(page, teamId, envelope.id, [{ envelopeItemId, contentMeta: textMeta() }]);
const content = await prisma.envelopeContent.findFirstOrThrow({ where: { envelopeId: envelope.id } });
const res = await setContents(page, teamId, envelope.id, [
{ id: content.id, envelopeItemId: secondItem.id, contentMeta: textMeta() },
]);
expect(res.status()).toBe(400);
const reloaded = await prisma.envelopeContent.findFirstOrThrow({ where: { id: content.id } });
expect(reloaded.envelopeItemId).toBe(envelopeItemId);
const logs = await getContentAuditLogs(envelope.id);
expect(logs.map((log) => log.type)).toEqual(['CONTENT_CREATED']);
});
test('does not log a save which changed nothing', async ({ page }) => {
const { envelope, envelopeItemId, teamId } = await setupEnvelope(page);
await setContents(page, teamId, envelope.id, [{ envelopeItemId, contentMeta: textMeta() }]);
const content = await prisma.envelopeContent.findFirstOrThrow({ where: { envelopeId: envelope.id } });
await setContents(page, teamId, envelope.id, [{ id: content.id, envelopeItemId, contentMeta: textMeta() }]);
const logs = await getContentAuditLogs(envelope.id);
expect(logs.map((log) => log.type)).toEqual(['CONTENT_CREATED']);
});
/**
* Bringing a content to the front is an authoring action like any other
* property change, so it is recorded. Merely selecting a content no longer
* rewrites its order, so this is only logged when the order actually moves.
*/
test('logs a changed stacking order as updated', async ({ page }) => {
const { envelope, envelopeItemId, teamId } = await setupEnvelope(page);
await setContents(page, teamId, envelope.id, [{ envelopeItemId, contentMeta: textMeta() }]);
const content = await prisma.envelopeContent.findFirstOrThrow({ where: { envelopeId: envelope.id } });
const res = await setContents(page, teamId, envelope.id, [
{ id: content.id, envelopeItemId, contentMeta: textMeta({ zIndex: content.contentMeta.zIndex + 5 }) },
]);
expect(res.ok()).toBeTruthy();
const reloaded = await prisma.envelopeContent.findFirstOrThrow({ where: { id: content.id } });
expect(reloaded.contentMeta.zIndex).toBe(content.contentMeta.zIndex + 5);
const logs = await getContentAuditLogs(envelope.id);
expect(logs.map((log) => log.type)).toEqual(['CONTENT_CREATED', 'CONTENT_UPDATED']);
expect(logs[1].data).toMatchObject({
contentId: content.id,
changes: [
{ type: 'PROPERTY', key: 'zIndex', from: content.contentMeta.zIndex, to: content.contentMeta.zIndex + 5 },
],
});
});
test('logs a removed content as deleted', async ({ page }) => {
const { envelope, envelopeItemId, teamId } = await setupEnvelope(page);
await setContents(page, teamId, envelope.id, [{ envelopeItemId, contentMeta: textMeta() }]);
const content = await prisma.envelopeContent.findFirstOrThrow({ where: { envelopeId: envelope.id } });
const res = await setContents(page, teamId, envelope.id, []);
expect(res.ok()).toBeTruthy();
const logs = await getContentAuditLogs(envelope.id);
expect(logs.map((log) => log.type)).toEqual(['CONTENT_CREATED', 'CONTENT_DELETED']);
expect(logs[1].data).toMatchObject({
contentId: content.id,
contentType: EnvelopeContentType.TEXT,
envelopeItemId,
});
});
/**
* Templates are not audit logged, matching `setFieldsForTemplate`.
*/
test('does not log contents authored on a template', async ({ page }) => {
const { envelope, envelopeItemId, teamId } = await setupEnvelope(page, 'template');
const res = await setContents(page, teamId, envelope.id, [{ envelopeItemId, contentMeta: textMeta() }]);
expect(res.ok()).toBeTruthy();
expect(await prisma.envelopeContent.count({ where: { envelopeId: envelope.id } })).toBe(1);
expect(await getContentAuditLogs(envelope.id)).toHaveLength(0);
});
@@ -0,0 +1,97 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { EnvelopeContentType, type TEnvelopeContentMetaInput } from '@documenso/lib/types/envelope-content-meta';
import { prisma } from '@documenso/prisma';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, type Page, test } from '@playwright/test';
import { apiSignin } from '../../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe.configure({
mode: 'parallel',
});
/**
* The editor sends every content on every save, and the ones which already
* exist are identified by their ID. An ID listed twice would update the same
* content twice, so it is rejected. New contents have no ID, so any number of
* them can be created in one save.
*/
const textMeta = (text: string, positionY = 10): TEnvelopeContentMetaInput => ({
type: EnvelopeContentType.TEXT,
page: 1,
positionX: 10,
positionY,
width: 20,
height: 6,
text,
});
type SetContentInput = {
id?: string;
envelopeItemId: string;
contentMeta: TEnvelopeContentMetaInput;
};
const setContents = async (page: Page, teamId: number, envelopeId: string, contents: SetContentInput[]) =>
await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/envelope.content.set`, {
headers: { 'content-type': 'application/json', 'x-team-id': teamId.toString() },
data: JSON.stringify({
json: { envelopeId, contents: contents.map((content) => ({ dataContentId: null, ...content })) },
}),
});
const setupDocument = async (page: Page) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id, { internalVersion: 2 });
await apiSignin({ page, email: user.email, redirectPath: `/t/${team.url}/documents` });
const envelope = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
include: { envelopeItems: true },
});
return { envelopeId: envelope.id, envelopeItemId: envelope.envelopeItems[0].id, teamId: team.id };
};
test('rejects a content listed twice without changing it', async ({ page }) => {
const { envelopeId, envelopeItemId, teamId } = await setupDocument(page);
const created = await setContents(page, teamId, envelopeId, [{ envelopeItemId, contentMeta: textMeta('Original') }]);
expect(created.ok(), await created.text()).toBeTruthy();
const content = await prisma.envelopeContent.findFirstOrThrow({ where: { envelopeId } });
const res = await setContents(page, teamId, envelopeId, [
{ id: content.id, envelopeItemId, contentMeta: textMeta('First edit') },
{ id: content.id, envelopeItemId, contentMeta: textMeta('Second edit') },
]);
expect(res.status()).toBe(400);
expect(await res.text()).toContain('Content IDs must be unique');
const contents = await prisma.envelopeContent.findMany({ where: { envelopeId } });
expect(contents).toHaveLength(1);
expect(contents[0].contentMeta).toEqual(content.contentMeta);
});
test('creates several new contents in one save', async ({ page }) => {
const { envelopeId, envelopeItemId, teamId } = await setupDocument(page);
const res = await setContents(page, teamId, envelopeId, [
{ envelopeItemId, contentMeta: textMeta('First', 10) },
{ envelopeItemId, contentMeta: textMeta('Second', 30) },
{ envelopeItemId, contentMeta: textMeta('Third', 50) },
]);
expect(res.ok(), await res.text()).toBeTruthy();
expect(await prisma.envelopeContent.count({ where: { envelopeId } })).toBe(3);
});
@@ -0,0 +1,219 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import {
CONTENT_MAX_Z_INDEX,
EnvelopeContentType,
type TEnvelopeContentMetaInput,
ZEnvelopeContentMetaSchema,
} from '@documenso/lib/types/envelope-content-meta';
import { generateDatabaseId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe.configure({
mode: 'parallel',
});
/**
* The editor hides the palette once a limit is reached, so these cover the
* server side of that rule: the API is the only way to push an envelope past
* its organisation's allowance.
*/
const textMeta = (index: number, overrides: { zIndex?: number } = {}): TEnvelopeContentMetaInput => ({
type: EnvelopeContentType.TEXT,
page: 1,
rotation: 0,
positionX: 10,
positionY: 10,
width: 20,
height: 6,
text: `Content ${index}`,
...overrides,
});
const imageMeta = (): TEnvelopeContentMetaInput => ({
type: EnvelopeContentType.IMAGE,
page: 1,
rotation: 0,
positionX: 10,
positionY: 30,
width: 20,
height: 10,
});
const setOrganisationContentLimits = async (
teamId: number,
limits: { envelopeContentCount?: number; envelopeContentImageCount?: number },
) => {
const team = await prisma.team.findFirstOrThrow({ where: { id: teamId } });
const organisationClaim = await prisma.organisationClaim.findFirstOrThrow({
where: { organisation: { id: team.organisationId } },
});
await prisma.organisationClaim.update({ where: { id: organisationClaim.id }, data: limits });
};
/**
* Seed contents straight onto the envelope, bypassing the limit check, to set
* up an envelope which is already over its allowance.
*/
const seedContents = async (envelopeId: string, envelopeItemId: string, count: number, images = 0) => {
await prisma.envelopeContent.createMany({
data: Array.from({ length: count }).map((_, index) => ({
id: generateDatabaseId('envelope_content'),
envelopeId,
envelopeItemId,
contentMeta: ZEnvelopeContentMetaSchema.parse(index < images ? imageMeta() : textMeta(index)),
})),
});
};
const setContents = async (
page: Page,
teamId: number,
envelopeId: string,
contents: {
id?: string;
envelopeItemId: string;
contentMeta: TEnvelopeContentMetaInput;
dataContentId?: string | null;
}[],
) =>
await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/envelope.content.set`, {
headers: { 'content-type': 'application/json', 'x-team-id': teamId.toString() },
data: JSON.stringify({
json: { envelopeId, contents: contents.map((content) => ({ dataContentId: null, ...content })) },
}),
});
const getContentCount = async (envelopeId: string) => await prisma.envelopeContent.count({ where: { envelopeId } });
const setupEnvelope = async (
page: Page,
limits: { envelopeContentCount?: number; envelopeContentImageCount?: number },
) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id, { internalVersion: 2 });
await setOrganisationContentLimits(team.id, limits);
await apiSignin({ page, email: user.email, redirectPath: `/t/${team.url}/documents` });
const envelope = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
include: { envelopeItems: true },
});
return { envelope, envelopeItemId: envelope.envelopeItems[0].id, teamId: team.id };
};
test('rejects a save which exceeds the content limit', async ({ page }) => {
const { envelope, envelopeItemId, teamId } = await setupEnvelope(page, { envelopeContentCount: 2 });
const withinLimit = await setContents(
page,
teamId,
envelope.id,
Array.from({ length: 2 }).map((_, index) => ({ envelopeItemId, contentMeta: textMeta(index) })),
);
expect(withinLimit.ok()).toBeTruthy();
expect(await getContentCount(envelope.id)).toBe(2);
const overLimit = await setContents(
page,
teamId,
envelope.id,
Array.from({ length: 3 }).map((_, index) => ({ envelopeItemId, contentMeta: textMeta(index) })),
);
expect(overLimit.status()).toBe(400);
expect(await overLimit.text()).toContain('ENVELOPE_CONTENT_LIMIT_EXCEEDED');
// The rejected save left the envelope untouched.
expect(await getContentCount(envelope.id)).toBe(2);
});
test('rejects a save which exceeds the image content limit', async ({ page }) => {
const { envelope, envelopeItemId, teamId } = await setupEnvelope(page, { envelopeContentImageCount: 1 });
const overLimit = await setContents(page, teamId, envelope.id, [
{ envelopeItemId, contentMeta: imageMeta() },
{ envelopeItemId, contentMeta: imageMeta() },
]);
expect(overLimit.status()).toBe(400);
expect(await overLimit.text()).toContain('ENVELOPE_CONTENT_IMAGE_LIMIT_EXCEEDED');
expect(await getContentCount(envelope.id)).toBe(0);
});
/**
* An envelope can end up over its limit without being edited, e.g. after the
* organisation's plan is lowered, so removals must keep working.
*/
test('allows an over limit envelope to be trimmed back down', async ({ page }) => {
const { envelope, envelopeItemId, teamId } = await setupEnvelope(page, { envelopeContentCount: 2 });
await seedContents(envelope.id, envelopeItemId, 5);
const existing = await prisma.envelopeContent.findMany({
where: { envelopeId: envelope.id },
orderBy: { id: 'asc' },
});
// Removing one while still over the limit is allowed.
const trimmed = await setContents(
page,
teamId,
envelope.id,
existing.slice(0, 4).map((content) => ({
id: content.id,
envelopeItemId,
contentMeta: textMeta(0),
})),
);
expect(trimmed.ok()).toBeTruthy();
expect(await getContentCount(envelope.id)).toBe(4);
// Adding while over the limit is still refused.
const added = await setContents(page, teamId, envelope.id, [
...existing.slice(0, 4).map((content) => ({ id: content.id, envelopeItemId, contentMeta: textMeta(0) })),
{ envelopeItemId, contentMeta: textMeta(99) },
]);
expect(added.status()).toBe(400);
expect(await getContentCount(envelope.id)).toBe(4);
// And once back within the allowance everything works normally again.
const backWithin = await setContents(
page,
teamId,
envelope.id,
existing.slice(0, 2).map((content) => ({ id: content.id, envelopeItemId, contentMeta: textMeta(0) })),
);
expect(backWithin.ok()).toBeTruthy();
expect(await getContentCount(envelope.id)).toBe(2);
});
test('rejects a stacking order above the authored ceiling', async ({ page }) => {
const { envelope, envelopeItemId, teamId } = await setupEnvelope(page, {});
const res = await setContents(page, teamId, envelope.id, [
{ envelopeItemId, contentMeta: textMeta(0, { zIndex: CONTENT_MAX_Z_INDEX + 1 }) },
]);
expect(res.ok()).toBeFalsy();
expect(await getContentCount(envelope.id)).toBe(0);
});
@@ -0,0 +1,482 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createDataContentImage } from '@documenso/lib/server-only/data-content/create-data-content-image';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import {
EnvelopeContentType,
type TEnvelopeContentMeta,
ZEnvelopeContentMetaSchema,
} from '@documenso/lib/types/envelope-content-meta';
import { generateDatabaseId, nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import type { TUpdateEmbeddingEnvelopePayload } from '@documenso/trpc/server/embedding-router/update-embedding-envelope.types';
import { type APIRequestContext, expect, test } from '@playwright/test';
import type { Team } from '@prisma/client';
import { createGifLabelledAsPng, createImageFile, type TestImageFile } from '../../fixtures/contents';
/**
* The embedded editor saves everything in one request when the envelope is
* updated. New contents have temporary IDs, new images are referenced by their
* index in `contentImages`, and the contents are checked before anything is
* written, so a request which fails changes nothing.
*/
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe.configure({
mode: 'parallel',
});
/**
* Seed a draft document and a presign token scoped to it, as the embedded
* editor is given.
*/
const setupEmbeddedDocument = async (request: APIRequestContext) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id, { internalVersion: 2 });
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: document.id },
include: { envelopeItems: true },
});
const { token: apiToken } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'e2e-embed-update-contents',
expiresIn: null,
});
const presignRes = await request.post(`${WEBAPP_BASE_URL}/api/v2/embedding/create-presign-token`, {
headers: { Authorization: `Bearer ${apiToken}`, 'Content-Type': 'application/json' },
data: { scope: `envelopeId:${envelope.id}` },
});
expect(presignRes.ok()).toBeTruthy();
const { token: presignToken }: { token: string } = await presignRes.json();
return { team, envelope, envelopeItem: envelope.envelopeItems[0], presignToken };
};
type UpdateEmbeddedEnvelopeOptions = {
request: APIRequestContext;
presignToken: string;
envelope: { id: string; title: string };
envelopeItem: { id: string; title: string; order: number };
data: Partial<TUpdateEmbeddingEnvelopePayload['data']>;
images?: TestImageFile[];
};
/**
* Save the envelope through the embedded update route, as the embedded
* editor does.
*/
const updateEmbeddedEnvelope = async ({
request,
presignToken,
envelope,
envelopeItem,
data,
images = [],
}: UpdateEmbeddedEnvelopeOptions) => {
const payload: TUpdateEmbeddingEnvelopePayload = {
envelopeId: envelope.id,
data: {
title: envelope.title,
envelopeItems: [{ id: envelopeItem.id, title: envelopeItem.title, order: envelopeItem.order }],
recipients: [],
attachments: [],
...data,
},
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
for (const image of images) {
formData.append('contentImages', new File([image.buffer], image.name, { type: image.mimeType }));
}
return await request.post(`${WEBAPP_BASE_URL}/api/trpc/embeddingPresign.updateEmbeddingEnvelope`, {
headers: { authorization: `Bearer ${presignToken}` },
multipart: formData,
});
};
const setOrganisationContentLimit = async (team: Team, envelopeContentCount: number) => {
const organisationClaim = await prisma.organisationClaim.findFirstOrThrow({
where: {
organisation: {
id: team.organisationId,
},
},
});
await prisma.organisationClaim.update({
where: {
id: organisationClaim.id,
},
data: {
envelopeContentCount,
},
});
};
const countDataContentsNamed = async (fileName: string) =>
await prisma.dataContent.count({
where: {
metadata: {
path: ['fileName'],
equals: fileName,
},
},
});
/**
* A failed update must leave the envelope as it was: the title is only
* changed by the steps which run before the contents are set.
*/
const expectEnvelopeUnchanged = async (envelope: { id: string; title: string }) => {
const current = await prisma.envelope.findUniqueOrThrow({
where: { id: envelope.id },
include: { contents: true },
});
expect(current.title).toBe(envelope.title);
expect(current.contents).toHaveLength(0);
};
const textMeta = (positionY = 10): TEnvelopeContentMeta =>
ZEnvelopeContentMetaSchema.parse({
type: EnvelopeContentType.TEXT,
page: 1,
positionX: 10,
positionY,
width: 40,
height: 5,
text: 'Text',
});
const imageMeta = (positionY: number): TEnvelopeContentMeta =>
ZEnvelopeContentMetaSchema.parse({
type: EnvelopeContentType.IMAGE,
page: 1,
positionX: 10,
positionY,
width: 30,
height: 10,
});
test.describe('Update embedded envelope contents', () => {
test('saves new contents and their new images', async ({ request }) => {
const { envelope, envelopeItem, presignToken } = await setupEmbeddedDocument(request);
const image = await createImageFile(`embed-update-${nanoid()}.png`, 300, 100);
const res = await updateEmbeddedEnvelope({
request,
presignToken,
envelope,
envelopeItem,
data: {
contents: [
{
id: 'PRESIGNED_content-a',
envelopeItemId: envelopeItem.id,
contentMeta: imageMeta(10),
dataContentId: null,
imageIndex: 0,
},
// A duplicate showing the same image.
{
id: 'PRESIGNED_content-b',
envelopeItemId: envelopeItem.id,
contentMeta: imageMeta(40),
dataContentId: null,
imageIndex: 0,
},
{
id: 'PRESIGNED_content-c',
envelopeItemId: envelopeItem.id,
contentMeta: textMeta(),
dataContentId: null,
},
],
},
images: [image],
});
expect(res.ok(), await res.text()).toBeTruthy();
const contents = await prisma.envelopeContent.findMany({
where: { envelopeId: envelope.id },
include: { dataContent: true },
});
expect(contents).toHaveLength(3);
expect(contents.some((content) => content.id.startsWith('PRESIGNED_'))).toBe(false);
const imageContents = contents.filter((content) => content.contentMeta.type === EnvelopeContentType.IMAGE);
expect(imageContents).toHaveLength(2);
// The image was stored once and is shared by both contents.
expect(imageContents[0].dataContentId).not.toBeNull();
expect(imageContents[0].dataContentId).toBe(imageContents[1].dataContentId);
expect(imageContents[0].dataContent?.metadata).toMatchObject({
width: 300,
height: 100,
fileName: image.name,
});
});
test('rejects contents over the limit without changing anything', async ({ request }) => {
const { team, envelope, envelopeItem, presignToken } = await setupEmbeddedDocument(request);
const image = await createImageFile(`embed-limit-${nanoid()}.png`, 50, 50);
await setOrganisationContentLimit(team, 1);
const res = await updateEmbeddedEnvelope({
request,
presignToken,
envelope,
envelopeItem,
data: {
title: 'Changed title',
contents: [
{
id: 'PRESIGNED_content-a',
envelopeItemId: envelopeItem.id,
contentMeta: imageMeta(10),
dataContentId: null,
imageIndex: 0,
},
{
id: 'PRESIGNED_content-b',
envelopeItemId: envelopeItem.id,
contentMeta: textMeta(),
dataContentId: null,
},
],
},
images: [image],
});
expect(res.status()).toBe(400);
expect(await res.text()).toContain('ENVELOPE_CONTENT_LIMIT_EXCEEDED');
await expectEnvelopeUnchanged(envelope);
expect(await countDataContentsNamed(image.name)).toBe(0);
});
test('rejects an unknown content id without changing anything', async ({ request }) => {
const { envelope, envelopeItem, presignToken } = await setupEmbeddedDocument(request);
const res = await updateEmbeddedEnvelope({
request,
presignToken,
envelope,
envelopeItem,
data: {
title: 'Changed title',
contents: [
{
id: 'envelope_content_unknown',
envelopeItemId: envelopeItem.id,
contentMeta: textMeta(),
dataContentId: null,
},
],
},
});
expect(res.status()).toBe(404);
expect(await res.text()).toContain('Envelope content not found');
await expectEnvelopeUnchanged(envelope);
});
test('rejects an image which is not attached to the envelope', async ({ request }) => {
const { envelope, envelopeItem, presignToken } = await setupEmbeddedDocument(request);
// An image stored for something else, referenced by its ID.
const png = await createImageFile(`embed-foreign-${nanoid()}.png`, 50, 50);
const foreignDataContent = await createDataContentImage({
file: {
name: png.name,
arrayBuffer: async () => await new Blob([png.buffer]).arrayBuffer(),
},
});
const res = await updateEmbeddedEnvelope({
request,
presignToken,
envelope,
envelopeItem,
data: {
title: 'Changed title',
contents: [
{
id: 'PRESIGNED_content-a',
envelopeItemId: envelopeItem.id,
contentMeta: imageMeta(10),
dataContentId: foreignDataContent.id,
},
],
},
});
expect(res.status()).toBe(404);
expect(await res.text()).toContain(`Data content ${foreignDataContent.id} not found`);
await expectEnvelopeUnchanged(envelope);
});
test('rejects an image index without an uploaded image', async ({ request }) => {
const { envelope, envelopeItem, presignToken } = await setupEmbeddedDocument(request);
const res = await updateEmbeddedEnvelope({
request,
presignToken,
envelope,
envelopeItem,
data: {
title: 'Changed title',
contents: [
{
id: 'PRESIGNED_content-a',
envelopeItemId: envelopeItem.id,
contentMeta: imageMeta(10),
dataContentId: null,
imageIndex: 0,
},
],
},
});
expect(res.status()).toBe(400);
expect(await res.text()).toContain('Invalid content image index');
await expectEnvelopeUnchanged(envelope);
});
test('rejects a content listed twice without changing anything', async ({ request }) => {
const { envelope, envelopeItem, presignToken } = await setupEmbeddedDocument(request);
const existingContent = await prisma.envelopeContent.create({
data: {
id: generateDatabaseId('envelope_content'),
envelopeId: envelope.id,
envelopeItemId: envelopeItem.id,
contentMeta: textMeta(),
},
});
// Both entries would update the same content.
const res = await updateEmbeddedEnvelope({
request,
presignToken,
envelope,
envelopeItem,
data: {
title: 'Changed title',
contents: [
{
id: existingContent.id,
envelopeItemId: envelopeItem.id,
contentMeta: textMeta(30),
dataContentId: null,
},
{
id: existingContent.id,
envelopeItemId: envelopeItem.id,
contentMeta: textMeta(60),
dataContentId: null,
},
],
},
});
expect(res.status()).toBe(400);
expect(await res.text()).toContain('Content IDs must be unique');
const current = await prisma.envelope.findUniqueOrThrow({
where: { id: envelope.id },
include: { contents: true },
});
expect(current.title).toBe(envelope.title);
expect(current.contents).toHaveLength(1);
expect(current.contents[0].contentMeta).toEqual(existingContent.contentMeta);
});
test('rejects a new content listed twice without changing anything', async ({ request }) => {
const { envelope, envelopeItem, presignToken } = await setupEmbeddedDocument(request);
const res = await updateEmbeddedEnvelope({
request,
presignToken,
envelope,
envelopeItem,
data: {
title: 'Changed title',
contents: [
{
id: 'PRESIGNED_content-a',
envelopeItemId: envelopeItem.id,
contentMeta: textMeta(),
dataContentId: null,
},
{
id: 'PRESIGNED_content-a',
envelopeItemId: envelopeItem.id,
contentMeta: imageMeta(40),
dataContentId: null,
},
],
},
});
expect(res.status()).toBe(400);
expect(await res.text()).toContain('Content IDs must be unique');
await expectEnvelopeUnchanged(envelope);
});
test('rejects an image which is not really a PNG, JPEG or WebP', async ({ request }) => {
const { envelope, envelopeItem, presignToken } = await setupEmbeddedDocument(request);
const image = createGifLabelledAsPng(`embed-format-${nanoid()}.png`);
const res = await updateEmbeddedEnvelope({
request,
presignToken,
envelope,
envelopeItem,
data: {
title: 'Changed title',
contents: [
{
id: 'PRESIGNED_content-a',
envelopeItemId: envelopeItem.id,
contentMeta: imageMeta(10),
dataContentId: null,
imageIndex: 0,
},
],
},
images: [image],
});
expect(res.status()).toBe(400);
expect(await res.text()).toContain('Unsupported image format: gif');
await expectEnvelopeUnchanged(envelope);
expect(await countDataContentsNamed(image.name)).toBe(0);
});
});
@@ -0,0 +1,287 @@
import fs from 'node:fs';
import path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createDataContentImage } from '@documenso/lib/server-only/data-content/create-data-content-image';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { EnvelopeContentType, ZEnvelopeContentMetaSchema } from '@documenso/lib/types/envelope-content-meta';
import { generateDatabaseId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, EnvelopeType, FieldType, RecipientRole } from '@documenso/prisma/client';
import { seedUser } from '@documenso/prisma/seed/users';
import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
import type { TDistributeEnvelopeRequest } from '@documenso/trpc/server/envelope-router/distribute-envelope.types';
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
import { type APIRequestContext, type APIResponse, expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
test.describe.configure({
mode: 'parallel',
});
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf'));
const logoPngBuffer = fs.readFileSync(path.join(__dirname, '../../../../../packages/assets/logo.png'));
/**
* Set the content allowances on the organisation that owns the seeded team.
*
* A value of `0` means unlimited.
*/
const setOrganisationContentLimits = async (
team: Team,
limits: { envelopeContentCount?: number; envelopeContentImageCount?: number },
) => {
const organisationClaim = await prisma.organisationClaim.findFirstOrThrow({
where: {
organisation: {
id: team.organisationId,
},
},
});
await prisma.organisationClaim.update({
where: {
id: organisationClaim.id,
},
data: limits,
});
};
const createEnvelope = async (request: APIRequestContext, authToken: string) => {
const payload: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: 'Envelope Content Limit Test',
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('files', new File([examplePdfBuffer], 'example.pdf', { type: 'application/pdf' }));
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${authToken}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
return (await res.json()) as TCreateEnvelopeResponse;
};
/**
* Build a distributable envelope holding `contentCount` contents, of which
* `imageCount` are image contents, then attempt to distribute it.
*
* Contents are inserted directly rather than created through the API, since
* the API refuses to create an envelope over the limits and these tests need
* one, as left behind when an organisation's plan is lowered. Image contents
* are given a real image, since sending rejects an image content without one
* and decodes it to draw it into the PDF.
*/
const buildAndDistributeEnvelopeWithContents = async ({
request,
authToken,
contentCount,
imageCount = 0,
}: {
request: APIRequestContext;
authToken: string;
contentCount: number;
imageCount?: number;
}): Promise<{ envelopeId: string; distributeRes: APIResponse }> => {
const envelope = await createEnvelope(request, authToken);
const envelopeRes = await request.get(`${baseUrl}/envelope/${envelope.id}`, {
headers: { Authorization: `Bearer ${authToken}` },
});
expect(envelopeRes.ok()).toBeTruthy();
const envelopeData = (await envelopeRes.json()) as TGetEnvelopeResponse;
const envelopeItemId = envelopeData.envelopeItems[0].id;
const recipientRes = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
data: {
envelopeId: envelope.id,
data: [
{
email: `recipient-${Date.now()}-${Math.random().toString(36).slice(2)}@test.documenso.com`,
name: 'Recipient',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
],
},
});
expect(recipientRes.ok()).toBeTruthy();
const [recipient] = (await recipientRes.json()).data;
const fieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
data: {
envelopeId: envelope.id,
data: [
{
recipientId: recipient.id,
envelopeItemId,
type: FieldType.SIGNATURE,
page: 1,
positionX: 10,
positionY: 60,
width: 30,
height: 8,
},
],
},
});
expect(fieldsRes.ok()).toBeTruthy();
const dataContentIds = await Promise.all(
Array.from({ length: imageCount }).map(async () => {
const dataContent = await createDataContentImage({
file: { name: 'logo.png', arrayBuffer: async () => new Uint8Array(logoPngBuffer).buffer as ArrayBuffer },
});
return dataContent.id;
}),
);
await prisma.envelopeContent.createMany({
data: Array.from({ length: contentCount }).map((_, index) => {
const isImage = index < imageCount;
return {
id: generateDatabaseId('envelope_content'),
envelopeId: envelope.id,
envelopeItemId,
dataContentId: isImage ? dataContentIds[index] : null,
contentMeta: ZEnvelopeContentMetaSchema.parse(
isImage
? {
type: EnvelopeContentType.IMAGE,
page: 1,
rotation: 0,
zIndex: 0,
positionX: 10,
positionY: 10,
width: 20,
height: 10,
}
: {
type: EnvelopeContentType.TEXT,
page: 1,
rotation: 0,
zIndex: 0,
positionX: 10,
positionY: 10,
width: 20,
height: 6,
text: `Content ${index}`,
},
),
};
}),
});
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
data: {
envelopeId: envelope.id,
} satisfies TDistributeEnvelopeRequest,
});
return { envelopeId: envelope.id, distributeRes };
};
const expectEnvelopeStatus = async (envelopeId: string, status: DocumentStatus) => {
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: envelopeId },
});
expect(envelope.status).toBe(status);
};
test.describe('Envelope content limits on distribute', () => {
let user: User;
let team: Team;
let token: string;
test.beforeEach(async () => {
({ user, team } = await seedUser());
({ token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test-envelope-content-limit',
expiresIn: null,
}));
});
test('allows distribution when the content count is at the limit', async ({ request }) => {
await setOrganisationContentLimits(team, { envelopeContentCount: 3 });
const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithContents({
request,
authToken: token,
contentCount: 3,
});
expect(distributeRes.status()).toBe(200);
await expectEnvelopeStatus(envelopeId, DocumentStatus.PENDING);
});
test('denies distribution when the content count is over the limit', async ({ request }) => {
await setOrganisationContentLimits(team, { envelopeContentCount: 3 });
const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithContents({
request,
authToken: token,
contentCount: 4,
});
expect(distributeRes.status()).toBe(400);
expect(await distributeRes.text()).toContain('ENVELOPE_CONTENT_LIMIT_EXCEEDED');
await expectEnvelopeStatus(envelopeId, DocumentStatus.DRAFT);
});
test('denies distribution when the image content count is over the limit', async ({ request }) => {
await setOrganisationContentLimits(team, { envelopeContentImageCount: 1 });
const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithContents({
request,
authToken: token,
contentCount: 3,
imageCount: 2,
});
expect(distributeRes.status()).toBe(400);
expect(await distributeRes.text()).toContain('ENVELOPE_CONTENT_IMAGE_LIMIT_EXCEEDED');
await expectEnvelopeStatus(envelopeId, DocumentStatus.DRAFT);
});
test('allows distribution when the limits are unlimited', async ({ request }) => {
await setOrganisationContentLimits(team, { envelopeContentCount: 0, envelopeContentImageCount: 0 });
const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithContents({
request,
authToken: token,
contentCount: 12,
imageCount: 6,
});
expect(distributeRes.status()).toBe(200);
await expectEnvelopeStatus(envelopeId, DocumentStatus.PENDING);
});
});
@@ -0,0 +1,238 @@
import fs from 'node:fs';
import path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { EnvelopeContentType, type TEnvelopeContentMetaInput } from '@documenso/lib/types/envelope-content-meta';
import { nanoid } from '@documenso/lib/universal/id';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { seedUser } from '@documenso/prisma/seed/users';
import type { TCreateEnvelopeResponse } from '@documenso/trpc/server/envelope-router/create-envelope.types';
import type { TDuplicateEnvelopeResponse } from '@documenso/trpc/server/envelope-router/duplicate-envelope.types';
import type {
TUseEnvelopePayload,
TUseEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/use-envelope.types';
import { type APIRequestContext, expect, test } from '@playwright/test';
import { EnvelopeType } from '@prisma/client';
import { createImageFile } from '../../fixtures/contents';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
test.describe.configure({
mode: 'parallel',
});
/**
* Duplicating an envelope, or creating a document from a template, copies the
* contents onto the matching items of the new envelope. Images are shared
* rather than copied, since a data content never changes.
*/
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf'));
const textMeta = (text: string): TEnvelopeContentMetaInput => ({
type: EnvelopeContentType.TEXT,
page: 1,
positionX: 10,
positionY: 10,
width: 40,
height: 5,
text,
});
const IMAGE_META: TEnvelopeContentMetaInput = {
type: EnvelopeContentType.IMAGE,
page: 1,
positionX: 10,
positionY: 40,
width: 30,
height: 10,
};
/**
* Create an envelope with two files through the public create route, with a
* text on each file and an image on the second.
*/
const createSourceEnvelope = async (request: APIRequestContext, token: string, type: EnvelopeType) => {
const image = await createImageFile(`copy-${nanoid()}.png`, 120, 60);
const formData = new FormData();
formData.append(
'payload',
JSON.stringify({
type,
title: 'Envelope With Contents',
contents: [
{ identifier: 'first.pdf', contentMeta: textMeta('On the first file') },
{ identifier: 'second.pdf', contentMeta: textMeta('On the second file') },
{ identifier: 'second.pdf', contentMeta: IMAGE_META, imageIndex: 0 },
],
}),
);
for (const pdfName of ['first.pdf', 'second.pdf']) {
formData.append('files', new File([examplePdfBuffer], pdfName, { type: 'application/pdf' }));
}
formData.append('contentImages', new File([image.buffer], image.name, { type: image.mimeType }));
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(res.ok(), await res.text()).toBeTruthy();
const { id } = (await res.json()) as TCreateEnvelopeResponse;
return id;
};
/**
* An envelope's contents, each described by the position of the item it is
* on rather than the item's ID, so a copy can be compared with its source.
*
* Titles would not do, since creating a document from a template drops the
* ".pdf" from them.
*/
const getContentSummaries = async (envelopeId: string) => {
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: envelopeId },
include: { envelopeItems: { orderBy: { order: 'asc' } }, contents: true },
});
return envelope.contents.map((content) => ({
envelopeItemIndex: envelope.envelopeItems.findIndex((item) => item.id === content.envelopeItemId),
contentMeta: content.contentMeta,
dataContentId: content.dataContentId,
}));
};
type CopyEnvelopeOptions = {
request: APIRequestContext;
token: string;
sourceId: string;
includeContents?: boolean;
};
type CopyRoute = {
name: string;
/**
* The type of envelope the route copies.
*/
sourceType: EnvelopeType;
/**
* Copy the source envelope, returning the ID of the new envelope.
*/
copy: (options: CopyEnvelopeOptions) => Promise<string>;
};
const COPY_ROUTES: CopyRoute[] = [
{
name: 'envelope/duplicate',
sourceType: EnvelopeType.DOCUMENT,
copy: async ({ request, token, sourceId, includeContents }) => {
const res = await request.post(`${baseUrl}/envelope/duplicate`, {
headers: { Authorization: `Bearer ${token}` },
data: { envelopeId: sourceId, includeContents },
});
expect(res.ok(), await res.text()).toBeTruthy();
const { id } = (await res.json()) as TDuplicateEnvelopeResponse;
return id;
},
},
{
name: 'envelope/use',
sourceType: EnvelopeType.TEMPLATE,
copy: async ({ request, token, sourceId, includeContents }) => {
const payload: TUseEnvelopePayload = { envelopeId: sourceId, includeContents };
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
const res = await request.post(`${baseUrl}/envelope/use`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(res.ok(), await res.text()).toBeTruthy();
const { id } = (await res.json()) as TUseEnvelopeResponse;
return id;
},
},
{
name: 'template/use',
sourceType: EnvelopeType.TEMPLATE,
copy: async ({ request, token, sourceId, includeContents }) => {
const template = await prisma.envelope.findUniqueOrThrow({ where: { id: sourceId } });
const res = await request.post(`${baseUrl}/template/use`, {
headers: { Authorization: `Bearer ${token}` },
data: {
templateId: mapSecondaryIdToTemplateId(template.secondaryId),
recipients: [],
includeContents,
},
});
expect(res.ok(), await res.text()).toBeTruthy();
const { envelopeId }: { envelopeId: string } = await res.json();
return envelopeId;
},
},
];
test.describe('Copy contents to new envelopes', () => {
let token: string;
test.beforeEach(async () => {
const { user, team } = await seedUser();
({ token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test-envelope-copy-contents',
expiresIn: null,
}));
});
for (const route of COPY_ROUTES) {
test(`${route.name} copies the contents onto the matching items`, async ({ request }) => {
const sourceId = await createSourceEnvelope(request, token, route.sourceType);
const copyId = await route.copy({ request, token, sourceId });
const source = await getContentSummaries(sourceId);
const copy = await getContentSummaries(copyId);
expect(source).toHaveLength(3);
// The same contents on the same files, showing the same image.
expect(copy).toHaveLength(source.length);
expect(copy).toEqual(expect.arrayContaining(source));
});
test(`${route.name} leaves the contents behind when asked to`, async ({ request }) => {
const sourceId = await createSourceEnvelope(request, token, route.sourceType);
const copyId = await route.copy({ request, token, sourceId, includeContents: false });
expect(await prisma.envelopeContent.count({ where: { envelopeId: copyId } })).toBe(0);
expect(await prisma.envelopeContent.count({ where: { envelopeId: sourceId } })).toBe(3);
});
}
});
@@ -0,0 +1,430 @@
import fs from 'node:fs';
import path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import {
EnvelopeContentShapeType,
EnvelopeContentType,
type TEnvelopeContentMetaInput,
} from '@documenso/lib/types/envelope-content-meta';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { seedUser } from '@documenso/prisma/seed/users';
import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
import { type APIRequestContext, expect, test } from '@playwright/test';
import { EnvelopeType, type Team } from '@prisma/client';
import { createGifLabelledAsPng, createImageFile, type TestImageFile } from '../../fixtures/contents';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
test.describe.configure({
mode: 'parallel',
});
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf'));
type TCreateContentInput = {
identifier?: string | number;
contentMeta: TEnvelopeContentMetaInput;
imageIndex?: number;
};
type CreateEnvelopeWithContentsOptions = {
request: APIRequestContext;
token: string;
payload?: Partial<Omit<TCreateEnvelopePayload, 'contents'>>;
contents: TCreateContentInput[];
pdfNames?: string[];
images?: TestImageFile[];
};
/**
* Create an envelope with contents through the public create route.
*/
const createEnvelopeWithContents = async ({
request,
token,
payload = {},
contents,
pdfNames = ['example.pdf'],
images = [],
}: CreateEnvelopeWithContentsOptions) => {
const formData = new FormData();
formData.append(
'payload',
JSON.stringify({
type: EnvelopeType.DOCUMENT,
title: 'Envelope With Contents',
...payload,
contents,
}),
);
for (const pdfName of pdfNames) {
formData.append('files', new File([examplePdfBuffer], pdfName, { type: 'application/pdf' }));
}
for (const image of images) {
formData.append('contentImages', new File([image.buffer], image.name, { type: image.mimeType }));
}
return await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
};
const getEnvelope = async (request: APIRequestContext, token: string, envelopeId: string) => {
const res = await request.get(`${baseUrl}/envelope/${envelopeId}`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(res.ok()).toBeTruthy();
return (await res.json()) as TGetEnvelopeResponse;
};
/**
* Set the image content allowance on the organisation that owns the team.
*/
const setOrganisationContentImageLimit = async (team: Team, envelopeContentImageCount: number) => {
const organisationClaim = await prisma.organisationClaim.findFirstOrThrow({
where: {
organisation: {
id: team.organisationId,
},
},
});
await prisma.organisationClaim.update({
where: {
id: organisationClaim.id,
},
data: {
envelopeContentImageCount,
},
});
};
const countDataContentsNamed = async (fileName: string) =>
await prisma.dataContent.count({
where: {
metadata: {
path: ['fileName'],
equals: fileName,
},
},
});
const textMeta = (text: string): TEnvelopeContentMetaInput => ({
type: EnvelopeContentType.TEXT,
page: 1,
positionX: 10,
positionY: 10,
width: 40,
height: 5,
text,
});
const RECTANGLE_META: TEnvelopeContentMetaInput = {
type: EnvelopeContentType.SHAPE,
shape: EnvelopeContentShapeType.RECTANGLE,
page: 1,
positionX: 10,
positionY: 20,
width: 20,
height: 10,
};
const imageMeta = (positionY: number): TEnvelopeContentMetaInput => ({
type: EnvelopeContentType.IMAGE,
page: 1,
positionX: 10,
positionY,
width: 30,
height: 10,
});
test.describe('Create envelope with contents', () => {
let team: Team;
let token: string;
test.beforeEach(async () => {
const seeded = await seedUser();
team = seeded.team;
({ token } = await createApiToken({
userId: seeded.user.id,
teamId: team.id,
tokenName: 'test-envelope-create-contents',
expiresIn: null,
}));
});
for (const envelopeType of [EnvelopeType.DOCUMENT, EnvelopeType.TEMPLATE]) {
test(`creates a ${envelopeType.toLowerCase()} with contents and images`, async ({ request }) => {
const logo = await createImageFile(`logo-${nanoid()}.png`, 300, 100);
const res = await createEnvelopeWithContents({
request,
token,
payload: { type: envelopeType },
contents: [
{ contentMeta: textMeta('Hello') },
{ contentMeta: RECTANGLE_META },
{ contentMeta: imageMeta(40), imageIndex: 0 },
// A second content showing the same image.
{ contentMeta: imageMeta(60), imageIndex: 0 },
// An image content which has no image yet.
{ contentMeta: imageMeta(80) },
],
images: [logo],
});
expect(res.ok(), await res.text()).toBeTruthy();
const { id } = (await res.json()) as TCreateEnvelopeResponse;
// Contents are returned by the public get route.
const envelope = await getEnvelope(request, token, id);
const [envelopeItem] = envelope.envelopeItems;
expect(envelope.contents).toHaveLength(5);
expect(envelope.contents.every((content) => content.envelopeItemId === envelopeItem.id)).toBe(true);
const text = envelope.contents.find((content) => content.contentMeta.type === EnvelopeContentType.TEXT);
const shape = envelope.contents.find((content) => content.contentMeta.type === EnvelopeContentType.SHAPE);
expect(text?.contentMeta).toMatchObject({ text: 'Hello', page: 1, positionX: 10, positionY: 10 });
expect(shape?.contentMeta).toMatchObject({ shape: EnvelopeContentShapeType.RECTANGLE });
const images = envelope.contents.filter((content) => content.contentMeta.type === EnvelopeContentType.IMAGE);
const withImage = images.filter((content) => content.dataContentId !== null);
expect(images).toHaveLength(3);
expect(withImage).toHaveLength(2);
// The image is stored once and shared by both contents showing it.
expect(withImage[0].dataContentId).toBe(withImage[1].dataContentId);
const dataContent = await prisma.dataContent.findUniqueOrThrow({
where: { id: withImage[0].dataContentId ?? '' },
});
expect(dataContent.metadata).toMatchObject({
type: 'image',
width: 300,
height: 100,
mimeType: 'image/png',
fileName: logo.name,
});
});
}
test('places each content on the file it identifies', async ({ request }) => {
const res = await createEnvelopeWithContents({
request,
token,
pdfNames: ['first.pdf', 'second.pdf'],
contents: [
{ contentMeta: textMeta('Default') },
{ identifier: 'second.pdf', contentMeta: textMeta('By name') },
{ identifier: 1, contentMeta: textMeta('By index') },
],
});
expect(res.ok(), await res.text()).toBeTruthy();
const { id } = (await res.json()) as TCreateEnvelopeResponse;
const envelope = await getEnvelope(request, token, id);
const [first, second] = [...envelope.envelopeItems].sort((a, b) => a.order - b.order);
const envelopeItemIdByText = Object.fromEntries(
envelope.contents.map((content) => [
content.contentMeta.type === EnvelopeContentType.TEXT ? content.contentMeta.text : content.id,
content.envelopeItemId,
]),
);
expect(envelopeItemIdByText).toEqual({
Default: first.id,
'By name': second.id,
'By index': second.id,
});
});
test('rejects contents over the limits without storing their images', async ({ request }) => {
await setOrganisationContentImageLimit(team, 1);
const externalId = `e2e-contents-limit-${nanoid()}`;
const images = await Promise.all([
createImageFile(`limit-a-${nanoid()}.png`, 50, 50),
createImageFile(`limit-b-${nanoid()}.png`, 50, 50),
]);
const res = await createEnvelopeWithContents({
request,
token,
payload: { externalId },
contents: [
{ contentMeta: imageMeta(40), imageIndex: 0 },
{ contentMeta: imageMeta(60), imageIndex: 1 },
],
images,
});
expect(res.status()).toBe(400);
expect(await res.text()).toContain('ENVELOPE_CONTENT_IMAGE_LIMIT_EXCEEDED');
expect(await prisma.envelope.count({ where: { externalId } })).toBe(0);
for (const image of images) {
expect(await countDataContentsNamed(image.name)).toBe(0);
}
});
test('rejects a content on a page its file does not have', async ({ request }) => {
const externalId = `e2e-contents-page-${nanoid()}`;
const image = await createImageFile(`page-${nanoid()}.png`, 50, 50);
// The example PDF has a single page.
const res = await createEnvelopeWithContents({
request,
token,
payload: { externalId },
contents: [{ contentMeta: { ...imageMeta(40), page: 2 }, imageIndex: 0 }],
images: [image],
});
expect(res.status()).toBe(400);
expect(await res.text()).toContain('only has 1 page(s)');
expect(await prisma.envelope.count({ where: { externalId } })).toBe(0);
// Rejected before the image was stored.
expect(await countDataContentsNamed(image.name)).toBe(0);
});
test('rejects an image index without an image', async ({ request }) => {
const externalId = `e2e-contents-index-${nanoid()}`;
const res = await createEnvelopeWithContents({
request,
token,
payload: { externalId },
contents: [{ contentMeta: imageMeta(40), imageIndex: 1 }],
images: [await createImageFile(`index-${nanoid()}.png`, 50, 50)],
});
expect(res.status()).toBe(400);
expect(await res.text()).toContain('Invalid content image index');
expect(await prisma.envelope.count({ where: { externalId } })).toBe(0);
});
test('rejects an image on a content which cannot hold one', async ({ request }) => {
const externalId = `e2e-contents-type-${nanoid()}`;
const image = await createImageFile(`type-${nanoid()}.png`, 50, 50);
const res = await createEnvelopeWithContents({
request,
token,
payload: { externalId },
contents: [{ contentMeta: textMeta('Not an image'), imageIndex: 0 }],
images: [image],
});
expect(res.status()).toBe(400);
expect(await res.text()).toContain('A text content cannot hold an image');
expect(await prisma.envelope.count({ where: { externalId } })).toBe(0);
expect(await countDataContentsNamed(image.name)).toBe(0);
});
for (const { identifier, description } of [
{ identifier: 'missing.pdf', description: 'a file name' },
{ identifier: 1, description: 'a file index' },
]) {
test(`rejects a content on ${description} which was not uploaded`, async ({ request }) => {
const externalId = `e2e-contents-identifier-${nanoid()}`;
const image = await createImageFile(`identifier-${nanoid()}.png`, 50, 50);
// Only "example.pdf" is uploaded, at index 0.
const res = await createEnvelopeWithContents({
request,
token,
payload: { externalId },
contents: [{ identifier, contentMeta: imageMeta(40), imageIndex: 0 }],
images: [image],
});
expect(res.status()).toBe(404);
expect(await res.text()).toContain('Document data not found');
expect(await prisma.envelope.count({ where: { externalId } })).toBe(0);
// Rejected before the image was stored.
expect(await countDataContentsNamed(image.name)).toBe(0);
});
}
test('rejects an image which is not really a PNG, JPEG or WebP', async ({ request }) => {
const externalId = `e2e-contents-format-${nanoid()}`;
const image = createGifLabelledAsPng(`format-${nanoid()}.png`);
const res = await createEnvelopeWithContents({
request,
token,
payload: { externalId },
contents: [{ contentMeta: imageMeta(40), imageIndex: 0 }],
images: [image],
});
expect(res.status()).toBe(400);
expect(await res.text()).toContain('Unsupported image format: gif');
expect(await prisma.envelope.count({ where: { externalId } })).toBe(0);
expect(await countDataContentsNamed(image.name)).toBe(0);
});
test('documents contents on the public create route', async ({ request }) => {
const res = await request.get(`${WEBAPP_BASE_URL}/api/v2/openapi.json`);
expect(res.ok()).toBeTruthy();
const openApiDocument: unknown = await res.json();
const requestSchemaPath = [
'paths',
'/envelope/create',
'post',
'requestBody',
'content',
'multipart/form-data',
'schema',
'properties',
];
expect(openApiDocument).toHaveProperty([...requestSchemaPath, 'contentImages']);
expect(openApiDocument).toHaveProperty([
...requestSchemaPath,
'payload',
'properties',
'contents',
'items',
'properties',
'imageIndex',
]);
});
});
@@ -0,0 +1,83 @@
import { nanoid } from '@documenso/lib/universal/id';
import { expect, type Page, test } from '@playwright/test';
import { getPageCanvas, placeContentOnPdf, selectContentOnCanvas, selectEditorTab } from '../fixtures/contents';
import {
clickAddMyselfButton,
clickEnvelopeEditorStep,
getEnvelopeEditorSettingsTrigger,
openDocumentEnvelopeEditor,
type TEnvelopeEditorSurface,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
import { getKonvaTransformerNodeCountForPage } from '../fixtures/konva';
/**
* Zooming destroys and rebuilds the page's Konva stage. The selection is held
* as node references, so it has to be re-resolved against the rebuilt nodes,
* otherwise the handles and the settings panel are lost mid-edit.
*/
const openContentsTab = async (surface: TEnvelopeEditorSurface) => {
const root = surface.root;
await getEnvelopeEditorSettingsTrigger(root).click();
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
await root.locator('input[name="externalId"]').fill(`e2e-zoom-selection-${nanoid()}`);
await root.getByRole('button', { name: 'Update' }).click();
await expectToastTextToBeVisible(root, 'Envelope updated');
await root.getByTestId('toast-close').click();
await clickAddMyselfButton(root);
await clickEnvelopeEditorStep(root, 'addFields');
await expect(getPageCanvas(root)).toBeVisible();
await selectEditorTab(root, 'Contents');
};
const zoomIn = async (root: Page) => await root.locator('button[title="Zoom in"]').click();
test('a selected field survives zooming', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const root = surface.root;
await clickAddMyselfButton(root);
await clickEnvelopeEditorStep(root, 'addFields');
await expect(getPageCanvas(root)).toBeVisible();
await root.getByRole('button', { name: 'Signature', exact: true }).click();
await getPageCanvas(root).click({ position: { x: 160, y: 160 } });
// A freshly placed field is selected, with its action bar showing.
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(1);
await expect(root.getByTestId('envelope-canvas-action-bar')).toBeVisible();
await zoomIn(root);
// The rebuilt stage must end up with the same field selected.
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(1);
// The action bar positions itself from the selected nodes, so it is only
// placed correctly while they are the live ones.
await expect(root.getByTestId('envelope-canvas-action-bar')).toBeVisible();
});
test('a selected content survives zooming', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const root = surface.root;
await openContentsTab(surface);
await placeContentOnPdf(root, 'Text', { x: 160, y: 160 });
await selectContentOnCanvas(root, { x: 170, y: 170 });
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(1);
await expect(root.getByTestId('envelope-canvas-action-bar')).toBeVisible();
await zoomIn(root);
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(1);
await expect(root.getByTestId('envelope-canvas-action-bar')).toBeVisible();
});
@@ -0,0 +1,268 @@
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { createCanvas } from '@napi-rs/canvas';
import { expect, type Page, test } from '@playwright/test';
import {
getContentActionButton,
getContentGroupsForPage,
getPageCanvas,
getPageSize,
interactWithCanvasPastActionBar,
placeContentOnPdf,
selectEditorTab,
uploadImage,
waitForContentsAutosave,
waitForUploadToLand,
} from '../fixtures/contents';
import {
clickAddMyselfButton,
clickEnvelopeEditorStep,
getEnvelopeEditorSettingsTrigger,
openDocumentEnvelopeEditor,
type TEnvelopeEditorSurface,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
/**
* Editing a content while its first save is still on the wire must not make
* the server delete and recreate the row, which used to happen on every save
* from then on because the client kept the id of the deleted row.
*/
const CONTENT_SET_PROCEDURE = 'envelope.content.set';
/**
* Hold the first `envelope.content.set` open so an edit can be made while it
* is in flight. Later saves are forwarded immediately.
*/
const installContentSetLag = async (page: Page, lagMs: number) => {
let markFirstInFlight: () => void = () => {};
const firstContentSetInFlight = new Promise<void>((resolve) => {
markFirstInFlight = resolve;
});
const requestBodies: string[] = [];
await page.route('**/api/trpc/**', async (route) => {
const request = route.request();
if (request.method() !== 'POST' || !request.url().includes(CONTENT_SET_PROCEDURE)) {
await route.continue();
return;
}
const callIndex = requestBodies.length + 1;
requestBodies.push(request.postData() ?? '');
if (callIndex === 1) {
markFirstInFlight();
await new Promise((resolve) => setTimeout(resolve, lagMs));
}
await route.continue();
});
return { firstContentSetInFlight, requestBodies };
};
const openContentsTab = async (surface: TEnvelopeEditorSurface) => {
const root = surface.root;
await getEnvelopeEditorSettingsTrigger(root).click();
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
await root.locator('input[name="externalId"]').fill(`e2e-content-race-${nanoid()}`);
await root.getByRole('button', { name: 'Update' }).click();
await expectToastTextToBeVisible(root, 'Envelope updated');
await root.getByTestId('toast-close').click();
await clickAddMyselfButton(root);
await clickEnvelopeEditorStep(root, 'addFields');
await expect(getPageCanvas(root)).toBeVisible();
await selectEditorTab(root, 'Contents');
};
const getContentRows = async (envelopeId: string) =>
await prisma.envelopeContent.findMany({
where: { envelopeId },
select: { id: true, contentMeta: true },
});
/**
* Drag the only content on the page by the given offset.
*/
const dragContent = async (root: Page, offsetX: number, offsetY: number) => {
const [content] = await getContentGroupsForPage(root);
const { scale } = await getPageSize(root);
const box = await getPageCanvas(root).boundingBox();
if (!box) {
throw new Error('Canvas bounding box not available');
}
const startX = box.x + (content.rect.x + content.rect.width / 2) * scale;
const startY = box.y + (content.rect.y + content.rect.height / 2) * scale;
await interactWithCanvasPastActionBar(root, async () => {
await root.mouse.move(startX, startY);
await root.mouse.down();
await root.mouse.move(startX + offsetX, startY + offsetY, { steps: 8 });
await root.mouse.up();
});
};
/**
* The same race on an image content, which used to be the worst case: the
* recreated row left the editor pointing at a data content the server had
* cleaned up, so every later save failed.
*/
test('editing a duplicated image content mid-save keeps saving', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const root = surface.root;
const envelopeId = surface.envelopeId;
if (!envelopeId) {
throw new Error('Expected the document editor surface to have an envelopeId');
}
await openContentsTab(surface);
await placeContentOnPdf(root, 'Image', { x: 150, y: 150 });
const png = await createCanvas(60, 30).encode('png');
await uploadImage(root, { name: 'image.png', mimeType: 'image/png', buffer: png });
await waitForUploadToLand(root);
await waitForContentsAutosave(surface);
await expect.poll(async () => (await getContentRows(envelopeId)).length).toBe(1);
const { firstContentSetInFlight } = await installContentSetLag(page, 4000);
// The duplicate shares the original's image rather than getting a copy.
await getContentActionButton(root, 'Duplicate').click();
await firstContentSetInFlight;
await dragContent(root, 50, 30);
await waitForContentsAutosave(surface);
// A further edit must still save cleanly.
await dragContent(root, -20, 15);
await waitForContentsAutosave(surface);
await expect(root.getByText('Save failed')).toHaveCount(0);
const rows = await prisma.envelopeContent.findMany({
where: { envelopeId },
select: { id: true, dataContentId: true },
});
expect(rows).toHaveLength(2);
// Both contents keep the image they share, and it has not been deleted.
const dataContentIds = rows.flatMap((row) => (row.dataContentId ? [row.dataContentId] : []));
expect(dataContentIds).toHaveLength(2);
expect(new Set(dataContentIds).size).toBe(1);
const dataContents = await prisma.dataContent.findMany({ where: { id: { in: dataContentIds } } });
expect(dataContents).toHaveLength(1);
});
/**
* Whatever the cause, if the editor ends up holding an ID the envelope no
* longer has, the server treats the content as new and the editor has to
* adopt the ID it gets back. Keeping the old one would make every later save
* create yet another row.
*/
test('a content whose row no longer exists recovers on the next save', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const root = surface.root;
const envelopeId = surface.envelopeId;
if (!envelopeId) {
throw new Error('Expected the document editor surface to have an envelopeId');
}
await openContentsTab(surface);
await placeContentOnPdf(root, 'Text', { x: 150, y: 150 });
await waitForContentsAutosave(surface);
await expect.poll(async () => (await getContentRows(envelopeId)).length).toBe(1);
// Drop the row behind the editor's back, so the ID it holds is stale.
await prisma.envelopeContent.deleteMany({ where: { envelopeId } });
await dragContent(root, 40, 30);
await waitForContentsAutosave(surface);
const recreated = await getContentRows(envelopeId);
expect(recreated).toHaveLength(1);
// The editor must now be pointing at the recreated row, so a further edit
// updates it instead of creating a third one.
await dragContent(root, -20, 10);
await waitForContentsAutosave(surface);
const rowsAfterEdit = await getContentRows(envelopeId);
expect(rowsAfterEdit).toHaveLength(1);
expect(rowsAfterEdit[0].id).toBe(recreated[0].id);
await expect(root.getByText('Save failed')).toHaveCount(0);
});
test('editing a content while its first save is in flight keeps the same row', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const root = surface.root;
const envelopeId = surface.envelopeId;
if (!envelopeId) {
throw new Error('Expected the document editor surface to have an envelopeId');
}
await openContentsTab(surface);
const { firstContentSetInFlight, requestBodies } = await installContentSetLag(page, 4000);
await placeContentOnPdf(root, 'Text', { x: 150, y: 150 });
// Wait until the creating save is actually on the wire, then edit.
await firstContentSetInFlight;
await dragContent(root, 60, 40);
await waitForContentsAutosave(surface);
await expect.poll(async () => (await getContentRows(envelopeId)).length).toBe(1);
const [afterRace] = await getContentRows(envelopeId);
// A further edit must update that same row rather than replacing it.
await dragContent(root, -30, 20);
await waitForContentsAutosave(surface);
const rowsAfterEdit = await getContentRows(envelopeId);
expect(rowsAfterEdit).toHaveLength(1);
expect(rowsAfterEdit[0].id).toBe(afterRace.id);
await expect(root.getByText('Save failed')).toHaveCount(0);
// The save queued during the race must reference the row the first save
// created, rather than asking for another one to be created.
expect(requestBodies).toHaveLength(3);
expect(requestBodies[1]).toContain(afterRace.id);
});
@@ -0,0 +1,214 @@
import { APP_CONTENT_IMAGE_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/envelope-content';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { prisma } from '@documenso/prisma';
import { createCanvas } from '@napi-rs/canvas';
import { expect, type Page, test } from '@playwright/test';
import {
getContentActionButton,
getContentGroupsForPage,
getPageCanvas,
placeContentOnPdf,
selectEditorTab,
waitForContentsAutosave,
} from '../fixtures/contents';
import { clickEnvelopeEditorStep, openDocumentEnvelopeEditor } from '../fixtures/envelope-editor';
test.use({ storageState: { cookies: [], origins: [] } });
/**
* Image uploads go through a dialog which blocks the editor until the upload
* settles, so there is only ever one in flight and it always lands on the
* content it was opened for.
*/
const createPng = async () => {
const canvas = createCanvas(80, 40);
const context = canvas.getContext('2d');
context.fillStyle = 'rgb(30, 120, 220)';
context.fillRect(0, 0, 80, 40);
return await canvas.encode('png');
};
const openContentsEditor = async (page: Page) => {
const surface = await openDocumentEnvelopeEditor(page);
await clickEnvelopeEditorStep(page, 'addFields');
await selectEditorTab(page, 'Contents');
return surface;
};
/**
* Click Upload / Replace on the action bar and hand the picker a file.
*/
const uploadThroughDialog = async (page: Page, buffer: Buffer) => {
const fileChooser = page.waitForEvent('filechooser');
await getContentActionButton(page, 'Upload image').or(getContentActionButton(page, 'Replace image')).click();
await (await fileChooser).setFiles({ name: 'logo.png', mimeType: 'image/png', buffer });
};
test('a dropped file which cannot be used shows why', async ({ page }) => {
await openContentsEditor(page);
await placeContentOnPdf(page, 'Image', { x: 200, y: 200 });
const dropzoneInput = page
.locator('div[role="button"]', { hasText: 'Click to upload or drag and drop' })
.locator('input[type="file"]');
// Not a supported image.
await dropzoneInput.setInputFiles({
name: 'animated.gif',
mimeType: 'image/gif',
buffer: Buffer.from('R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==', 'base64'),
});
await expect(page.getByText('This image could not be read. Use a PNG, JPEG or WebP file.')).toBeVisible();
await page.getByRole('button', { name: 'Close' }).click();
await expect(page.getByRole('dialog')).toHaveCount(0);
// Over the size limit.
await dropzoneInput.setInputFiles({
name: 'huge.png',
mimeType: 'image/png',
buffer: Buffer.alloc(megabytesToBytes(APP_CONTENT_IMAGE_UPLOAD_SIZE_LIMIT) + 1),
});
await expect(page.getByText(`This image is larger than ${APP_CONTENT_IMAGE_UPLOAD_SIZE_LIMIT} MB.`)).toBeVisible();
});
test('the dialog blocks the editor while an image uploads', async ({ page }) => {
const surface = await openContentsEditor(page);
await placeContentOnPdf(page, 'Image', { x: 200, y: 200 });
await waitForContentsAutosave(surface);
// Hold the upload open so the blocked state is observable.
let releaseUpload: () => void = () => {};
const uploadHeld = new Promise<void>((resolve) => {
releaseUpload = resolve;
});
await page.route('**/api/trpc/envelope.content.uploadImage**', async (route) => {
await uploadHeld;
await route.continue();
});
await uploadThroughDialog(page, await createPng());
await expect(page.getByTestId('content-image-uploading')).toBeVisible();
// The dialog's overlay intercepts pointer events, so a normal click on the
// canvas cannot be performed.
await expect(getPageCanvas(page).click({ position: { x: 50, y: 50 }, timeout: 1500 })).rejects.toThrow();
await expect(page.getByTestId('content-image-uploading')).toBeVisible();
// Escape does not dismiss it either.
await page.keyboard.press('Escape');
await expect(page.getByTestId('content-image-uploading')).toBeVisible();
releaseUpload();
await expect(page.getByTestId('content-image-uploading')).toHaveCount(0);
await expect(getContentActionButton(page, 'Replace image')).toBeVisible();
});
test('the upload lands on the content it was opened for', async ({ page }) => {
const surface = await openContentsEditor(page);
await placeContentOnPdf(page, 'Image', { x: 150, y: 150 });
await placeContentOnPdf(page, 'Image', { x: 350, y: 350 });
await waitForContentsAutosave(surface);
// The second placed content is the selected one.
await uploadThroughDialog(page, await createPng());
await expect(getContentActionButton(page, 'Replace image')).toBeVisible();
const groups = await getContentGroupsForPage(page);
const withImage = groups.filter((group) => group.visibleChildren.includes('content-image'));
expect(withImage).toHaveLength(1);
// And it is persisted on that content, not the other.
const contents = await prisma.envelopeContent.findMany({
where: { envelopeId: surface.envelopeId },
});
const byStackingOrder = [...contents].sort((a, b) => a.contentMeta.zIndex - b.contentMeta.zIndex);
expect(byStackingOrder.map((content) => content.dataContentId !== null)).toEqual([false, true]);
});
/**
* Dismissing the native picker leaves the dialog open on its "Choose image"
* button. Pressing that must reopen the picker even though the dialog's
* state has not changed.
*/
test('the picker can be reopened from the dialog after being dismissed', async ({ page }) => {
const surface = await openContentsEditor(page);
await placeContentOnPdf(page, 'Image', { x: 200, y: 200 });
await waitForContentsAutosave(surface);
// Open the picker and leave it unanswered: the dialog stays on "picking".
const firstChooser = page.waitForEvent('filechooser');
await getContentActionButton(page, 'Upload image').click();
await firstChooser;
await expect(page.getByRole('button', { name: 'Choose image' })).toBeVisible();
// Pressing the dialog's button opens a fresh picker.
const secondChooser = page.waitForEvent('filechooser');
await page.getByRole('button', { name: 'Choose image' }).click();
await (await secondChooser).setFiles({ name: 'logo.png', mimeType: 'image/png', buffer: await createPng() });
await expect(getContentActionButton(page, 'Replace image')).toBeVisible();
});
test('cancelling the picker closes the dialog', async ({ page }) => {
const surface = await openContentsEditor(page);
await placeContentOnPdf(page, 'Image', { x: 200, y: 200 });
await waitForContentsAutosave(surface);
const chooser = page.waitForEvent('filechooser');
await getContentActionButton(page, 'Upload image').click();
await chooser;
await expect(page.getByRole('dialog')).toBeVisible();
// Playwright cannot dismiss a native picker, but the browser reports a
// dismissal as a `cancel` event on the input, which is what the dialog
// listens for.
await page.locator('input[id^="content-image-input-"]').dispatchEvent('cancel');
await expect(page.getByRole('dialog')).toHaveCount(0);
await expect(getContentActionButton(page, 'Upload image')).toBeVisible();
});
test('a content placed and uploaded before autosave still receives its image', async ({ page }) => {
const surface = await openContentsEditor(page);
// No waitForContentsAutosave: the content has no server id yet when the
// upload is requested. The dialog flushes the save before uploading.
await placeContentOnPdf(page, 'Image', { x: 200, y: 200 });
await uploadThroughDialog(page, await createPng());
await expect(getContentActionButton(page, 'Replace image')).toBeVisible();
const content = await prisma.envelopeContent.findFirstOrThrow({ where: { envelopeId: surface.envelopeId } });
expect(content.dataContentId).not.toBeNull();
});
@@ -0,0 +1,396 @@
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { expect, type Page, test } from '@playwright/test';
import {
createImageFile,
getContentActionButton,
getContentGroupsForPage,
getPageCanvas,
getPageSize,
placeContentOnPdf,
selectEditorTab,
type TestImageFile,
uploadImage,
waitForContentsAutosave,
waitForUploadToLand,
} from '../fixtures/contents';
import {
clickAddMyselfButton,
clickEnvelopeEditorStep,
getEnvelopeEditorSettingsTrigger,
openDocumentEnvelopeEditor,
openTemplateEnvelopeEditor,
type TEnvelopeEditorSurface,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
/**
* The default box an image content is dropped at, as a percentage of the page.
* Mirrors `CONTENT_IMAGE_DEFAULT_SIZE`.
*/
const IMAGE_DEFAULT_SIZE = { width: 15, height: 10 };
/**
* A minimal GIF, which is a real image in a format the editor does not accept.
*/
const createGifFile = (name: string) => ({
name,
mimeType: 'image/gif',
buffer: Buffer.from('R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==', 'base64'),
});
const openSettingsDialog = async (root: Page) => {
await getEnvelopeEditorSettingsTrigger(root).click();
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
};
const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: string) => {
await openSettingsDialog(surface.root);
await surface.root.locator('input[name="externalId"]').fill(externalId);
await surface.root.getByRole('button', { name: 'Update' }).click();
await expectToastTextToBeVisible(surface.root, 'Envelope updated');
await surface.root.getByTestId('toast-close').click();
};
const openContentsTab = async (surface: TEnvelopeEditorSurface, externalId: string) => {
await updateExternalId(surface, externalId);
await clickAddMyselfButton(surface.root);
await clickEnvelopeEditorStep(surface.root, 'addFields');
await expect(getPageCanvas(surface.root)).toBeVisible();
await selectEditorTab(surface.root, 'Contents');
await expect(surface.root.getByRole('heading', { name: 'Add Content' })).toBeVisible();
};
/**
* Retry from the dialog's error state, which reopens the picker.
*/
const retryUpload = async (root: Page, file: TestImageFile) => {
const fileChooser = root.waitForEvent('filechooser');
await root.getByRole('button', { name: 'Try again' }).click();
await (await fileChooser).setFiles({ name: file.name, mimeType: file.mimeType, buffer: file.buffer });
};
const findEnvelopeWithContents = async (surface: TEnvelopeEditorSurface, externalId: string) =>
await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
type: surface.envelopeType,
},
orderBy: { createdAt: 'desc' },
include: {
contents: {
include: {
dataContent: true,
},
},
},
});
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null;
const getMeta = (metadata: unknown): Record<string, unknown> => {
if (!isRecord(metadata)) {
throw new Error('Metadata is not an object');
}
return metadata;
};
// --- Upload, replace and remove ---
const runUploadReplaceRemoveFlow = async (surface: TEnvelopeEditorSurface) => {
const externalId = `e2e-content-images-${nanoid()}`;
const root = surface.root;
await openContentsTab(surface, externalId);
await placeContentOnPdf(root, 'Image', { x: 200, y: 200 });
await expect(root.getByRole('heading', { name: 'Image Settings' })).toBeVisible();
await expect(root.getByText('Click to upload or drag and drop')).toBeVisible();
// The image content is a placeholder before upload.
const [placeholder] = await getContentGroupsForPage(root);
expect(placeholder.visibleChildren).toContain('content-placeholder-rect');
// Upload a wide 2:1 image. The box is still the default size, so it is
// sized to the image at one point per pixel.
const wide = await createImageFile('wide-logo.png', 400, 200);
await uploadImage(root, wide);
await waitForUploadToLand(root);
await expect(root.getByText('wide-logo.png')).toBeVisible();
await expect(root.getByText('400 * 200')).toBeVisible();
const [withWide] = await getContentGroupsForPage(root);
expect(withWide.visibleChildren).toContain('content-image');
expect(withWide.visibleChildren).not.toContain('content-placeholder-rect');
expect(withWide.rect.width / withWide.rect.height).toBeCloseTo(2, 1);
await waitForContentsAutosave(surface);
const afterUpload = await findEnvelopeWithContents(surface, externalId);
const firstDataContentId = afterUpload.contents[0].dataContentId;
expect(firstDataContentId).not.toBeNull();
expect(getMeta(afterUpload.contents[0].dataContent?.metadata)).toMatchObject({
type: 'image',
width: 400,
height: 200,
mimeType: 'image/png',
fileName: 'wide-logo.png',
});
// The box is no longer the default size, so replacing with a tall image
// tightens the box around the fitted image rather than resizing to it.
const tall = await createImageFile('tall-logo.jpg', 100, 300, 'jpeg');
await uploadImage(root, tall);
await waitForUploadToLand(root);
await expect(root.getByText('tall-logo.jpg')).toBeVisible();
const [withTall] = await getContentGroupsForPage(root);
expect(withTall.rect.width / withTall.rect.height).toBeCloseTo(1 / 3, 1);
expect(withTall.rect.width).toBeLessThanOrEqual(withWide.rect.width + 0.5);
expect(withTall.rect.height).toBeLessThanOrEqual(withWide.rect.height + 0.5);
await waitForContentsAutosave(surface);
const afterReplace = await findEnvelopeWithContents(surface, externalId);
const secondDataContentId = afterReplace.contents[0].dataContentId;
expect(secondDataContentId).not.toBeNull();
expect(secondDataContentId).not.toBe(firstDataContentId);
expect(getMeta(afterReplace.contents[0].dataContent?.metadata)).toMatchObject({
mimeType: 'image/jpeg',
fileName: 'tall-logo.jpg',
});
// Data contents are never deleted: the replaced image's row remains, since
// another content (e.g. on a document created from this one) may still
// reference it.
expect(await prisma.dataContent.findUnique({ where: { id: firstDataContentId ?? '' } })).not.toBeNull();
// Remove returns to the drop zone and detaches the image, leaving the box.
await root.getByRole('button', { name: 'Remove image' }).click();
await expect(root.getByText('Click to upload or drag and drop')).toBeVisible();
const [afterRemoveGroup] = await getContentGroupsForPage(root);
expect(afterRemoveGroup.visibleChildren).toContain('content-placeholder-rect');
expect(afterRemoveGroup.rect.width).toBeCloseTo(withTall.rect.width, 0);
await waitForContentsAutosave(surface);
const afterRemove = await findEnvelopeWithContents(surface, externalId);
expect(afterRemove.contents[0].dataContentId).toBeNull();
// Removing detaches the content from its image; the data content itself is
// never deleted.
expect(await prisma.dataContent.findUnique({ where: { id: secondDataContentId ?? '' } })).not.toBeNull();
return { externalId };
};
// --- Upload via the action bar, auto size caps ---
const runActionBarUploadFlow = async (surface: TEnvelopeEditorSurface) => {
const externalId = `e2e-content-images-bar-${nanoid()}`;
const root = surface.root;
await openContentsTab(surface, externalId);
await placeContentOnPdf(root, 'Image', { x: 200, y: 200 });
await expect(getContentActionButton(root, 'Upload image')).toBeVisible();
// The bar's button opens the upload dialog, which opens the picker at once.
const fileChooser = root.waitForEvent('filechooser');
await getContentActionButton(root, 'Upload image').click();
const huge = await createImageFile('huge-logo.png', 2000, 2000);
await (await fileChooser).setFiles({ name: huge.name, mimeType: huge.mimeType, buffer: huge.buffer });
await expect(root.getByText('huge-logo.png')).toBeVisible();
await expect(getContentActionButton(root, 'Replace image')).toBeVisible();
const pageSize = await getPageSize(root);
await waitForContentsAutosave(surface);
return { externalId, pageSize };
};
const assertActionBarUploadPersisted = async (
surface: TEnvelopeEditorSurface,
externalId: string,
pageSize: { width: number; height: number },
) => {
const envelope = await findEnvelopeWithContents(surface, externalId);
const [imageContent] = envelope.contents;
const meta = getMeta(imageContent.contentMeta);
expect(imageContent.dataContent).not.toBeNull();
// A 2000px square exceeds the page, so its width is capped at 80% of the
// page width and the height follows the square ratio in page points, i.e.
// 80% of the page width expressed as a percentage of the page height.
expect(Number(meta.width)).toBeCloseTo(80, 0);
expect(Number(meta.height)).toBeCloseTo((80 * pageSize.width) / pageSize.height, 0);
expect(Number(meta.positionX) + Number(meta.width)).toBeLessThanOrEqual(100);
expect(Number(meta.positionY) + Number(meta.height)).toBeLessThanOrEqual(100);
};
// --- Rejected uploads ---
const runRejectedUploadsFlow = async (surface: TEnvelopeEditorSurface) => {
const externalId = `e2e-content-images-reject-${nanoid()}`;
const root = surface.root;
await openContentsTab(surface, externalId);
await placeContentOnPdf(root, 'Image', { x: 200, y: 200 });
// An unsupported format is rejected by the server's MIME allowlist. The
// dialog stays open showing why.
await uploadImage(root, createGifFile('animated.gif'));
await expect(root.getByText('This image could not be read. Use a PNG, JPEG or WebP file.')).toBeVisible();
// Bytes which are not an image at all pass the type check and are rejected
// while the server decodes them.
await retryUpload(root, {
name: 'not-an-image.png',
mimeType: 'image/png',
buffer: Buffer.from('definitely not a png'),
});
await expect(root.getByText('This image could not be read. Use a PNG, JPEG or WebP file.')).toBeVisible();
// Give up, releasing the editor.
await root.getByRole('button', { name: 'Close' }).click();
await expect(root.getByRole('button', { name: 'Try again' })).toHaveCount(0);
// The placeholder is unaffected.
const [group] = await getContentGroupsForPage(root);
expect(group.visibleChildren).toContain('content-placeholder-rect');
expect(IMAGE_DEFAULT_SIZE.width).toBeGreaterThan(0);
await waitForContentsAutosave(surface);
return { externalId };
};
const assertNoImageAttached = async (surface: TEnvelopeEditorSurface, externalId: string) => {
const envelope = await findEnvelopeWithContents(surface, externalId);
expect(envelope.contents).toHaveLength(1);
expect(envelope.contents[0].dataContentId).toBeNull();
};
// --- Send guard ---
const runSendGuardFlow = async (surface: TEnvelopeEditorSurface) => {
const externalId = `e2e-content-images-guard-${nanoid()}`;
const root = surface.root;
await openContentsTab(surface, externalId);
await placeContentOnPdf(root, 'Image', { x: 200, y: 200 });
// A signature field so the only thing blocking sending is the empty image.
await selectEditorTab(root, 'Fields');
await root.getByRole('button', { name: 'Signature', exact: true }).click();
await getPageCanvas(root).click({ position: { x: 200, y: 500 } });
await waitForContentsAutosave(surface);
await root.getByRole('navigation').getByRole('button', { name: 'Send Document' }).click();
await expect(root.getByRole('heading', { name: 'Send Document' })).toBeVisible();
await expect(root.getByText(/image content(s)? (has|have) no image/)).toBeVisible();
await expect(root.getByRole('button', { name: 'Send', exact: true })).toHaveCount(0);
await root.keyboard.press('Escape');
// Attach an image and the guard lifts.
await selectEditorTab(root, 'Contents');
await getPageCanvas(root).click({ position: { x: 200, y: 200 }, force: true });
await expect(root.getByRole('heading', { name: 'Image Settings' })).toBeVisible();
const logo = await createImageFile('logo.png', 120, 60);
await uploadImage(root, logo);
await waitForUploadToLand(root);
await expect(root.getByText('logo.png')).toBeVisible();
await waitForContentsAutosave(surface);
await root.getByRole('navigation').getByRole('button', { name: 'Send Document' }).click();
await expect(root.getByRole('heading', { name: 'Send Document' })).toBeVisible();
await expect(root.getByText(/image content(s)? (has|have) no image/)).toHaveCount(0);
await expect(root.getByRole('button', { name: 'Send', exact: true })).toBeVisible();
};
// --- Tests ---
test.describe('document editor', () => {
test('upload, replace and remove a content image', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
await runUploadReplaceRemoveFlow(surface);
});
test('upload a content image from the canvas action bar', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const { externalId, pageSize } = await runActionBarUploadFlow(surface);
await assertActionBarUploadPersisted(surface, externalId, pageSize);
});
test('rejected uploads leave the placeholder untouched', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const { externalId } = await runRejectedUploadsFlow(surface);
await assertNoImageAttached(surface, externalId);
});
test('a document cannot be sent with an image content which has no image', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
await runSendGuardFlow(surface);
});
});
test.describe('template editor', () => {
test('upload, replace and remove a content image', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
await runUploadReplaceRemoveFlow(surface);
});
test('upload a content image from the canvas action bar', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const { externalId, pageSize } = await runActionBarUploadFlow(surface);
await assertActionBarUploadPersisted(surface, externalId, pageSize);
});
test('rejected uploads leave the placeholder untouched', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const { externalId } = await runRejectedUploadsFlow(surface);
await assertNoImageAttached(surface, externalId);
});
});
@@ -0,0 +1,157 @@
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { expect, type Page, test } from '@playwright/test';
import {
getContentActionButton,
getContentCountForPage,
getContentGroupsForPage,
getPageCanvas,
getPageSize,
placeContentOnPdf,
selectContentOnCanvas,
selectEditorTab,
} from '../fixtures/contents';
import {
clickAddMyselfButton,
clickEnvelopeEditorStep,
getEnvelopeEditorSettingsTrigger,
openDocumentEnvelopeEditor,
type TEnvelopeEditorSurface,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
/**
* The organisation's plan caps how many contents (and how many image
* contents) an envelope may hold. Reaching a limit hides the palette and the
* duplicate action, while existing contents stay editable and removable.
*/
const setOrganisationContentLimits = async (
teamId: number,
limits: { envelopeContentCount?: number; envelopeContentImageCount?: number },
) => {
const team = await prisma.team.findFirstOrThrow({ where: { id: teamId } });
const organisationClaim = await prisma.organisationClaim.findFirstOrThrow({
where: { organisation: { id: team.organisationId } },
});
await prisma.organisationClaim.update({
where: { id: organisationClaim.id },
data: limits,
});
};
/**
* Open the editor for an organisation with the given content allowances.
*
* The claim is read from the organisation session, which is loaded when the
* app boots, so the limits are applied before the editor is reloaded.
*/
const openEditorWithContentLimits = async (
page: Page,
limits: { envelopeContentCount?: number; envelopeContentImageCount?: number },
) => {
const surface = await openDocumentEnvelopeEditor(page);
await setOrganisationContentLimits(surface.teamId, limits);
await page.reload();
return surface;
};
const openContentsTab = async (surface: TEnvelopeEditorSurface) => {
const root = surface.root;
await getEnvelopeEditorSettingsTrigger(root).click();
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
await root.locator('input[name="externalId"]').fill(`e2e-content-limit-${nanoid()}`);
await root.getByRole('button', { name: 'Update' }).click();
await expectToastTextToBeVisible(root, 'Envelope updated');
await root.getByTestId('toast-close').click();
await clickAddMyselfButton(root);
await clickEnvelopeEditorStep(root, 'addFields');
await expect(getPageCanvas(root)).toBeVisible();
await selectEditorTab(root, 'Contents');
};
const getLimitAlert = (root: Page) => root.getByTestId('content-limit-reached-alert');
const getPaletteButton = (root: Page, name: string) => root.getByRole('button', { name, exact: true });
test('content palette is hidden once the content limit is reached', async ({ page }) => {
const surface = await openEditorWithContentLimits(page, { envelopeContentCount: 2 });
const root = surface.root;
await openContentsTab(surface);
// Under the limit the palette is available.
await expect(getPaletteButton(root, 'Text')).toBeVisible();
await expect(getLimitAlert(root)).toHaveCount(0);
await placeContentOnPdf(root, 'Text', { x: 120, y: 120 });
await expect.poll(() => getContentCountForPage(root)).toBe(1);
await expect(getPaletteButton(root, 'Text')).toBeVisible();
// Reaching the limit replaces the whole palette with the message.
await placeContentOnPdf(root, 'Text', { x: 120, y: 220 });
await expect.poll(() => getContentCountForPage(root)).toBe(2);
await expect(getLimitAlert(root)).toBeVisible();
await expect(getLimitAlert(root)).toContainText('cannot have more than 2 contents');
await expect(getPaletteButton(root, 'Text')).toHaveCount(0);
await expect(getPaletteButton(root, 'Rectangle')).toHaveCount(0);
});
test('duplicate is hidden at the limit and existing contents stay removable', async ({ page }) => {
const surface = await openEditorWithContentLimits(page, { envelopeContentCount: 1 });
const root = surface.root;
await openContentsTab(surface);
await placeContentOnPdf(root, 'Text', { x: 120, y: 120 });
await expect.poll(() => getContentCountForPage(root)).toBe(1);
await expect(getLimitAlert(root)).toBeVisible();
const [content] = await getContentGroupsForPage(root);
const { scale } = await getPageSize(root);
await selectContentOnCanvas(root, { x: (content.rect.x + 4) * scale, y: (content.rect.y + 4) * scale });
// Duplicating would add a content, so it is gone; removing is still offered.
await expect(getContentActionButton(root, 'Duplicate')).toHaveCount(0);
await expect(getContentActionButton(root, 'Remove')).toBeVisible();
await getContentActionButton(root, 'Remove').click();
await expect.poll(() => getContentCountForPage(root)).toBe(0);
// Back under the limit, the palette returns.
await expect(getLimitAlert(root)).toHaveCount(0);
await expect(getPaletteButton(root, 'Text')).toBeVisible();
});
test('image contents are limited separately from other contents', async ({ page }) => {
const surface = await openEditorWithContentLimits(page, {
envelopeContentCount: 0,
envelopeContentImageCount: 1,
});
const root = surface.root;
await openContentsTab(surface);
await placeContentOnPdf(root, 'Text', { x: 120, y: 120 });
await expect(getLimitAlert(root)).toHaveCount(0);
await placeContentOnPdf(root, 'Image', { x: 120, y: 260 });
await expect.poll(() => getContentCountForPage(root)).toBe(2);
// The image allowance is used up even though contents are unlimited.
await expect(getLimitAlert(root)).toBeVisible();
await expect(getLimitAlert(root)).toContainText('cannot have more than 1 image content');
});
@@ -0,0 +1,110 @@
import { EnvelopeContentType, ZEnvelopeContentMetaSchema } from '@documenso/lib/types/envelope-content-meta';
import { generateDatabaseId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { expect, type Page, test } from '@playwright/test';
import { getContentGroupsForPage, getPageCanvas, selectEditorTab } from '../fixtures/contents';
import { clickEnvelopeEditorStep, openDocumentEnvelopeEditor } from '../fixtures/envelope-editor';
/**
* Contents sharing a `zIndex` are stacked by their database ID everywhere the
* document is rendered. The editor has to agree, otherwise what is authored
* is not what is sealed.
*
* Ties cannot be produced through the editor, which always places a content
* above the rest of its page, so they are seeded directly.
*/
const POSITIONS = [10, 25, 40, 55];
const seedTiedContents = async (envelopeId: string) => {
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: envelopeId },
include: { envelopeItems: true },
});
await prisma.envelopeContent.createMany({
data: POSITIONS.map((positionX) => ({
id: generateDatabaseId('envelope_content'),
envelopeId,
envelopeItemId: envelope.envelopeItems[0].id,
contentMeta: ZEnvelopeContentMetaSchema.parse({
type: EnvelopeContentType.TEXT,
page: 1,
rotation: 0,
positionX,
positionY: 20,
width: 10,
height: 5,
text: `x${positionX}`,
}),
})),
});
const contents = await prisma.envelopeContent.findMany({
where: { envelopeId },
select: { id: true, contentMeta: true },
});
// The order every other renderer uses: zIndex, then database ID.
return [...contents]
.sort((a, b) => (a.id < b.id ? -1 : 1))
.map((content) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const contentMeta = content.contentMeta as { positionX?: number };
return contentMeta.positionX;
});
};
/**
* The horizontal positions of the page's contents, in the order they are
* stacked on the canvas.
*/
const getCanvasStackingOrder = async (root: Page) => {
const groups = await getContentGroupsForPage(root);
const { width } = await root.evaluate(() => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const konva = (window as unknown as { Konva: typeof import('konva').default }).Konva;
const stage = konva.stages.find((currentStage) => currentStage.attrs.id === 'page-1');
return { width: (stage?.width() ?? 1) / (stage?.scaleX() ?? 1) };
});
return groups.map((group) =>
POSITIONS.reduce((closest, position) =>
Math.abs((position / 100) * width - group.rect.x) < Math.abs((closest / 100) * width - group.rect.x)
? position
: closest,
),
);
};
const openContentsTab = async (root: Page) => {
await clickEnvelopeEditorStep(root, 'addFields');
await expect(getPageCanvas(root)).toBeVisible();
await selectEditorTab(root, 'Contents');
};
test('contents sharing a zIndex stack by database ID in the editor', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const envelopeId = surface.envelopeId;
if (!envelopeId) {
throw new Error('Expected the document editor surface to have an envelopeId');
}
const expectedOrder = await seedTiedContents(envelopeId);
// Reloaded repeatedly since the editor's own IDs are regenerated on each
// load, so a stacking order derived from them would vary between loads.
for (let attempt = 0; attempt < 3; attempt += 1) {
await page.reload();
await openContentsTab(page);
await expect.poll(async () => (await getContentGroupsForPage(page)).length).toBe(POSITIONS.length);
expect(await getCanvasStackingOrder(page)).toEqual(expectedOrder);
}
});
File diff suppressed because it is too large Load Diff
@@ -310,6 +310,32 @@ test.describe('embedded edit', () => {
expect(items[0].title).toBe('Envelope Item A');
expect(items[0].order).toBe(2); // Expect order 2 because deleting items does not drop the order of sequential items.
});
test('items added together keep the order they were added in', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
mode: 'edit',
tokenNamePrefix: 'e2e-embed-items-order',
});
const externalId = `e2e-items-order-${nanoid()}`;
await updateExternalId(surface, externalId);
await expect(getEnvelopeItemTitleInputs(surface.root)).toHaveCount(1);
// Both files are added in one go and not reordered afterwards, which
// would renumber every item.
await uploadFiles(surface.root, [createPdfPayload('first-added.pdf'), createPdfPayload('second-added.pdf')]);
await expect(getEnvelopeItemTitleInputs(surface.root)).toHaveCount(3);
await persistEmbeddedEnvelope(surface);
const items = await getEnvelopeItemsFromDatabase(surface, externalId);
expect(items.map((item) => item.order)).toEqual([1, 2, 3]);
expect(items[1].title).toContain('first-added');
expect(items[2].title).toContain('second-added');
});
});
test.describe('pending envelope title editing', () => {
@@ -0,0 +1,119 @@
import fs from 'node:fs';
import path from 'node:path';
import { EnvelopeContentType, ZEnvelopeContentMetaSchema } from '@documenso/lib/types/envelope-content-meta';
import { generateDatabaseId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { expect, type Page, test } from '@playwright/test';
import {
getContentCountForPage,
placeContentOnPdf,
selectEditorTab,
waitForContentsAutosave,
} from '../fixtures/contents';
import {
clickEnvelopeEditorStep,
getEnvelopeItemReplaceButtons,
openDocumentEnvelopeEditor,
} from '../fixtures/envelope-editor';
test.use({
storageState: {
cookies: [],
origins: [],
},
});
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
const multiPagePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/field-font-alignment.pdf'));
const replacePdf = async (root: Page, buffer: Buffer, name: string) => {
const replaceButton = getEnvelopeItemReplaceButtons(root).nth(0);
await expect(replaceButton).toBeVisible();
const [fileChooser] = await Promise.all([root.waitForEvent('filechooser'), replaceButton.click()]);
await fileChooser.setFiles({ name, mimeType: 'application/pdf', buffer });
await expect(replaceButton).toBeDisabled({ timeout: 15000 });
await expect(replaceButton).toBeEnabled({ timeout: 15000 });
};
const seedContent = async (envelopeId: string, envelopeItemId: string, page: number) =>
await prisma.envelopeContent.create({
data: {
id: generateDatabaseId('envelope_content'),
envelopeId,
envelopeItemId,
contentMeta: ZEnvelopeContentMetaSchema.parse({
type: EnvelopeContentType.TEXT,
page,
rotation: 0,
positionX: 10,
positionY: 10,
width: 20,
height: 6,
text: `Page ${page}`,
}),
},
});
const countContents = async (envelopeId: string) => await prisma.envelopeContent.count({ where: { envelopeId } });
/**
* Replacing a PDF deletes the contents which sit on pages the new file does
* not have. The editor keeps its own copy of the contents, so unless that copy
* is resynced the next save posts the deleted content back and the server,
* finding no content with that id, creates it again on a page which no longer
* exists.
*/
test('does not recreate contents removed by a pdf replacement', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const { root, envelopeId } = surface;
// A three page PDF so a content can live on a page the replacement drops.
await replacePdf(root, multiPagePdfBuffer, 'multi-page.pdf');
const envelope = await prisma.envelope.findFirstOrThrow({
where: { id: envelopeId },
include: { envelopeItems: true },
});
const envelopeItemId = envelope.envelopeItems[0].id;
await seedContent(envelopeId, envelopeItemId, 1);
await seedContent(envelopeId, envelopeItemId, 3);
expect(await countContents(envelopeId)).toBe(2);
// Reload so the editor loads both contents into its local state.
await root.reload();
await clickEnvelopeEditorStep(root, 'addFields');
await selectEditorTab(root, 'Contents');
await expect.poll(async () => await getContentCountForPage(root, 1)).toBe(1);
// Replace with a single page PDF, dropping the content on page 3.
await clickEnvelopeEditorStep(root, 'upload');
await expect(root.getByRole('heading', { name: 'Documents' })).toBeVisible();
await replacePdf(root, examplePdfBuffer, 'single-page.pdf');
await expect.poll(async () => await countContents(envelopeId)).toBe(1);
// Any later save posts every content the editor is holding, so placing one
// is enough to resurrect a stale content.
await clickEnvelopeEditorStep(root, 'addFields');
await selectEditorTab(root, 'Contents');
await placeContentOnPdf(root, 'Text', { x: 200, y: 260 });
await waitForContentsAutosave(surface);
// The placed content, and nothing else.
expect(await countContents(envelopeId)).toBe(2);
const pages = await prisma.envelopeContent.findMany({ where: { envelopeId } });
expect(pages.every((content) => (content.contentMeta.page ?? 1) === 1)).toBe(true);
});
@@ -0,0 +1,236 @@
import fs from 'node:fs';
import path from 'node:path';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download';
import { prisma } from '@documenso/prisma';
import { seedContentAlignmentTestDocument } from '@documenso/prisma/seed/initial-seed';
import { seedUser } from '@documenso/prisma/seed/users';
import { createCanvas } from '@napi-rs/canvas';
import type { APIRequestContext, Page, TestInfo } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { DocumentStatus } from '@prisma/client';
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
import pixelMatch from 'pixelmatch';
import { PNG } from 'pngjs';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../lib/constants/app';
import type { TDistributeEnvelopeRequest } from '../../../trpc/server/envelope-router/distribute-envelope.types';
import { apiSignin } from '../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2`;
const VISUAL_REGRESSION_DIR = path.join(__dirname, '../../visual-regression');
const ITEM_TITLE = 'content-alignment-pdf';
test.describe.configure({ mode: 'parallel', timeout: 90000 });
/**
* Seed the content alignment document, then run it through distribution and
* signing so the contents are imprinted by the real seal job.
*
* Returns the sealed PDF bytes.
*/
const sealContentAlignmentDocument = async ({ page, request }: { page: Page; request: APIRequestContext }) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const envelope = await seedContentAlignmentTestDocument({
userId: user.id,
teamId: team.id,
recipientName: user.name || '',
recipientEmail: user.email,
status: DocumentStatus.DRAFT,
});
const distributeEnvelopeRequest = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${token}` },
data: { envelopeId: envelope.id } satisfies TDistributeEnvelopeRequest,
});
expect(distributeEnvelopeRequest.ok()).toBeTruthy();
// Pre-insert the signature field so completing does not depend on the
// signing UI. This test is about the contents, not the fields.
const signatureField = await prisma.field.findFirstOrThrow({ where: { envelopeId: envelope.id } });
await prisma.field.update({
where: { id: signatureField.id },
data: {
inserted: true,
customText: '',
signature: {
create: {
recipientId: envelope.recipients[0].id,
typedSignature: 'Content Alignment',
},
},
},
});
// Sign as the recipient so the seal job runs.
const recipientToken = envelope.recipients[0].token;
const signUrl = `/sign/${recipientToken}`;
await apiSignin({ page, email: user.email, redirectPath: signUrl });
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`${signUrl}/complete`);
await expect(async () => {
const { status } = await prisma.envelope.findFirstOrThrow({ where: { id: envelope.id } });
expect(status).toBe(DocumentStatus.COMPLETED);
}).toPass({ timeout: 20000 });
const completed = await prisma.envelope.findFirstOrThrow({
where: { id: envelope.id },
include: { envelopeItems: { include: { documentData: true } } },
});
const documentUrl = getEnvelopeItemPdfUrl({
type: 'download',
envelopeItem: completed.envelopeItems[0],
token: recipientToken,
version: 'signed',
});
const pdfData = await fetch(documentUrl).then(async (res) => await res.arrayBuffer());
return new Uint8Array(pdfData);
};
test('content placement visual regression', async ({ page, request }, testInfo) => {
const pdfData = await sealContentAlignmentDocument({ page, request });
const storedImages = fs
.readdirSync(VISUAL_REGRESSION_DIR)
.filter((image) => image.startsWith(`${ITEM_TITLE}-`))
.sort()
.map((image) => fs.readFileSync(path.join(VISUAL_REGRESSION_DIR, image)));
expect(storedImages.length, 'Reference images missing, run the "download" test to generate them').toBeGreaterThan(0);
await compareSealedPdfWithImages({ id: ITEM_TITLE, pdfData, images: storedImages, testInfo });
});
/**
* Used to regenerate the reference images when the rendering legitimately
* changes. Inspect the output carefully before committing.
*
* DON'T COMMIT THIS WITHOUT THE "SKIP" COMMAND.
*/
test.skip('download content alignment images', async ({ page, request }) => {
const pdfData = await sealContentAlignmentDocument({ page, request });
const pdfImages = await renderPdfToImage(pdfData);
// The last page is the dynamic certificate, which is not compared.
for (const [index, { image }] of pdfImages.slice(0, -1).entries()) {
fs.writeFileSync(path.join(VISUAL_REGRESSION_DIR, `${ITEM_TITLE}-${index}.png`), new Uint8Array(image));
}
});
async function renderPdfToImage(pdfBytes: Uint8Array) {
const loadingTask = pdfjsLib.getDocument({ data: pdfBytes });
const pdf = await loadingTask.promise;
// Increase for higher resolution
const scale = 4;
return await Promise.all(
Array.from({ length: pdf.numPages }, async (_, index) => {
const page = await pdf.getPage(index + 1);
const viewport = page.getViewport({ scale });
const canvas = createCanvas(viewport.width, viewport.height);
const canvasContext = canvas.getContext('2d');
canvasContext.imageSmoothingEnabled = false;
await page.render({
// @ts-expect-error @napi-rs/canvas satisfies runtime requirements for pdfjs
canvas,
// @ts-expect-error @napi-rs/canvas satisfies runtime requirements for pdfjs
canvasContext,
viewport,
}).promise;
return {
image: await canvas.encode('png'),
width: Math.floor(viewport.width),
height: Math.floor(viewport.height),
};
}),
);
}
type CompareSealedPdfWithImagesOptions = {
id: string;
pdfData: Uint8Array;
images: Buffer[];
testInfo: TestInfo;
};
/**
* Whether a rendered page has any non white pixels.
*/
const hasInk = (rgba: Uint8Array) => {
for (let index = 0; index < rgba.length; index += 4) {
if (rgba[index] < 250 || rgba[index + 1] < 250 || rgba[index + 2] < 250) {
return true;
}
}
return false;
};
const compareSealedPdfWithImages = async ({ id, pdfData, images, testInfo }: CompareSealedPdfWithImagesOptions) => {
const renderedImages = await renderPdfToImage(pdfData);
// Every content page must have a reference.
expect(images).toHaveLength(renderedImages.length - 1);
for (const [index, { image, width, height }] of renderedImages.entries()) {
const isCertificate = index === renderedImages.length - 1;
const newImage = new Uint8Array(PNG.sync.read(image).data);
fs.writeFileSync(path.join(testInfo.outputPath(), `${id}-${index}-new.png`), new Uint8Array(image));
if (isCertificate) {
// The certificate is dynamic (dates, ids), so only assert it rendered.
expect(hasInk(newImage)).toBe(true);
continue;
}
const diff = new PNG({ width, height });
const oldImage = new Uint8Array(PNG.sync.read(images[index]).data);
const comparison = pixelMatch(
oldImage,
newImage,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
diff.data as unknown as Uint8Array,
width,
height,
{ threshold: 0.25 },
);
console.log(`${id}-${index}: ${comparison}`);
fs.writeFileSync(path.join(testInfo.outputPath(), `${id}-${index}-diff.png`), new Uint8Array(PNG.sync.write(diff)));
fs.writeFileSync(path.join(testInfo.outputPath(), `${id}-${index}-old.png`), new Uint8Array(images[index]));
expect(comparison).toBe(0);
}
};
@@ -0,0 +1,78 @@
import {
EnvelopeContentShapeType,
EnvelopeContentType,
ZEnvelopeContentMetaSchema,
} from '@documenso/lib/types/envelope-content-meta';
import { generateDatabaseId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { seedCompletedDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
import { getContentGroupsForPage, getPageCanvas } from '../fixtures/contents';
/**
* Seed a text content and a rectangle onto the first page of an envelope.
*/
const seedStaticContents = async (envelopeId: string, envelopeItemId: string) => {
await prisma.envelopeContent.createMany({
data: [
{
id: generateDatabaseId('envelope_content'),
envelopeId,
envelopeItemId,
contentMeta: ZEnvelopeContentMetaSchema.parse({
type: EnvelopeContentType.TEXT,
page: 1,
rotation: 0,
zIndex: 0,
positionX: 10,
positionY: 10,
width: 40,
height: 6,
text: 'Signing content',
}),
},
{
id: generateDatabaseId('envelope_content'),
envelopeId,
envelopeItemId,
contentMeta: ZEnvelopeContentMetaSchema.parse({
type: EnvelopeContentType.SHAPE,
shape: EnvelopeContentShapeType.RECTANGLE,
page: 1,
rotation: 0,
zIndex: 0,
positionX: 10,
positionY: 20,
width: 40,
height: 10,
strokeWidth: 2,
strokeColor: '#d00000',
strokeStyle: 'solid',
}),
},
],
});
};
test('contents are not rendered again on a completed document', async ({ page }) => {
const { user, team } = await seedUser();
const document = await seedCompletedDocument(user, team.id, [user], { internalVersion: 2 });
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: document.id },
include: { envelopeItems: true },
});
// Contents on a completed envelope are already imprinted onto its PDF.
await seedStaticContents(envelope.id, envelope.envelopeItems[0].id);
await apiSignin({ page, email: user.email, redirectPath: `/t/${team.url}/documents/${document.id}` });
await expect(getPageCanvas(page)).toBeVisible();
expect(await getContentGroupsForPage(page)).toHaveLength(0);
});
@@ -0,0 +1,183 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import {
EnvelopeContentShapeType,
EnvelopeContentType,
type TEnvelopeContentMetaInput,
ZEnvelopeContentMetaSchema,
} from '@documenso/lib/types/envelope-content-meta';
import { generateDatabaseId, nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, type Page, test } from '@playwright/test';
import { DocumentStatus, FieldType } from '@prisma/client';
import { apiSignin } from '../fixtures/authentication';
import { getKonvaElementCountForPage } from '../fixtures/konva';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe.configure({ mode: 'parallel' });
/**
* Contents are rendered into the PDF when the envelope is sent, so everything
* after DRAFT works from bytes which already contain them. The signer never
* draws them and never fetches their images.
*/
const rectangleMeta: TEnvelopeContentMetaInput = {
type: EnvelopeContentType.SHAPE,
shape: EnvelopeContentShapeType.RECTANGLE,
page: 1,
rotation: 0,
positionX: 10,
positionY: 10,
width: 30,
height: 20,
fillColor: '#ff0000',
fillOpacity: 1,
};
const setupSendableDocument = async (page: Page) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id, { internalVersion: 2 });
await apiSignin({ page, email: user.email, redirectPath: `/t/${team.url}/documents` });
const envelope = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
include: { envelopeItems: { include: { documentData: true } } },
});
const envelopeItem = envelope.envelopeItems[0];
// A signer with a signature field, so the envelope is sendable.
const recipient = await prisma.recipient.create({
data: {
envelopeId: envelope.id,
email: `signer-${nanoid(8)}@example.com`,
name: 'Signer',
token: nanoid(),
},
});
await prisma.field.create({
data: {
envelopeId: envelope.id,
envelopeItemId: envelopeItem.id,
recipientId: recipient.id,
type: FieldType.SIGNATURE,
page: 1,
positionX: 60,
positionY: 60,
width: 20,
height: 8,
customText: '',
inserted: false,
fieldMeta: { type: 'signature', overflow: 'auto' },
},
});
return { envelope, envelopeItem, teamId: team.id, recipientToken: recipient.token };
};
const addContent = async (page: Page, teamId: number, envelopeId: string, envelopeItemId: string) =>
await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/envelope.content.set`, {
headers: { 'content-type': 'application/json', 'x-team-id': teamId.toString() },
data: JSON.stringify({
json: { envelopeId, contents: [{ envelopeItemId, contentMeta: rectangleMeta, dataContentId: null }] },
}),
});
const sendDocument = async (page: Page, teamId: number, envelopeId: string) =>
await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/envelope.distribute`, {
headers: { 'content-type': 'application/json', 'x-team-id': teamId.toString() },
data: JSON.stringify({ json: { envelopeId } }),
});
test('sending renders the contents into the pdf while keeping the original', async ({ page }) => {
const { envelope, envelopeItem, teamId } = await setupSendableDocument(page);
const originalDataId = envelopeItem.documentDataId;
const originalInitialData = envelopeItem.documentData.initialData;
expect((await addContent(page, teamId, envelope.id, envelopeItem.id)).ok()).toBeTruthy();
const sent = await sendDocument(page, teamId, envelope.id);
expect(sent.ok()).toBeTruthy();
const after = await prisma.envelopeItem.findFirstOrThrow({
where: { id: envelopeItem.id },
include: { documentData: true },
});
// A new document data was written...
expect(after.documentDataId).not.toBe(originalDataId);
// ...with the contents in it...
expect(after.documentData.data).not.toBe(originalInitialData);
// ...but the original upload is untouched.
expect(after.documentData.initialData).toBe(originalInitialData);
});
test('the signer page does not draw contents or request their images', async ({ page }) => {
const { envelope, envelopeItem, teamId, recipientToken } = await setupSendableDocument(page);
await addContent(page, teamId, envelope.id, envelopeItem.id);
expect((await sendDocument(page, teamId, envelope.id)).ok()).toBeTruthy();
const imageRequests: string[] = [];
page.on('request', (request) => {
if (request.url().includes('/dataContent/')) {
imageRequests.push(request.url());
}
});
await page.goto(`${WEBAPP_BASE_URL}/sign/${recipientToken}`);
await page.locator('.konva-container canvas').first().waitFor({ state: 'visible' });
// The signature field is drawn on the canvas, the content is not.
await expect.poll(async () => await getKonvaElementCountForPage(page, 1, '.field-group')).toBe(1);
expect(await getKonvaElementCountForPage(page, 1, '.content-group')).toBe(0);
expect(imageRequests).toEqual([]);
});
test('sending is blocked when a content image cannot be loaded', async ({ page }) => {
const { envelope, envelopeItem, teamId } = await setupSendableDocument(page);
// An image content with no image attached to it.
await prisma.envelopeContent.create({
data: {
id: generateDatabaseId('envelope_content'),
envelopeId: envelope.id,
envelopeItemId: envelopeItem.id,
dataContentId: null,
contentMeta: ZEnvelopeContentMetaSchema.parse({
type: EnvelopeContentType.IMAGE,
page: 1,
rotation: 0,
positionX: 10,
positionY: 10,
width: 30,
height: 20,
}),
},
});
const sent = await sendDocument(page, teamId, envelope.id);
expect(sent.status()).toBe(400);
expect(await sent.text()).toContain('MISSING_CONTENT_IMAGE');
// Nothing was inserted and the envelope is still a draft.
const after = await prisma.envelope.findFirstOrThrow({
where: { id: envelope.id },
include: { envelopeItems: true },
});
expect(after.status).toBe(DocumentStatus.DRAFT);
expect(after.envelopeItems[0].documentDataId).toBe(envelopeItem.documentDataId);
});
@@ -0,0 +1,98 @@
import { createDataContentImage } from '@documenso/lib/server-only/data-content/create-data-content-image';
import { EnvelopeContentType, ZEnvelopeContentMetaSchema } from '@documenso/lib/types/envelope-content-meta';
import { generateDatabaseId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { createCanvas } from '@napi-rs/canvas';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
/**
* A page and everything drawn on it appear together. The page image is held
* back until the content images are loaded too, so a page is never shown in a
* half drawn state.
*/
const IMAGE_LOAD_MS = 4000;
test('a page waiting on its content images shows a loader', async ({ page }) => {
const { user, team } = await seedUser();
const document = await seedPendingDocument(user, team.id, [user], { internalVersion: 2 });
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: document.id },
include: { envelopeItems: true, recipients: true },
});
// The seeded fields carry no `fieldMeta`, which the v2 signer rejects.
await prisma.field.deleteMany({ where: { envelopeId: envelope.id } });
const png = await createCanvas(60, 30).encode('png');
const dataContent = await createDataContentImage({
file: { name: 'image.png', arrayBuffer: async () => png.buffer as ArrayBuffer },
});
await prisma.envelopeContent.create({
data: {
id: generateDatabaseId('envelope_content'),
envelopeId: envelope.id,
envelopeItemId: envelope.envelopeItems[0].id,
dataContentId: dataContent.id,
contentMeta: ZEnvelopeContentMetaSchema.parse({
type: EnvelopeContentType.IMAGE,
page: 1,
rotation: 0,
zIndex: 0,
positionX: 10,
positionY: 10,
width: 20,
height: 10,
}),
},
});
await page.route('**/dataContent/**/image', async (route) => {
await new Promise((resolve) => setTimeout(resolve, IMAGE_LOAD_MS));
await route.continue();
});
await apiSignin({ page, email: user.email, redirectPath: `/sign/${envelope.recipients[0].token}` });
const pageLoader = page.getByTestId('page-loader').first();
const pageImage = page.locator('img[alt=""]').first();
// While the content image is on its way, neither the page nor its contents
// are shown.
await expect(pageLoader).toBeVisible();
await expect(pageImage).not.toBeVisible();
await expect(page.locator('.konva-container canvas')).toHaveCount(0);
// Once it lands they appear together.
await expect(pageImage).toBeVisible({ timeout: 15000 });
await expect(page.locator('.konva-container canvas').first()).toBeVisible();
await expect(pageLoader).toHaveCount(0);
});
test('a page with no content images renders as soon as the page image is ready', async ({ page }) => {
const { user, team } = await seedUser();
const document = await seedPendingDocument(user, team.id, [user], { internalVersion: 2 });
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: document.id },
include: { recipients: true },
});
await prisma.field.deleteMany({ where: { envelopeId: envelope.id } });
await apiSignin({ page, email: user.email, redirectPath: `/sign/${envelope.recipients[0].token}` });
await expect(page.locator('.konva-container canvas').first()).toBeVisible();
await expect(page.locator('img[alt=""]').first()).toBeVisible();
await expect(page.getByTestId('page-loader')).toHaveCount(0);
});
@@ -0,0 +1,162 @@
import fs from 'node:fs';
import path from 'node:path';
import { UNSAFE_replaceEnvelopeItemPdf } from '@documenso/lib/server-only/envelope-item/replace-envelope-item-pdf';
import { EnvelopeContentType, ZEnvelopeContentMetaSchema } from '@documenso/lib/types/envelope-content-meta';
import { generateDatabaseId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
/**
* Replacing an envelope item's PDF already drops fields which fall beyond the
* new page count. Contents have to be dropped for the same reason: at seal
* time they are looked up by page, so one left behind is either drawn onto
* the certificate or aborts the job.
*/
const singlePagePdf = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
const multiPagePdf = fs.readFileSync(path.join(__dirname, '../../../../assets/field-font-alignment.pdf'));
const textMetaOnPage = (page: number) => ({
type: EnvelopeContentType.TEXT,
page,
rotation: 0,
positionX: 10,
positionY: 10,
width: 20,
height: 6,
text: `Page ${page}`,
});
const replacePdf = async (envelopeId: string, buffer: Buffer, name: string) => {
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: envelopeId },
include: { recipients: true, envelopeItems: true, documentMeta: true },
});
const user = await prisma.user.findUniqueOrThrow({ where: { id: envelope.userId } });
return await UNSAFE_replaceEnvelopeItemPdf({
envelope,
recipients: envelope.recipients,
envelopeItemId: envelope.envelopeItems[0].id,
oldDocumentDataId: envelope.envelopeItems[0].documentDataId,
data: {
file: new File([new Uint8Array(buffer)], name, { type: 'application/pdf' }),
},
user: { id: user.id, name: user.name, email: user.email },
apiRequestMetadata: {
requestMetadata: {},
source: 'app',
auth: null,
auditUser: { id: user.id, name: user.name, email: user.email },
},
});
};
test('replacing a PDF drops contents which fall beyond the new page count', async () => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id, { internalVersion: 2 });
// Start from a three page PDF.
await replacePdf(document.id, multiPagePdf, 'multi-page.pdf');
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: document.id },
include: { envelopeItems: true },
});
const envelopeItemId = envelope.envelopeItems[0].id;
await prisma.envelopeContent.createMany({
data: [1, 3].map((page) => ({
id: generateDatabaseId('envelope_content'),
envelopeId: envelope.id,
envelopeItemId,
contentMeta: ZEnvelopeContentMetaSchema.parse(textMetaOnPage(page)),
})),
});
await replacePdf(document.id, singlePagePdf, 'single-page.pdf');
const contents = await prisma.envelopeContent.findMany({
where: { envelopeId: envelope.id },
select: { contentMeta: true },
});
// The page 1 content survives, the page 3 content is gone.
expect(contents).toHaveLength(1);
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const contentMeta = contents[0].contentMeta as { page?: number };
expect(contentMeta.page).toBe(1);
});
test('replacing a PDF keeps a content which has no page of its own', async () => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id, { internalVersion: 2 });
await replacePdf(document.id, multiPagePdf, 'multi-page.pdf');
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: document.id },
include: { envelopeItems: true },
});
// A content without a page belongs to the first page, so it always fits.
await prisma.envelopeContent.create({
data: {
id: generateDatabaseId('envelope_content'),
envelopeId: envelope.id,
envelopeItemId: envelope.envelopeItems[0].id,
contentMeta: ZEnvelopeContentMetaSchema.parse({
type: EnvelopeContentType.TEXT,
rotation: 0,
positionX: 10,
positionY: 10,
width: 20,
height: 6,
text: 'No page',
}),
},
});
await replacePdf(document.id, singlePagePdf, 'single-page.pdf');
const contents = await prisma.envelopeContent.findMany({ where: { envelopeId: envelope.id } });
expect(contents).toHaveLength(1);
});
test('replacing a PDF keeps contents which still fit', async () => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id, { internalVersion: 2 });
await replacePdf(document.id, multiPagePdf, 'multi-page.pdf');
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: document.id },
include: { envelopeItems: true },
});
await prisma.envelopeContent.createMany({
data: [1, 2, 3].map((page) => ({
id: generateDatabaseId('envelope_content'),
envelopeId: envelope.id,
envelopeItemId: envelope.envelopeItems[0].id,
contentMeta: ZEnvelopeContentMetaSchema.parse(textMetaOnPage(page)),
})),
});
// Replacing with another three page PDF leaves them all in place.
await replacePdf(document.id, multiPagePdf, 'multi-page-again.pdf');
const contents = await prisma.envelopeContent.findMany({ where: { envelopeId: envelope.id } });
expect(contents).toHaveLength(3);
});
@@ -0,0 +1,164 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import {
EnvelopeContentShapeType,
EnvelopeContentType,
type TEnvelopeContentMetaInput,
} from '@documenso/lib/types/envelope-content-meta';
import { nanoid } from '@documenso/lib/universal/id';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { prisma } from '@documenso/prisma';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { createCanvas } from '@napi-rs/canvas';
import { expect, type Page, test } from '@playwright/test';
import { DocumentStatus, RecipientRole } from '@prisma/client';
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
import { apiSignin } from '../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
/**
* Contents are inserted into the PDF on send. The seal must then sign those
* bytes as they are, not draw the contents a second time on top of them.
*
* A translucent fill makes a second draw measurable: two 40% layers of the
* same colour compound to 64%, which reads as a much darker pixel.
*/
const FILL_OPACITY = 0.4;
const translucentGreen: TEnvelopeContentMetaInput = {
type: EnvelopeContentType.SHAPE,
shape: EnvelopeContentShapeType.RECTANGLE,
page: 1,
rotation: 0,
positionX: 20,
positionY: 20,
width: 40,
height: 30,
// Pure green so red and blue are driven entirely by the opacity. The stroke
// sits at the edge and the sample is taken at the centre, so it is ignored.
fillColor: '#00ff00',
fillOpacity: FILL_OPACITY,
};
/**
* Red/blue channel of pure green at the given opacity over white.
*/
const channelAtOpacity = (opacity: number) => Math.round(255 * (1 - opacity));
const SINGLE_DRAW = channelAtOpacity(FILL_OPACITY);
const DOUBLE_DRAW = channelAtOpacity(1 - (1 - FILL_OPACITY) ** 2);
/**
* Render the first page of a PDF and return the pixel at the centre of the
* given percentage box.
*/
const samplePixelAtBoxCentre = async (
pdfBytes: Uint8Array,
box: { positionX: number; positionY: number; width: number; height: number },
) => {
const pdf = await pdfjsLib.getDocument({ data: pdfBytes }).promise;
const page = await pdf.getPage(1);
const scale = 2;
const viewport = page.getViewport({ scale });
const canvas = createCanvas(viewport.width, viewport.height);
const canvasContext = canvas.getContext('2d');
await page.render({
// @ts-expect-error @napi-rs/canvas satisfies runtime requirements for pdfjs
canvas,
// @ts-expect-error @napi-rs/canvas satisfies runtime requirements for pdfjs
canvasContext,
viewport,
}).promise;
const x = Math.round(viewport.width * ((box.positionX + box.width / 2) / 100));
const y = Math.round(viewport.height * ((box.positionY + box.height / 2) / 100));
const [r, g, b] = canvasContext.getImageData(x, y, 1, 1).data;
return { r, g, b };
};
const setupEnvelopeWithContent = async (page: Page) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id, { internalVersion: 2 });
await apiSignin({ page, email: user.email, redirectPath: `/t/${team.url}/documents` });
const envelope = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
include: { envelopeItems: { include: { documentData: true } } },
});
const envelopeItem = envelope.envelopeItems[0];
const addContent = await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/envelope.content.set`, {
headers: { 'content-type': 'application/json', 'x-team-id': team.id.toString() },
data: JSON.stringify({
json: {
envelopeId: envelope.id,
contents: [{ envelopeItemId: envelopeItem.id, contentMeta: translucentGreen, dataContentId: null }],
},
}),
});
expect(addContent.ok()).toBeTruthy();
// A CC-only envelope has nobody left to act, so sending it seals it at
// once - through the real seal job, on the bytes the send just wrote.
await prisma.recipient.create({
data: {
envelopeId: envelope.id,
email: `cc-${nanoid(8)}@example.com`,
name: 'Copied',
token: nanoid(),
role: RecipientRole.CC,
},
});
return { envelope, envelopeItem, teamId: team.id };
};
test('sealing does not draw the contents a second time', async ({ page }) => {
const { envelope, envelopeItem, teamId } = await setupEnvelopeWithContent(page);
const sent = await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/envelope.distribute`, {
headers: { 'content-type': 'application/json', 'x-team-id': teamId.toString() },
data: JSON.stringify({ json: { envelopeId: envelope.id } }),
});
expect(sent.ok()).toBeTruthy();
await expect
.poll(async () => (await prisma.envelope.findFirstOrThrow({ where: { id: envelope.id } })).status, {
timeout: 30_000,
})
.toBe(DocumentStatus.COMPLETED);
const sealed = await prisma.envelopeItem.findFirstOrThrow({
where: { id: envelopeItem.id },
include: { documentData: true },
});
const sealedBytes = await getFileServerSide(sealed.documentData);
const pixel = await samplePixelAtBoxCentre(sealedBytes, translucentGreen);
// Green stays saturated either way; red and blue tell the two apart.
expect(pixel.g).toBeGreaterThan(240);
expect(pixel.r).toBeGreaterThan((SINGLE_DRAW + DOUBLE_DRAW) / 2);
expect(pixel.b).toBeGreaterThan((SINGLE_DRAW + DOUBLE_DRAW) / 2);
// The original upload never had the content drawn on it at all.
const originalBytes = await getFileServerSide({
type: sealed.documentData.type,
data: sealed.documentData.initialData,
});
const originalPixel = await samplePixelAtBoxCentre(originalBytes, translucentGreen);
expect(originalPixel).toEqual({ r: 255, g: 255, b: 255 });
});
@@ -0,0 +1,92 @@
import { createDataContentImage } from '@documenso/lib/server-only/data-content/create-data-content-image';
import { EnvelopeContentType, ZEnvelopeContentMetaSchema } from '@documenso/lib/types/envelope-content-meta';
import { generateDatabaseId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { createCanvas } from '@napi-rs/canvas';
import { expect, type Page, test } from '@playwright/test';
import { DocumentStatus } from '@prisma/client';
import { apiSignin } from '../fixtures/authentication';
import { getPageCanvas } from '../fixtures/contents';
/**
* A sealed envelope has its contents imprinted on the PDF, so their images
* are never loaded. The page must still render, otherwise the fields drawn
* alongside them disappear too.
*/
const seedSealedDocumentWithImageContent = async ({ withImageContent }: { withImageContent: boolean }) => {
const { user, team } = await seedUser();
const document = await seedPendingDocument(user, team.id, [user], { internalVersion: 2 });
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: document.id },
include: { envelopeItems: true },
});
if (withImageContent) {
const png = await createCanvas(60, 30).encode('png');
const dataContent = await createDataContentImage({
file: { name: 'image.png', arrayBuffer: async () => png.buffer as ArrayBuffer },
});
await prisma.envelopeContent.create({
data: {
id: generateDatabaseId('envelope_content'),
envelopeId: envelope.id,
envelopeItemId: envelope.envelopeItems[0].id,
dataContentId: dataContent.id,
contentMeta: ZEnvelopeContentMetaSchema.parse({
type: EnvelopeContentType.IMAGE,
page: 1,
rotation: 0,
zIndex: 0,
positionX: 10,
positionY: 10,
width: 20,
height: 10,
}),
},
});
}
// Rejecting seals the envelope, and unlike completing it the fields are
// still meant to be drawn.
await prisma.envelope.update({
where: { id: envelope.id },
data: { status: DocumentStatus.REJECTED },
});
return { user, team, documentId: document.id };
};
const countFieldGroups = async (page: Page) =>
await page.evaluate(() => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const konva = (window as unknown as { Konva: typeof import('konva').default }).Konva;
const stage = konva.stages.find((currentStage) => currentStage.attrs.id === 'page-1');
return stage?.find('.field-group').length ?? 0;
});
test('a rejected document with an image content still draws its fields', async ({ page }) => {
const { user, team, documentId } = await seedSealedDocumentWithImageContent({ withImageContent: true });
await apiSignin({ page, email: user.email, redirectPath: `/t/${team.url}/documents/${documentId}` });
await expect(getPageCanvas(page)).toBeVisible();
await expect.poll(() => countFieldGroups(page)).toBeGreaterThan(0);
});
test('a rejected document without contents is unaffected', async ({ page }) => {
const { user, team, documentId } = await seedSealedDocumentWithImageContent({ withImageContent: false });
await apiSignin({ page, email: user.email, redirectPath: `/t/${team.url}/documents/${documentId}` });
await expect(getPageCanvas(page)).toBeVisible();
await expect.poll(() => countFieldGroups(page)).toBeGreaterThan(0);
});
@@ -0,0 +1,112 @@
import { prisma } from '@documenso/prisma';
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, type Page, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
import { getPageCanvas } from '../fixtures/contents';
/**
* The viewer toolbar is positioned against the visible area of the scroll
* container, which can change size without the window doing so (e.g. the
* signing view's collapsible sidebar).
*/
const SIDEBAR_EXPANDED_WIDTH = 320;
const SIDEBAR_COLLAPSED_WIDTH = 48;
/**
* The horizontal centre of the toolbar and of the scroll container it belongs
* to. They must stay aligned.
*/
const getToolbarAndContainerCentres = async (page: Page) =>
await page.evaluate(() => {
const zoomOut = document.querySelector('button[title="Zoom out"]');
const toolbar = zoomOut?.parentElement;
const scroller = document.querySelector('.embed--DocumentContainer');
if (!toolbar || !scroller) {
throw new Error('Toolbar or scroll container not found');
}
const toolbarRect = toolbar.getBoundingClientRect();
const scrollerRect = scroller.getBoundingClientRect();
return {
toolbar: toolbarRect.left + toolbarRect.width / 2,
container: scrollerRect.left + scrollerRect.width / 2,
containerWidth: scrollerRect.width,
};
});
test('the viewer toolbar follows the scroll container when the sidebar collapses', async ({ page }) => {
const { user, team } = await seedUser();
const document = await seedPendingDocument(user, team.id, [user], { internalVersion: 2 });
// The seeded fields carry no `fieldMeta`, which the v2 signer rejects, and
// fields are not what this test is about.
await prisma.field.deleteMany({ where: { envelopeId: document.id } });
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: document.id },
include: { recipients: true },
});
// The scroll container is observed from within the observer's own callback,
// so a mistake there surfaces as a resize loop.
const consoleErrors: string[] = [];
page.on('console', (message) => {
if (message.type() === 'error') {
consoleErrors.push(message.text());
}
});
// The toolbar is only shown from the `lg` breakpoint on the signing view.
await page.setViewportSize({ width: 1400, height: 900 });
await apiSignin({ page, email: user.email, redirectPath: `/sign/${envelope.recipients[0].token}` });
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
await expect(getPageCanvas(page)).toBeVisible();
const collapseButton = page.getByRole('button', { name: 'Collapse sidebar' });
await expect(collapseButton).toBeVisible();
const expanded = await getToolbarAndContainerCentres(page);
expect(expanded.toolbar).toBeCloseTo(expanded.container, 0);
await collapseButton.click();
// The container widens by the difference in sidebar widths, so its centre
// shifts left by half of that.
await expect
.poll(async () => (await getToolbarAndContainerCentres(page)).containerWidth)
.toBeGreaterThan(expanded.containerWidth + (SIDEBAR_EXPANDED_WIDTH - SIDEBAR_COLLAPSED_WIDTH) / 2);
await expect
.poll(async () => {
const { toolbar, container } = await getToolbarAndContainerCentres(page);
return Math.abs(toolbar - container);
})
.toBeLessThan(1);
// And it tracks the container back the other way.
await page.getByRole('button', { name: 'Expand sidebar' }).click();
await expect
.poll(async () => (await getToolbarAndContainerCentres(page)).containerWidth)
.toBeLessThan(expanded.containerWidth + 1);
await expect
.poll(async () => {
const { toolbar, container } = await getToolbarAndContainerCentres(page);
return Math.abs(toolbar - container);
})
.toBeLessThan(1);
expect(consoleErrors.filter((error) => error.includes('ResizeObserver'))).toEqual([]);
});
+270
View File
@@ -0,0 +1,270 @@
import { createCanvas } from '@napi-rs/canvas';
import { expect, type Locator, type Page } from '@playwright/test';
import type Konva from 'konva';
import type { TEnvelopeEditorSurface } from './envelope-editor';
import { getKonvaElementCountForPage } from './konva';
export type ContentButtonName = 'Text' | 'Line' | 'Rectangle' | 'Highlight' | 'Image';
export type TestImageFile = { name: string; mimeType: string; buffer: Buffer };
/**
* Generate a solid color image of the given size in memory.
*/
export const createImageFile = async (
name: string,
width: number,
height: number,
format: 'png' | 'jpeg' | 'webp' = 'png',
): Promise<TestImageFile> => {
const canvas = createCanvas(width, height);
const context = canvas.getContext('2d');
context.fillStyle = 'rgb(30, 120, 220)';
context.fillRect(0, 0, width, height);
const buffer = format === 'png' ? await canvas.encode('png') : await canvas.encode(format);
return { name, mimeType: `image/${format}`, buffer };
};
/**
* A real GIF labelled as a PNG, as when the file is renamed. The declared type
* passes every check made before the bytes are read, so only decoding the
* image can reject it.
*/
export const createGifLabelledAsPng = (name: string): TestImageFile => ({
name,
mimeType: 'image/png',
buffer: Buffer.from('R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==', 'base64'),
});
/**
* Switch the fields step sidebar to the given tab.
*/
export const selectEditorTab = async (root: Page, tab: 'Fields' | 'Contents') => {
await root.getByRole('tab', { name: tab }).click();
await expect(root.getByRole('tab', { name: tab })).toHaveAttribute('data-state', 'active');
};
export const getPageCanvas = (root: Page) => root.locator('.konva-container canvas').first();
/**
* Run a mouse interaction on the canvas with the floating action bar of the
* current selection unable to intercept it.
*
* The bar hangs just below the selected content and sits above the canvas,
* so a click there would press one of its buttons (e.g. Remove) rather than
* reach the canvas.
*/
export const interactWithCanvasPastActionBar = async (root: Page, interaction: () => Promise<void>) => {
const setActionBarPointerEvents = async (value: string) => {
await root.evaluate((pointerEvents) => {
for (const bar of document.querySelectorAll<HTMLElement>('[data-testid="envelope-canvas-action-bar"]')) {
bar.style.pointerEvents = pointerEvents;
}
}, value);
};
await setActionBarPointerEvents('none');
try {
await interaction();
} finally {
await setActionBarPointerEvents('');
}
};
/**
* Pick a content from the palette and click on the canvas to place it.
*/
export const placeContentOnPdf = async (
root: Page,
contentName: ContentButtonName,
position: { x: number; y: number },
) => {
await root.getByRole('button', { name: contentName, exact: true }).click();
const canvas = getPageCanvas(root);
await expect(canvas).toBeVisible();
await interactWithCanvasPastActionBar(root, async () => {
await canvas.click({ position });
});
};
/**
* Click a content on the canvas to select it.
*/
export const selectContentOnCanvas = async (
root: Page,
position: { x: number; y: number },
options: { shift?: boolean } = {},
) => {
const canvas = getPageCanvas(root);
await expect(canvas).toBeVisible();
await root.waitForTimeout(300);
await interactWithCanvasPastActionBar(root, async () => {
await canvas.click({ position, modifiers: options.shift ? ['Shift'] : [], force: true });
});
};
export const getContentCountForPage = async (root: Page, pageNumber = 1) =>
await getKonvaElementCountForPage(root, pageNumber, '.content-group');
export const getFieldCountForPage = async (root: Page, pageNumber = 1) =>
await getKonvaElementCountForPage(root, pageNumber, '.field-group');
type ContentGroupSnapshot = {
id: string;
contentType: string | undefined;
visible: boolean;
/**
* The group's client rect in unscaled page coordinates.
*/
rect: { x: number; y: number; width: number; height: number };
/**
* The names of the group's visible child nodes.
*/
visibleChildren: string[];
};
/**
* Snapshot every content group on a page, straight from Konva.
*/
export const getContentGroupsForPage = async (root: Page, pageNumber = 1): Promise<ContentGroupSnapshot[]> => {
await getPageCanvas(root).waitFor({ state: 'visible' });
return await root.evaluate(
({ pageNumber }) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const konva: typeof Konva = (window as unknown as { Konva: typeof Konva }).Konva;
const stage = konva.stages.find((currentStage) => currentStage.attrs.id === `page-${pageNumber}`);
if (!stage) {
return [];
}
const scale = stage.scaleX();
return stage.find('.content-group').map((node) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const group = node as Konva.Group;
const rect = group.getClientRect({ skipStroke: true, skipShadow: true });
return {
id: group.id(),
contentType: group.getAttr('contentType'),
visible: group.isVisible(),
rect: { x: rect.x / scale, y: rect.y / scale, width: rect.width / scale, height: rect.height / scale },
visibleChildren: group
.getChildren()
.filter((child) => child.isVisible())
.map((child) => child.name()),
};
});
},
{ pageNumber },
);
};
/**
* The unscaled page size of a page's Konva stage.
*/
export const getPageSize = async (root: Page, pageNumber = 1) => {
await getPageCanvas(root).waitFor({ state: 'visible' });
return await root.evaluate(
({ pageNumber }) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const konva: typeof Konva = (window as unknown as { Konva: typeof Konva }).Konva;
const stage = konva.stages.find((currentStage) => currentStage.attrs.id === `page-${pageNumber}`);
if (!stage) {
throw new Error(`Stage for page ${pageNumber} not found`);
}
return { width: stage.width() / stage.scaleX(), height: stage.height() / stage.scaleY(), scale: stage.scaleX() };
},
{ pageNumber },
);
};
/**
* Wait until exactly the given contents are selected, i.e. the transformer is
* attached to them and their anchors can be grabbed.
*/
export const waitForContentSelection = async (root: Page, contentIds: string[], pageNumber = 1) => {
await expect
.poll(
async () =>
await root.evaluate(
({ pageNumber }) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const konva: typeof Konva = (window as unknown as { Konva: typeof Konva }).Konva;
const stage = konva.stages.find((currentStage) => currentStage.attrs.id === `page-${pageNumber}`);
if (!stage) {
return [];
}
return stage
.find('Transformer')
.flatMap((node) =>
node instanceof konva.Transformer ? node.nodes().map((selected) => selected.id()) : [],
)
.sort();
},
{ pageNumber },
),
)
.toEqual([...contentIds].sort());
};
/**
* Open the settings dialog and set the external ID used to find the envelope
* in the database.
*/
export const getContentActionButton = (root: Page, title: string): Locator =>
root.getByTestId('envelope-canvas-action-bar').locator(`button[title="${title}"]`);
/**
* Upload through the action bar. Clicking Upload / Replace opens the upload
* dialog, which immediately opens the native picker; the file is set on that.
*
* Returns as soon as the file is handed over. Follow with
* `waitForUploadToLand` for a successful upload, or check the dialog's error
* text for a rejected one.
*/
export const uploadImage = async (root: Page, file: TestImageFile) => {
const fileChooser = root.waitForEvent('filechooser');
await getContentActionButton(root, 'Upload image').or(getContentActionButton(root, 'Replace image')).click();
await (await fileChooser).setFiles({ name: file.name, mimeType: file.mimeType, buffer: file.buffer });
};
/**
* Wait for a successful upload's dialog to close.
*/
export const waitForUploadToLand = async (root: Page) => {
await expect(root.getByTestId('content-image-uploading')).toHaveCount(0);
await expect(root.getByRole('dialog')).toHaveCount(0);
};
/**
* Wait for the editor's debounced autosave to flush.
*/
export const waitForContentsAutosave = async (surface: TEnvelopeEditorSurface) => {
if (surface.isEmbedded) {
return;
}
// Autosave debounces for 2 seconds.
await surface.root.waitForTimeout(2500);
};
@@ -0,0 +1,151 @@
import { createDataContentImage } from '@documenso/lib/server-only/data-content/create-data-content-image';
import { EnvelopeContentType, ZEnvelopeContentMetaSchema } from '@documenso/lib/types/envelope-content-meta';
import { generateDatabaseId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { seedDirectTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { createCanvas } from '@napi-rs/canvas';
import { expect, test } from '@playwright/test';
import { getContentGroupsForPage } from '../fixtures/contents';
/**
* An image content with no image renders nothing, so a direct template
* holding one would let a signer fill everything in and only fail when the
* document is created. The signing page reports it up front instead.
*/
const INVALID_TEMPLATE_HEADING = 'Invalid direct link template';
/**
* A real PNG, encoded the same way as an uploaded one, so the browser can
* actually decode it when the signing page loads the image.
*/
const createPng = async () => {
const canvas = createCanvas(60, 30);
const context = canvas.getContext('2d');
context.fillStyle = 'rgb(30, 120, 220)';
context.fillRect(0, 0, 60, 30);
return await canvas.encode('png');
};
const seedDirectTemplateWithImageContent = async ({ withImage }: { withImage: boolean }) => {
const { user, team } = await seedUser();
const template = await seedDirectTemplate({
title: `E2E Direct Template Missing Image ${Date.now()}`,
userId: user.id,
teamId: team.id,
internalVersion: 2,
createDirectRecipientSignatureField: true,
});
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: template.id },
include: { envelopeItems: true, directLink: true },
});
let dataContentId: string | null = null;
if (withImage) {
const png = await createPng();
const dataContent = await createDataContentImage({
file: { name: 'image.png', arrayBuffer: async () => png.buffer as ArrayBuffer },
});
dataContentId = dataContent.id;
}
await prisma.envelopeContent.create({
data: {
id: generateDatabaseId('envelope_content'),
envelopeId: envelope.id,
envelopeItemId: envelope.envelopeItems[0].id,
dataContentId,
contentMeta: ZEnvelopeContentMetaSchema.parse({
type: EnvelopeContentType.IMAGE,
page: 1,
rotation: 0,
zIndex: 0,
positionX: 10,
positionY: 10,
width: 20,
height: 10,
}),
},
});
if (!envelope.directLink) {
throw new Error('Direct link not seeded');
}
return { token: envelope.directLink.token };
};
test('the direct signing page reports an image content with no image', async ({ page }) => {
const { token } = await seedDirectTemplateWithImageContent({ withImage: false });
await page.goto(`/d/${token}`);
await expect(page.getByRole('heading', { name: INVALID_TEMPLATE_HEADING })).toBeVisible();
await expect(page.getByText('image content')).toBeVisible();
// The signing form is not offered at all.
await expect(page.getByRole('button', { name: 'Complete' })).toHaveCount(0);
});
test('the direct signing page still reports a signer with no signature field', async ({ page }) => {
const { user, team } = await seedUser();
const template = await seedDirectTemplate({
title: `E2E Direct Template Missing Signature ${Date.now()}`,
userId: user.id,
teamId: team.id,
internalVersion: 2,
createDirectRecipientSignatureField: false,
});
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: template.id },
include: { directLink: true },
});
if (!envelope.directLink) {
throw new Error('Direct link not seeded');
}
await page.goto(`/d/${envelope.directLink.token}`);
await expect(page.getByRole('heading', { name: INVALID_TEMPLATE_HEADING })).toBeVisible();
await expect(page.getByText('do not have a signature field assigned')).toBeVisible();
});
/**
* A direct template is signed as-is, before any document exists, so its
* contents are not yet inserted into the PDF. The signer must still see them,
* which means they are drawn on the canvas here rather than read from the
* bytes as they are for a sent document.
*/
test('the direct signing page draws the contents once the image is attached', async ({ page }) => {
const { token } = await seedDirectTemplateWithImageContent({ withImage: true });
await page.goto(`/d/${token}`);
await expect(page.getByRole('heading', { name: INVALID_TEMPLATE_HEADING })).toHaveCount(0);
await expect(page.locator('.konva-container canvas').first()).toBeVisible();
// The image decodes asynchronously, so poll until the group has drawn it.
await expect
.poll(async () => {
const groups = await getContentGroupsForPage(page);
return groups.map((group) => ({
type: group.contentType,
hasImage: group.visibleChildren.includes('content-image'),
}));
})
.toEqual([{ type: EnvelopeContentType.IMAGE, hasImage: true }]);
});
@@ -0,0 +1,144 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createDataContentImage } from '@documenso/lib/server-only/data-content/create-data-content-image';
import { EnvelopeContentType, ZEnvelopeContentMetaSchema } from '@documenso/lib/types/envelope-content-meta';
import { generateDatabaseId, nanoid } from '@documenso/lib/universal/id';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { createCanvas } from '@napi-rs/canvas';
import { expect, type Page, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe.configure({ mode: 'parallel' });
/**
* A data content (e.g. an uploaded image) is an immutable blob which any
* number of contents may point at. Copying a template shares its images with
* the new document rather than duplicating them, and removing a content never
* deletes the data content behind it.
*/
const createPng = async () => {
const canvas = createCanvas(60, 30);
const context = canvas.getContext('2d');
context.fillStyle = 'rgb(30, 120, 220)';
context.fillRect(0, 0, 60, 30);
return await canvas.encode('png');
};
const seedTemplateWithImageContent = async (page: Page) => {
const { user, team } = await seedUser();
const template = await seedBlankTemplate(user, team.id, { internalVersion: 2 });
const envelope = await prisma.envelope.findFirstOrThrow({
where: { id: template.id },
include: { envelopeItems: true },
});
const recipient = await prisma.recipient.create({
data: {
envelopeId: envelope.id,
email: `signer-${nanoid(8)}@example.com`,
name: 'Signer',
token: nanoid(),
},
});
const png = await createPng();
const dataContent = await createDataContentImage({
file: { name: 'image.png', arrayBuffer: async () => png.buffer as ArrayBuffer },
});
await prisma.envelopeContent.create({
data: {
id: generateDatabaseId('envelope_content'),
envelopeId: envelope.id,
envelopeItemId: envelope.envelopeItems[0].id,
dataContentId: dataContent.id,
contentMeta: ZEnvelopeContentMetaSchema.parse({
type: EnvelopeContentType.IMAGE,
page: 1,
rotation: 0,
zIndex: 0,
positionX: 10,
positionY: 10,
width: 20,
height: 10,
}),
},
});
await apiSignin({ page, email: user.email, redirectPath: `/t/${team.url}/templates` });
return { envelope, recipient, dataContentId: dataContent.id, teamId: team.id };
};
const createDocumentFromTemplate = async (
page: Page,
teamId: number,
templateSecondaryId: string,
recipient: { id: number; email: string; name: string },
) => {
const res = await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/template.createDocumentFromTemplate`, {
headers: { 'content-type': 'application/json', 'x-team-id': teamId.toString() },
data: JSON.stringify({
json: {
templateId: mapSecondaryIdToTemplateId(templateSecondaryId),
recipients: [{ id: recipient.id, email: recipient.email, name: recipient.name }],
},
}),
});
expect(res.ok()).toBeTruthy();
const body = await res.json();
// The route answers with the legacy document shape, whose `id` is the
// numeric document ID; `envelopeId` is the envelope's own ID.
return body.result.data.json.envelopeId as string;
};
test('a document created from a template shares its data contents', async ({ page }) => {
const { envelope, recipient, dataContentId, teamId } = await seedTemplateWithImageContent(page);
const documentId = await createDocumentFromTemplate(page, teamId, envelope.secondaryId, recipient);
const copied = await prisma.envelopeContent.findFirstOrThrow({ where: { envelopeId: documentId } });
// Same row, not a copy of it.
expect(copied.dataContentId).toBe(dataContentId);
expect(await prisma.dataContent.count({ where: { id: dataContentId } })).toBe(1);
// Both contents point at it.
expect(await prisma.envelopeContent.count({ where: { dataContentId } })).toBe(2);
});
test('removing a content leaves the shared data content in place', async ({ page }) => {
const { envelope, recipient, dataContentId, teamId } = await seedTemplateWithImageContent(page);
const documentId = await createDocumentFromTemplate(page, teamId, envelope.secondaryId, recipient);
// Save the document with no contents, removing the copied one.
const removed = await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/envelope.content.set`, {
headers: { 'content-type': 'application/json', 'x-team-id': teamId.toString() },
data: JSON.stringify({ json: { envelopeId: documentId, contents: [] } }),
});
expect(removed.ok()).toBeTruthy();
expect(await prisma.envelopeContent.count({ where: { envelopeId: documentId } })).toBe(0);
// The template's content still has its image.
expect(await prisma.dataContent.count({ where: { id: dataContentId } })).toBe(1);
const templateContent = await prisma.envelopeContent.findFirstOrThrow({ where: { envelopeId: envelope.id } });
expect(templateContent.dataContentId).toBe(dataContentId);
});
+8 -1
View File
@@ -1,6 +1,11 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT, FREE_PLAN_LIMITS } from './constants';
import {
DEFAULT_ENVELOPE_CONTENT_COUNT,
DEFAULT_ENVELOPE_CONTENT_IMAGE_COUNT,
DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
FREE_PLAN_LIMITS,
} from './constants';
import type { TLimitsResponseSchema } from './schema';
import { ZLimitsResponseSchema } from './schema';
@@ -33,6 +38,8 @@ export const getLimits = async ({ headers, teamId }: GetLimitsOptions) => {
quota: FREE_PLAN_LIMITS,
remaining: FREE_PLAN_LIMITS,
maximumEnvelopeItemCount: DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
maximumEnvelopeContentCount: DEFAULT_ENVELOPE_CONTENT_COUNT,
maximumEnvelopeContentImageCount: DEFAULT_ENVELOPE_CONTENT_IMAGE_COUNT,
} satisfies TLimitsResponseSchema;
});
};
@@ -35,3 +35,18 @@ export const DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT = 5;
* 0 = Unlimited recipients.
*/
export const DEFAULT_RECIPIENT_COUNT = 20;
/**
* Used as an initial value for the frontend before values are loaded from the server.
*
* 0 = Unlimited contents, matching the value existing organisation claims
* were backfilled with.
*/
export const DEFAULT_ENVELOPE_CONTENT_COUNT = 0;
/**
* Used as an initial value for the frontend before values are loaded from the server.
*
* 0 = Unlimited image contents.
*/
export const DEFAULT_ENVELOPE_CONTENT_IMAGE_COUNT = 0;
@@ -3,7 +3,12 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'rea
import { isDeepEqual } from 'remeda';
import { getLimits } from '../client';
import { DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT, FREE_PLAN_LIMITS } from '../constants';
import {
DEFAULT_ENVELOPE_CONTENT_COUNT,
DEFAULT_ENVELOPE_CONTENT_IMAGE_COUNT,
DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
FREE_PLAN_LIMITS,
} from '../constants';
import type { TLimitsResponseSchema } from '../schema';
export type LimitsContextValue = TLimitsResponseSchema & { refreshLimits: () => Promise<void> };
@@ -37,6 +42,8 @@ export const LimitsProvider = ({
quota: FREE_PLAN_LIMITS,
remaining: FREE_PLAN_LIMITS,
maximumEnvelopeItemCount: DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
maximumEnvelopeContentCount: DEFAULT_ENVELOPE_CONTENT_COUNT,
maximumEnvelopeContentImageCount: DEFAULT_ENVELOPE_CONTENT_IMAGE_COUNT,
},
disableLimitsFetch,
teamId,
+7 -1
View File
@@ -1,6 +1,10 @@
import { z } from 'zod';
import { DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT } from './constants';
import {
DEFAULT_ENVELOPE_CONTENT_COUNT,
DEFAULT_ENVELOPE_CONTENT_IMAGE_COUNT,
DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
} from './constants';
// Not proud of the below but it's a way to deal with Infinity when returning JSON.
export const ZLimitsSchema = z.object({
@@ -24,6 +28,8 @@ export const ZLimitsResponseSchema = z.object({
quota: ZLimitsSchema,
remaining: ZLimitsSchema,
maximumEnvelopeItemCount: z.number().optional().default(DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT),
maximumEnvelopeContentCount: z.number().optional().default(DEFAULT_ENVELOPE_CONTENT_COUNT),
maximumEnvelopeContentImageCount: z.number().optional().default(DEFAULT_ENVELOPE_CONTENT_IMAGE_COUNT),
});
export type TLimitsResponseSchema = z.infer<typeof ZLimitsResponseSchema>;
+12 -7
View File
@@ -42,13 +42,18 @@ export const getServerLimits = async ({ userId, teamId }: GetServerLimitsOptions
const remaining = structuredClone(FREE_PLAN_LIMITS);
const subscription = organisation.subscription;
const maximumEnvelopeItemCount = organisation.organisationClaim.envelopeItemCount;
const baseValues = {
maximumEnvelopeItemCount: organisation.organisationClaim.envelopeItemCount,
maximumEnvelopeContentCount: organisation.organisationClaim.envelopeContentCount,
maximumEnvelopeContentImageCount: organisation.organisationClaim.envelopeContentImageCount,
};
if (!IS_BILLING_ENABLED()) {
return {
quota: SELFHOSTED_PLAN_LIMITS,
remaining: SELFHOSTED_PLAN_LIMITS,
maximumEnvelopeItemCount,
...baseValues,
};
}
@@ -57,7 +62,7 @@ export const getServerLimits = async ({ userId, teamId }: GetServerLimitsOptions
return {
quota: PAID_PLAN_LIMITS,
remaining: PAID_PLAN_LIMITS,
maximumEnvelopeItemCount,
...baseValues,
};
}
@@ -66,7 +71,7 @@ export const getServerLimits = async ({ userId, teamId }: GetServerLimitsOptions
return {
quota: INACTIVE_PLAN_LIMITS,
remaining: INACTIVE_PLAN_LIMITS,
maximumEnvelopeItemCount,
...baseValues,
};
}
@@ -75,7 +80,7 @@ export const getServerLimits = async ({ userId, teamId }: GetServerLimitsOptions
return {
quota: INACTIVE_PLAN_LIMITS,
remaining: INACTIVE_PLAN_LIMITS,
maximumEnvelopeItemCount,
...baseValues,
};
}
@@ -85,7 +90,7 @@ export const getServerLimits = async ({ userId, teamId }: GetServerLimitsOptions
return {
quota: PAID_PLAN_LIMITS,
remaining: PAID_PLAN_LIMITS,
maximumEnvelopeItemCount,
...baseValues,
};
}
@@ -123,6 +128,6 @@ export const getServerLimits = async ({ userId, teamId }: GetServerLimitsOptions
return {
quota,
remaining,
maximumEnvelopeItemCount,
...baseValues,
};
};
@@ -0,0 +1,240 @@
import type { EnvelopeContent } from '@prisma/client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ContentImageStatus, ContentImageStatusMap } from '../../universal/content-renderer/content-image-status';
import type { ContentImageMap, ContentImageSource } from '../../universal/content-renderer/content-renderer';
import { getDataContentIds } from '../../utils/envelope-content';
import { getDataContentImageUrl } from '../../utils/envelope-download';
import { type ContentImageDetails, decodeLocalContentImage, loadContentImage } from '../load-content-image';
import { useLatestRef } from './use-latest-ref';
export type ContentImages = {
/**
* The loaded images by data content ID.
*/
images: ContentImageMap;
/**
* The file details of the loaded images by data content ID, for display.
*/
details: ReadonlyMap<string, ContentImageDetails>;
/**
* The loading status by data content ID. IDs which have not started
* loading are absent.
*/
loadingStatuses: ContentImageStatusMap;
/**
* The images being loaded.
*
* An image belonging to a content is not always requested, e.g. nothing is
* loaded for a sealed envelope since its contents are imprinted on the PDF.
* Consumers need this to tell an image which is still on its way from one
* which is never coming.
*/
requestedDataContentIds: ReadonlySet<string>;
/**
* Insert an already decoded image, e.g. an upload decoded locally, so it is
* available immediately without a request.
*/
setImage: (dataContentId: string, image: ContentImageSource, details: ContentImageDetails) => void;
};
type UseContentImagesOptions = {
envelopeId: string;
/**
* The envelope item currently being rendered. Only its images are loaded.
*/
envelopeItemId: string | null;
/**
* Data is only used for embeds, where the image has yet to be uploaded since
* embeds are all built locally before being sent at the end of the flow.
*/
contents: (Pick<EnvelopeContent, 'envelopeItemId' | 'dataContentId'> & { data?: Blob })[];
token: string | undefined;
presignToken?: string | undefined;
};
type ContentImagesState = {
images: Map<string, ContentImageSource>;
details: Map<string, ContentImageDetails>;
loadingStatuses: Map<string, ContentImageStatus>;
};
const createEmptyState = (): ContentImagesState => ({
images: new Map(),
details: new Map(),
loadingStatuses: new Map(),
});
/**
* Load and cache the images of the image contents of the envelope item being
* rendered, starting as soon as the contents are known so they are ready by
* the time the pages render.
*
* Images are kept for the lifetime of the provider, including across item
* switches, so navigating back never refetches or re-decodes. They are only
* closed on unmount, since a Konva node may still draw an image until its
* stage is destroyed and drawing a closed bitmap throws.
*/
export const useContentImages = ({
envelopeId,
envelopeItemId,
contents,
token,
presignToken,
}: UseContentImagesOptions): ContentImages => {
const [state, setState] = useState<ContentImagesState>(createEmptyState);
// Read when a load starts, so the effect does not re-run on every content
// change. A content always holds its file before its ID is requested.
const contentsRef = useLatestRef(contents);
// Mirrors of the state for the effects, so starting loads does not depend
// on the state itself (which would re-run the effect on every update) and
// unmounting can close whatever was loaded.
const loadingStatusesRef = useLatestRef(state.loadingStatuses);
const imagesRef = useLatestRef(state.images);
// Identifies the current mount. Bumped on unmount so a load started under a
// previous mount discards its result rather than writing into state which
// has since been cleared. A strict mode remount counts as a new mount.
const mountIdRef = useRef(0);
const requestedDataContentIds = useMemo(() => {
const itemContents = contents.filter((content) => content.envelopeItemId === envelopeItemId);
return getDataContentIds(itemContents).sort();
}, [contents, envelopeItemId]);
// A stable key so the effect only re-runs when the set of IDs changes.
const requestedDataContentIdsKey = requestedDataContentIds.join(',');
useEffect(() => {
const unrequestedDataContentIds = requestedDataContentIds.filter(
(dataContentId) => !loadingStatusesRef.current.has(dataContentId),
);
if (unrequestedDataContentIds.length === 0) {
return;
}
const mountId = mountIdRef.current;
setState((prev) => ({
...prev,
loadingStatuses: setDataContentStatuses(prev.loadingStatuses, unrequestedDataContentIds, 'loading'),
}));
for (const dataContentId of unrequestedDataContentIds) {
const localImage = contentsRef.current.find(
(content) => content.dataContentId === dataContentId && content.data,
)?.data;
const loadImage = localImage
? decodeLocalContentImage(localImage)
: loadContentImage(getDataContentImageUrl({ envelopeId, dataContentId, token, presignToken }));
void loadImage
.then(({ image, details }) => {
if (mountIdRef.current !== mountId) {
closeImage(image);
return;
}
setState((prev) => ({
images: new Map(prev.images).set(dataContentId, image),
details: new Map(prev.details).set(dataContentId, details),
loadingStatuses: setDataContentStatuses(prev.loadingStatuses, [dataContentId], 'loaded'),
}));
})
.catch((error: unknown) => {
if (mountIdRef.current !== mountId) {
return;
}
console.error(`Failed to load content image ${dataContentId}`, error);
setState((prev) => ({
...prev,
loadingStatuses: setDataContentStatuses(prev.loadingStatuses, [dataContentId], 'failed'),
}));
});
}
// The IDs are tracked via their key so an equal set does not re-run.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [envelopeId, requestedDataContentIdsKey, token, presignToken]);
useEffect(() => {
return () => {
mountIdRef.current += 1;
for (const image of imagesRef.current.values()) {
closeImage(image);
}
// Also reset the mirrors so a strict mode remount reloads everything
// rather than reusing closed images.
imagesRef.current = new Map();
loadingStatusesRef.current = new Map();
setState(createEmptyState());
};
}, []);
const setImage = useCallback((dataContentId: string, image: ContentImageSource, details: ContentImageDetails) => {
setState((prev) => ({
images: new Map(prev.images).set(dataContentId, image),
details: new Map(prev.details).set(dataContentId, details),
loadingStatuses: setDataContentStatuses(prev.loadingStatuses, [dataContentId], 'loaded'),
}));
}, []);
return useMemo(
() => ({
images: state.images,
details: state.details,
loadingStatuses: state.loadingStatuses,
/**
* The images being loaded, so consumers can tell an image which is
* still on its way from one which is never coming.
*/
requestedDataContentIds: new Set(requestedDataContentIds),
setImage,
}),
[state, setImage, requestedDataContentIdsKey],
);
};
/**
* A copy of the statuses with the given data contents set to the given
* status. The original is left untouched so it can be used as React state.
*/
const setDataContentStatuses = (
loadingStatuses: Map<string, ContentImageStatus>,
dataContentIds: string[],
status: ContentImageStatus,
) => {
const remappedLoadingStatuses = new Map(loadingStatuses);
for (const dataContentId of dataContentIds) {
remappedLoadingStatuses.set(dataContentId, status);
}
return remappedLoadingStatuses;
};
/**
* Release the memory of a decoded bitmap. Image elements have nothing to
* release.
*/
const closeImage = (image: ContentImageSource) => {
if ('close' in image) {
image.close();
}
};

Some files were not shown because too many files have changed in this diff Show More