mirror of
https://github.com/documenso/documenso.git
synced 2026-09-29 16:24:30 +10:00
feat: add content fields
This commit is contained in:
@@ -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,126 @@
|
||||
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 });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
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 })}
|
||||
>
|
||||
<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>
|
||||
|
||||
+7
-1
@@ -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">
|
||||
|
||||
+131
@@ -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>
|
||||
);
|
||||
};
|
||||
+423
@@ -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>
|
||||
);
|
||||
};
|
||||
+169
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+40
@@ -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>
|
||||
);
|
||||
};
|
||||
+34
@@ -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,65 @@
|
||||
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[];
|
||||
|
||||
/**
|
||||
* Whether the selection was made programmatically (e.g. on creation) rather
|
||||
* than by the user.
|
||||
*/
|
||||
isAuto: boolean;
|
||||
} | 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;
|
||||
};
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
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[], options?: { isAuto?: boolean }) => void;
|
||||
clear: () => void;
|
||||
|
||||
/**
|
||||
* Whether a selection made by this sync counts as automatic.
|
||||
*/
|
||||
isAuto: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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,
|
||||
isAuto,
|
||||
}: 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], { isAuto });
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (editorFormId === null && isSingleSelection) {
|
||||
clear();
|
||||
}
|
||||
};
|
||||
+454
@@ -0,0 +1,454 @@
|
||||
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 {
|
||||
getClientSideContentTranslations,
|
||||
getLocalContentOrderId,
|
||||
sortContentsForRender,
|
||||
} from '@documenso/lib/utils/envelope-content';
|
||||
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,
|
||||
isAuto: false,
|
||||
});
|
||||
}
|
||||
|
||||
applyPageItemsVisibility();
|
||||
|
||||
// Contents may have changed in a way which affects the transformer, e.g.
|
||||
// an image attached to the selected content locks its ratio.
|
||||
selection.refreshTransformerConfig();
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
||||
+475
@@ -0,0 +1,475 @@
|
||||
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);
|
||||
}
|
||||
|
||||
// Newly created fields are auto selected, which suppresses the action bar
|
||||
// so it can't intercept the next placement click.
|
||||
syncEditorSelectionToCanvas({
|
||||
layer,
|
||||
kind: 'field',
|
||||
editorFormId: editorFields.selectedField?.formId ?? null,
|
||||
isOnPage,
|
||||
selectedGroups,
|
||||
select: selection.select,
|
||||
clear: selection.clear,
|
||||
isAuto: true,
|
||||
});
|
||||
|
||||
applyPageItemsVisibility();
|
||||
|
||||
selection.refreshTransformerConfig();
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
||||
+195
@@ -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 };
|
||||
};
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
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),
|
||||
});
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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 (selectionRectangle.visible() && selectionRectangle.width() > 0 && selectionRectangle.height() > 0) {
|
||||
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()),
|
||||
);
|
||||
};
|
||||
+88
@@ -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 };
|
||||
};
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
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 SelectOptions = {
|
||||
isAuto?: boolean;
|
||||
};
|
||||
|
||||
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-resolve the transformer configuration for the current selection, for
|
||||
* when the selected items change in a way which affects it (e.g. an image
|
||||
* being attached to a selected content) without the selection changing.
|
||||
*/
|
||||
const refreshTransformerConfig = useCallback(() => {
|
||||
const transformer = transformerRef.current;
|
||||
|
||||
if (!transformer) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyTransformerConfig(transformer, selectionRef.current);
|
||||
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();
|
||||
}
|
||||
|
||||
setSelectionState(nextSelection);
|
||||
onChangeRef.current(nextSelection);
|
||||
}, []);
|
||||
|
||||
const select = useCallback(
|
||||
(kind: EnvelopeCanvasSelectionKind, nodes: Konva.Node[], options?: SelectOptions) => {
|
||||
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, isAuto: Boolean(options?.isAuto) } : 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, { isAuto: currentSelection.isAuto });
|
||||
},
|
||||
[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,
|
||||
shouldOverdrawWholeArea: true,
|
||||
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,
|
||||
refreshTransformerConfig,
|
||||
};
|
||||
};
|
||||
|
||||
export type EnvelopeCanvasSelectionApi = ReturnType<typeof useEnvelopeCanvasSelection>;
|
||||
@@ -0,0 +1,270 @@
|
||||
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 { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
|
||||
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';
|
||||
|
||||
/**
|
||||
* 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 } = useCurrentEnvelopeEditor();
|
||||
const { contentImages, pageSizes } = useCurrentEnvelopeRender();
|
||||
|
||||
const { mutateAsync: uploadImage } = trpc.envelope.content.uploadImage.useMutation();
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [status, setStatus] = useState<UploadStatus>(droppedFile ? 'uploading' : 'picking');
|
||||
|
||||
const upload = async (file: File) => {
|
||||
setStatus('uploading');
|
||||
|
||||
try {
|
||||
// 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 without another
|
||||
// request.
|
||||
const image = await createImageBitmap(file, {
|
||||
imageOrientation: 'from-image',
|
||||
resizeWidth: dataContent.metadata.width,
|
||||
resizeHeight: dataContent.metadata.height,
|
||||
resizeQuality: 'high',
|
||||
}).catch(() => null);
|
||||
|
||||
if (image) {
|
||||
contentImages.setImage(dataContent.id, image, {
|
||||
fileSize: dataContent.metadata.fileSize,
|
||||
});
|
||||
}
|
||||
|
||||
// Re-read in case the content moved while the upload was in flight.
|
||||
const latest = editorContents.getContentByFormId(formId);
|
||||
|
||||
if (latest && latest.contentMeta.type === EnvelopeContentType.IMAGE) {
|
||||
const { contentMeta } = latest;
|
||||
const page = pageSizes.get(latest.envelopeItemId, contentMeta.page);
|
||||
|
||||
const box = page
|
||||
? resolveAttachedImageBox({
|
||||
image: dataContent.metadata,
|
||||
page,
|
||||
box: {
|
||||
positionX: contentMeta.positionX,
|
||||
positionY: contentMeta.positionY,
|
||||
width: contentMeta.width,
|
||||
height: contentMeta.height,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
editorContents.updateContentByFormId(formId, {
|
||||
dataContentId: dataContent.id,
|
||||
contentMeta: box ? { ...contentMeta, ...box } : contentMeta,
|
||||
});
|
||||
}
|
||||
|
||||
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.
|
||||
const hasStartedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasStartedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasStartedRef.current = true;
|
||||
|
||||
if (droppedFile) {
|
||||
void upload(droppedFile);
|
||||
} else {
|
||||
inputRef.current?.click();
|
||||
}
|
||||
}, []);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Reject what the server would reject before uploading it, so the
|
||||
// picker path gets the same feedback as a drop.
|
||||
if (file.size > megabytesToBytes(APP_CONTENT_IMAGE_UPLOAD_SIZE_LIMIT)) {
|
||||
setStatus('too-large');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!APP_CONTENT_IMAGE_MIME_TYPES.some((accepted) => accepted === file.type)) {
|
||||
setStatus('invalid-image');
|
||||
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),
|
||||
};
|
||||
};
|
||||
+401
@@ -0,0 +1,401 @@
|
||||
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 } = 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);
|
||||
|
||||
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();
|
||||
};
|
||||
+205
-875
File diff suppressed because it is too large
Load Diff
+459
-313
@@ -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,42 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
|
||||
const scrollableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { envelope, editorFields, navigateToStep, editorConfig } = useCurrentEnvelopeEditor();
|
||||
const {
|
||||
envelope,
|
||||
editorFields,
|
||||
editorContents,
|
||||
navigateToStep,
|
||||
editorConfig,
|
||||
selectedEditorTab,
|
||||
setSelectedEditorTab,
|
||||
isEmbedded,
|
||||
} = useCurrentEnvelopeEditor();
|
||||
|
||||
const { currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
const { currentEnvelopeItem, setCurrentEnvelopeItem, viewerControls } = useCurrentEnvelopeRender();
|
||||
|
||||
/**
|
||||
* Todo: Contents
|
||||
*
|
||||
* The embedded editor persists through the public envelope API, which does
|
||||
* not carry contents yet, so authoring them there would silently lose them.
|
||||
*/
|
||||
const isContentsTabAvailable = !isEmbedded;
|
||||
|
||||
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 +151,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
|
||||
@@ -203,318 +263,404 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
};
|
||||
|
||||
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-4">
|
||||
<Tabs value={selectedEditorTab} onValueChange={(value) => onEditorTabChange(value as EnvelopeEditorTab)}>
|
||||
<TabsList
|
||||
className={cn('-mt-4 flex w-full flex-row border-b text-muted-foreground text-sm', {
|
||||
hidden: !isContentsTabAvailable,
|
||||
})}
|
||||
>
|
||||
<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>
|
||||
|
||||
{isContentsTabAvailable && (
|
||||
<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">
|
||||
|
||||
+1
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -273,12 +280,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 +341,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);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+64
-3
@@ -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}
|
||||
|
||||
+33
-1
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -377,6 +377,7 @@ const EnvelopeCreatePage = ({ embedAuthoringOptions }: EnvelopeCreatePageProps)
|
||||
},
|
||||
recipients,
|
||||
fields: [],
|
||||
contents: [],
|
||||
envelopeItems: [],
|
||||
directLink: null,
|
||||
team: {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,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,296 @@
|
||||
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 { DataContentType } from '@documenso/lib/types/data-content-meta';
|
||||
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'));
|
||||
|
||||
/**
|
||||
* 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 since they are not part of the public API.
|
||||
* Image contents are given an image so the send is not rejected for the
|
||||
* unrelated reason of an image content having no image.
|
||||
*/
|
||||
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 prisma.dataContent.create({
|
||||
data: {
|
||||
id: generateDatabaseId('data'),
|
||||
type: 'BYTES_64',
|
||||
data: 'aW1hZ2U=',
|
||||
metadata: {
|
||||
type: DataContentType.IMAGE,
|
||||
width: 10,
|
||||
height: 10,
|
||||
mimeType: 'image/png',
|
||||
fileName: 'image.png',
|
||||
fileSize: 5,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
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,86 @@
|
||||
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 auto selected, which deliberately suppresses
|
||||
// the action bar, so click it to make it a real selection.
|
||||
await selectContentOnCanvas(root, { x: 160, y: 160 });
|
||||
|
||||
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,267 @@
|
||||
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,
|
||||
waitForContentsAutosave,
|
||||
} 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 root
|
||||
.locator('input[id^="content-image-input-"]')
|
||||
.setInputFiles({ name: 'image.png', mimeType: 'image/png', buffer: png });
|
||||
|
||||
await waitForContentsAutosave(surface);
|
||||
await expect.poll(async () => (await getContentRows(envelopeId)).length).toBe(1);
|
||||
|
||||
const { firstContentSetInFlight } = await installContentSetLag(page, 4000);
|
||||
|
||||
// Duplicating clones the image server side, so the response carries a
|
||||
// different data content id than was sent.
|
||||
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 their own image, and neither points at a deleted row.
|
||||
const dataContentIds = rows.flatMap((row) => (row.dataContentId ? [row.dataContentId] : []));
|
||||
|
||||
expect(new Set(dataContentIds).size).toBe(2);
|
||||
|
||||
const dataContents = await prisma.dataContent.findMany({ where: { id: { in: dataContentIds } } });
|
||||
|
||||
expect(dataContents).toHaveLength(2);
|
||||
});
|
||||
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
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('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,439 @@
|
||||
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,
|
||||
placeContentOnPdf,
|
||||
selectEditorTab,
|
||||
waitForContentsAutosave,
|
||||
} 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 };
|
||||
|
||||
/**
|
||||
* Generate a solid color image of the given size in memory.
|
||||
*/
|
||||
const createImageFile = async (
|
||||
name: string,
|
||||
width: number,
|
||||
height: number,
|
||||
format: 'png' | 'jpeg' | 'webp' = 'png',
|
||||
) => {
|
||||
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 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();
|
||||
};
|
||||
|
||||
type TestImageFile = { name: string; mimeType: string; buffer: Buffer };
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
const waitForUploadToLand = async (root: Page) => {
|
||||
await expect(root.getByTestId('content-image-uploading')).toHaveCount(0);
|
||||
await expect(root.getByRole('dialog')).toHaveCount(0);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,695 @@
|
||||
import { nanoid } 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 { expect, type Page, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import {
|
||||
getContentActionButton,
|
||||
getContentCountForPage,
|
||||
getContentGroupsForPage,
|
||||
getFieldCountForPage,
|
||||
getPageCanvas,
|
||||
getPageSize,
|
||||
interactWithCanvasPastActionBar,
|
||||
placeContentOnPdf,
|
||||
selectContentOnCanvas,
|
||||
selectEditorTab,
|
||||
waitForContentSelection,
|
||||
waitForContentsAutosave,
|
||||
} from '../fixtures/contents';
|
||||
import {
|
||||
clickAddMyselfButton,
|
||||
clickEnvelopeEditorStep,
|
||||
getEnvelopeEditorSettingsTrigger,
|
||||
openDocumentEnvelopeEditor,
|
||||
openEmbeddedEnvelopeEditor,
|
||||
openTemplateEnvelopeEditor,
|
||||
type TEnvelopeEditorSurface,
|
||||
} from '../fixtures/envelope-editor';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
import { getKonvaTransformerNodeCountForPage } from '../fixtures/konva';
|
||||
|
||||
/**
|
||||
* Contents are only authored on the native document and template surfaces.
|
||||
* The embedded editor persists through the public envelope API, which does
|
||||
* not carry contents yet, so the tab is hidden there (see the embedded test).
|
||||
*/
|
||||
|
||||
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();
|
||||
};
|
||||
|
||||
/**
|
||||
* Set up the editor on the fields step with the contents tab open.
|
||||
*/
|
||||
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();
|
||||
};
|
||||
|
||||
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('Content meta is not an object');
|
||||
}
|
||||
|
||||
return metadata;
|
||||
};
|
||||
|
||||
// --- Place and persist one of each content type ---
|
||||
|
||||
const runPlaceAllContentTypesFlow = async (surface: TEnvelopeEditorSurface) => {
|
||||
const externalId = `e2e-contents-${nanoid()}`;
|
||||
const root = surface.root;
|
||||
|
||||
await openContentsTab(surface, externalId);
|
||||
|
||||
await placeContentOnPdf(root, 'Text', { x: 120, y: 120 });
|
||||
await placeContentOnPdf(root, 'Line', { x: 320, y: 120 });
|
||||
await placeContentOnPdf(root, 'Rectangle', { x: 120, y: 260 });
|
||||
await placeContentOnPdf(root, 'Highlight', { x: 320, y: 260 });
|
||||
await placeContentOnPdf(root, 'Image', { x: 120, y: 420 });
|
||||
|
||||
expect(await getContentCountForPage(root)).toBe(5);
|
||||
|
||||
await waitForContentsAutosave(surface);
|
||||
|
||||
// Navigate away and back to verify the contents are reloaded.
|
||||
await clickEnvelopeEditorStep(root, 'upload');
|
||||
await expect(root.getByRole('heading', { name: 'Recipients' })).toBeVisible();
|
||||
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
await expect(getPageCanvas(root)).toBeVisible();
|
||||
await selectEditorTab(root, 'Contents');
|
||||
|
||||
expect(await getContentCountForPage(root)).toBe(5);
|
||||
|
||||
return { externalId };
|
||||
};
|
||||
|
||||
const assertAllContentTypesPersisted = async (surface: TEnvelopeEditorSurface, externalId: string) => {
|
||||
const envelope = await findEnvelopeWithContents(surface, externalId);
|
||||
|
||||
expect(envelope.contents).toHaveLength(5);
|
||||
|
||||
const types = envelope.contents.map((content) => getMeta(content.contentMeta).type).sort();
|
||||
|
||||
expect(types).toEqual(['highlight', 'image', 'line', 'shape', 'text']);
|
||||
|
||||
const shape = envelope.contents.find((content) => getMeta(content.contentMeta).type === 'shape');
|
||||
|
||||
expect(getMeta(shape?.contentMeta).shape).toBe('rectangle');
|
||||
|
||||
for (const content of envelope.contents) {
|
||||
const meta = getMeta(content.contentMeta);
|
||||
|
||||
expect(meta.page).toBe(1);
|
||||
|
||||
if (meta.type === 'line') {
|
||||
for (const key of ['x1', 'x2', 'y1', 'y2']) {
|
||||
expect(meta[key]).toBeGreaterThanOrEqual(0);
|
||||
expect(meta[key]).toBeLessThanOrEqual(100);
|
||||
}
|
||||
} else {
|
||||
expect(Number(meta.positionX) + Number(meta.width)).toBeLessThanOrEqual(100);
|
||||
expect(Number(meta.positionY) + Number(meta.height)).toBeLessThanOrEqual(100);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// --- Edit settings via the sidebar ---
|
||||
|
||||
const SIDEBAR_VALUES = {
|
||||
text: 'Edited from the sidebar',
|
||||
fontSize: '18',
|
||||
strokeWidth: '3',
|
||||
};
|
||||
|
||||
const runSidebarSettingsFlow = async (surface: TEnvelopeEditorSurface) => {
|
||||
const externalId = `e2e-contents-sidebar-${nanoid()}`;
|
||||
const root = surface.root;
|
||||
|
||||
await openContentsTab(surface, externalId);
|
||||
|
||||
// Text: the content stays selected after being placed.
|
||||
await placeContentOnPdf(root, 'Text', { x: 150, y: 150 });
|
||||
await expect(root.getByRole('heading', { name: 'Text Settings' })).toBeVisible();
|
||||
|
||||
await root.getByTestId('content-form-text').fill(SIDEBAR_VALUES.text);
|
||||
await root.getByTestId('field-form-fontSize').fill(SIDEBAR_VALUES.fontSize);
|
||||
|
||||
await root.getByTestId('field-form-textAlign').click();
|
||||
await root.getByRole('option', { name: 'Center' }).click();
|
||||
|
||||
// Line.
|
||||
await placeContentOnPdf(root, 'Line', { x: 300, y: 300 });
|
||||
await expect(root.getByRole('heading', { name: 'Line Settings' })).toBeVisible();
|
||||
|
||||
await root.getByTestId('content-form-strokeWidth').fill(SIDEBAR_VALUES.strokeWidth);
|
||||
await root.getByTestId('content-form-strokeStyle').click();
|
||||
await root.getByRole('option', { name: 'Dashed' }).click();
|
||||
|
||||
// Rectangle: enable the fill.
|
||||
await placeContentOnPdf(root, 'Rectangle', { x: 150, y: 450 });
|
||||
await expect(root.getByRole('heading', { name: 'Shape Settings' })).toBeVisible();
|
||||
|
||||
await root.getByTestId('content-form-fill').click();
|
||||
await expect(root.getByText('Fill Color')).toBeVisible();
|
||||
|
||||
await waitForContentsAutosave(surface);
|
||||
|
||||
return { externalId };
|
||||
};
|
||||
|
||||
const assertSidebarSettingsPersisted = async (surface: TEnvelopeEditorSurface, externalId: string) => {
|
||||
const envelope = await findEnvelopeWithContents(surface, externalId);
|
||||
|
||||
const text = envelope.contents.find((content) => getMeta(content.contentMeta).type === 'text');
|
||||
const line = envelope.contents.find((content) => getMeta(content.contentMeta).type === 'line');
|
||||
const shape = envelope.contents.find((content) => getMeta(content.contentMeta).type === 'shape');
|
||||
|
||||
expect(getMeta(text?.contentMeta)).toMatchObject({
|
||||
text: SIDEBAR_VALUES.text,
|
||||
fontSize: Number(SIDEBAR_VALUES.fontSize),
|
||||
textAlign: 'center',
|
||||
});
|
||||
|
||||
expect(getMeta(line?.contentMeta)).toMatchObject({
|
||||
strokeWidth: Number(SIDEBAR_VALUES.strokeWidth),
|
||||
strokeStyle: 'dashed',
|
||||
});
|
||||
|
||||
expect(typeof getMeta(shape?.contentMeta).fillColor).toBe('string');
|
||||
};
|
||||
|
||||
// --- Edit styles via the floating action bar ---
|
||||
|
||||
const ACTION_BAR_VALUES = {
|
||||
textColor: '#ff0000',
|
||||
borderColor: '#0000ff',
|
||||
};
|
||||
|
||||
/**
|
||||
* Open a color action from the bar and type a hex value into its picker.
|
||||
*/
|
||||
const setActionBarColor = async (root: Page, title: string, hex: string) => {
|
||||
await getContentActionButton(root, title).click();
|
||||
|
||||
const hexInput = root.locator('[role="dialog"] input, [data-radix-popper-content-wrapper] input').last();
|
||||
|
||||
await expect(hexInput).toBeVisible();
|
||||
await hexInput.fill(hex.replace('#', ''));
|
||||
await hexInput.press('Enter');
|
||||
|
||||
await root.keyboard.press('Escape');
|
||||
};
|
||||
|
||||
const runActionBarStylesFlow = async (surface: TEnvelopeEditorSurface) => {
|
||||
const externalId = `e2e-contents-bar-${nanoid()}`;
|
||||
const root = surface.root;
|
||||
|
||||
await openContentsTab(surface, externalId);
|
||||
|
||||
await placeContentOnPdf(root, 'Text', { x: 150, y: 150 });
|
||||
await expect(getContentActionButton(root, 'Text color')).toBeVisible();
|
||||
|
||||
await setActionBarColor(root, 'Text color', ACTION_BAR_VALUES.textColor);
|
||||
|
||||
await getContentActionButton(root, 'Text align').click();
|
||||
await root.getByRole('button', { name: 'Right', exact: true }).click();
|
||||
await root.keyboard.press('Escape');
|
||||
|
||||
await getContentActionButton(root, 'Vertical align').click();
|
||||
await root.getByRole('button', { name: 'Bottom', exact: true }).click();
|
||||
await root.keyboard.press('Escape');
|
||||
|
||||
// The sidebar reflects the bar's changes.
|
||||
await expect(root.getByText(ACTION_BAR_VALUES.textColor.toUpperCase())).toBeVisible();
|
||||
await expect(root.getByTestId('field-form-textAlign')).toContainText('Right');
|
||||
|
||||
await placeContentOnPdf(root, 'Rectangle', { x: 150, y: 400 });
|
||||
await expect(getContentActionButton(root, 'Border color')).toBeVisible();
|
||||
|
||||
await setActionBarColor(root, 'Border color', ACTION_BAR_VALUES.borderColor);
|
||||
|
||||
// Only single selections get style actions.
|
||||
await selectContentOnCanvas(root, { x: 150, y: 150 }, { shift: true });
|
||||
expect(await getKonvaTransformerNodeCountForPage(root, 1)).toBe(2);
|
||||
await expect(getContentActionButton(root, 'Text color')).toHaveCount(0);
|
||||
await expect(getContentActionButton(root, 'Border color')).toHaveCount(0);
|
||||
await expect(getContentActionButton(root, 'Duplicate')).toBeVisible();
|
||||
|
||||
await waitForContentsAutosave(surface);
|
||||
|
||||
return { externalId };
|
||||
};
|
||||
|
||||
const assertActionBarStylesPersisted = async (surface: TEnvelopeEditorSurface, externalId: string) => {
|
||||
const envelope = await findEnvelopeWithContents(surface, externalId);
|
||||
|
||||
const text = envelope.contents.find((content) => getMeta(content.contentMeta).type === 'text');
|
||||
const shape = envelope.contents.find((content) => getMeta(content.contentMeta).type === 'shape');
|
||||
|
||||
expect(getMeta(text?.contentMeta)).toMatchObject({
|
||||
color: ACTION_BAR_VALUES.textColor,
|
||||
textAlign: 'right',
|
||||
verticalAlign: 'bottom',
|
||||
});
|
||||
|
||||
expect(getMeta(shape?.contentMeta)).toMatchObject({
|
||||
strokeColor: ACTION_BAR_VALUES.borderColor,
|
||||
});
|
||||
};
|
||||
|
||||
// --- Duplicate and remove via the action bar ---
|
||||
|
||||
const runDuplicateAndRemoveFlow = async (surface: TEnvelopeEditorSurface) => {
|
||||
const externalId = `e2e-contents-actions-${nanoid()}`;
|
||||
const root = surface.root;
|
||||
|
||||
await openContentsTab(surface, externalId);
|
||||
|
||||
await placeContentOnPdf(root, 'Rectangle', { x: 150, y: 150 });
|
||||
expect(await getContentCountForPage(root)).toBe(1);
|
||||
|
||||
const [original] = await getContentGroupsForPage(root);
|
||||
|
||||
await getContentActionButton(root, 'Duplicate').click();
|
||||
expect(await getContentCountForPage(root)).toBe(2);
|
||||
|
||||
// The duplicate is offset from the original so it does not fully overlap.
|
||||
const groups = await getContentGroupsForPage(root);
|
||||
const duplicate = groups.find((group) => group.id !== original.id);
|
||||
|
||||
expect(duplicate).toBeDefined();
|
||||
expect(duplicate?.rect.x).toBeGreaterThan(original.rect.x);
|
||||
expect(duplicate?.rect.y).toBeGreaterThan(original.rect.y);
|
||||
|
||||
await waitForContentsAutosave(surface);
|
||||
|
||||
const afterDuplicate = await findEnvelopeWithContents(surface, externalId);
|
||||
|
||||
expect(afterDuplicate.contents).toHaveLength(2);
|
||||
|
||||
// Multi-select both via shift click and remove them together. The
|
||||
// duplicate is offset down and right of the original and overlaps it, so
|
||||
// each is clicked where only it is. Points sit at the top edge of their
|
||||
// box: the selected content's floating action bar hangs just below its
|
||||
// bottom edge, and a click near there would hit the bar's buttons instead.
|
||||
const { scale } = await getPageSize(root);
|
||||
|
||||
if (!duplicate) {
|
||||
throw new Error('Duplicate not found');
|
||||
}
|
||||
|
||||
const duplicateOnly = {
|
||||
x: (duplicate.rect.x + duplicate.rect.width - 4) * scale,
|
||||
y: (duplicate.rect.y + 4) * scale,
|
||||
};
|
||||
|
||||
const originalOnly = {
|
||||
x: (original.rect.x + 4) * scale,
|
||||
y: (original.rect.y + 4) * scale,
|
||||
};
|
||||
|
||||
await selectContentOnCanvas(root, duplicateOnly);
|
||||
await selectContentOnCanvas(root, originalOnly, { shift: true });
|
||||
expect(await getKonvaTransformerNodeCountForPage(root, 1)).toBe(2);
|
||||
|
||||
await getContentActionButton(root, 'Remove').click();
|
||||
expect(await getContentCountForPage(root)).toBe(0);
|
||||
|
||||
await waitForContentsAutosave(surface);
|
||||
|
||||
return { externalId };
|
||||
};
|
||||
|
||||
const assertContentsRemoved = async (surface: TEnvelopeEditorSurface, externalId: string) => {
|
||||
const envelope = await findEnvelopeWithContents(surface, externalId);
|
||||
|
||||
expect(envelope.contents).toHaveLength(0);
|
||||
};
|
||||
|
||||
// --- Fields auto hide on the contents tab ---
|
||||
|
||||
const runFieldsAutoHideFlow = async (surface: TEnvelopeEditorSurface) => {
|
||||
const externalId = `e2e-contents-hide-${nanoid()}`;
|
||||
const root = surface.root;
|
||||
|
||||
await updateExternalId(surface, externalId);
|
||||
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: 150, y: 150 } });
|
||||
expect(await getFieldCountForPage(root)).toBe(1);
|
||||
|
||||
/**
|
||||
* Whether every field group on the page satisfies the given Konva check.
|
||||
*/
|
||||
const everyFieldGroup = async (check: 'isVisible' | 'isListening') => {
|
||||
const results = await root.evaluate(
|
||||
({ check }) => {
|
||||
// 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').map((node) => node[check]()) ?? [];
|
||||
},
|
||||
{ check },
|
||||
);
|
||||
|
||||
return results.length > 0 && results.every(Boolean);
|
||||
};
|
||||
|
||||
expect(await everyFieldGroup('isVisible')).toBe(true);
|
||||
|
||||
await selectEditorTab(root, 'Contents');
|
||||
expect(await everyFieldGroup('isVisible')).toBe(false);
|
||||
await expect(root.locator('button[title="Show fields"]')).toBeVisible();
|
||||
|
||||
await selectEditorTab(root, 'Fields');
|
||||
expect(await everyFieldGroup('isVisible')).toBe(true);
|
||||
await expect(root.locator('button[title="Hide fields"]')).toBeVisible();
|
||||
|
||||
// Showing the fields from the toolbar while on the contents tab mutes them
|
||||
// (visible but not interactive). Hiding them again and returning to the
|
||||
// fields tab must hand the interactivity back.
|
||||
await selectEditorTab(root, 'Contents');
|
||||
await root.locator('button[title="Show fields"]').click();
|
||||
await expect(root.locator('button[title="Hide fields"]')).toBeVisible();
|
||||
expect(await everyFieldGroup('isListening')).toBe(false);
|
||||
|
||||
await root.locator('button[title="Hide fields"]').click();
|
||||
await expect(root.locator('button[title="Show fields"]')).toBeVisible();
|
||||
|
||||
await selectEditorTab(root, 'Fields');
|
||||
expect(await everyFieldGroup('isVisible')).toBe(true);
|
||||
expect(await everyFieldGroup('isListening')).toBe(true);
|
||||
|
||||
// Hiding the contents from the toolbar is an override for the current tab
|
||||
// only. Switching tabs in either direction resets contents to visible.
|
||||
await root.locator('button[title="Hide contents"]').click();
|
||||
await expect(root.locator('button[title="Show contents"]')).toBeVisible();
|
||||
|
||||
await selectEditorTab(root, 'Contents');
|
||||
await expect(root.locator('button[title="Hide contents"]')).toBeVisible();
|
||||
|
||||
await root.locator('button[title="Hide contents"]').click();
|
||||
await expect(root.locator('button[title="Show contents"]')).toBeVisible();
|
||||
|
||||
await selectEditorTab(root, 'Fields');
|
||||
await expect(root.locator('button[title="Hide contents"]')).toBeVisible();
|
||||
await expect(root.locator('button[title="Hide fields"]')).toBeVisible();
|
||||
|
||||
// And the field can actually be selected by clicking it.
|
||||
const fieldRect = 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');
|
||||
const group = stage?.findOne('.field-group');
|
||||
|
||||
if (!stage || !group) {
|
||||
throw new Error('Field group not found');
|
||||
}
|
||||
|
||||
const rect = group.getClientRect();
|
||||
|
||||
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
|
||||
});
|
||||
|
||||
await getPageCanvas(root).click({ position: fieldRect });
|
||||
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(1);
|
||||
};
|
||||
|
||||
// --- Resize is pinned to the page ---
|
||||
|
||||
const runResizePinnedToPageFlow = async (surface: TEnvelopeEditorSurface) => {
|
||||
const externalId = `e2e-contents-resize-${nanoid()}`;
|
||||
const root = surface.root;
|
||||
|
||||
await openContentsTab(surface, externalId);
|
||||
|
||||
await placeContentOnPdf(root, 'Rectangle', { x: 200, y: 200 });
|
||||
|
||||
const canvas = getPageCanvas(root);
|
||||
const box = await canvas.boundingBox();
|
||||
|
||||
if (!box) {
|
||||
throw new Error('Canvas bounding box not available');
|
||||
}
|
||||
|
||||
const [group] = await getContentGroupsForPage(root);
|
||||
const { scale } = await getPageSize(root);
|
||||
|
||||
// The anchors only exist once the placed content is selected.
|
||||
await waitForContentSelection(root, [group.id]);
|
||||
|
||||
// Grab the bottom right anchor and drag it well past the page's bottom
|
||||
// right corner. The mouse leaves the page, the box must not.
|
||||
const anchorX = box.x + (group.rect.x + group.rect.width) * scale;
|
||||
const anchorY = box.y + (group.rect.y + group.rect.height) * scale;
|
||||
|
||||
await interactWithCanvasPastActionBar(root, async () => {
|
||||
await root.mouse.move(anchorX, anchorY);
|
||||
await root.mouse.down();
|
||||
await root.mouse.move(box.x + box.width + 200, box.y + box.height + 200, { steps: 10 });
|
||||
await root.mouse.up();
|
||||
});
|
||||
|
||||
await waitForContentsAutosave(surface);
|
||||
|
||||
return { externalId };
|
||||
};
|
||||
|
||||
const assertResizePinnedToPage = async (surface: TEnvelopeEditorSurface, externalId: string) => {
|
||||
const envelope = await findEnvelopeWithContents(surface, externalId);
|
||||
|
||||
expect(envelope.contents).toHaveLength(1);
|
||||
|
||||
const meta = getMeta(envelope.contents[0].contentMeta);
|
||||
|
||||
// The box grew in both directions and stopped at the page edges. The pixel
|
||||
// to percent conversion leaves floating point noise, hence the tolerance.
|
||||
expect(Number(meta.positionX) + Number(meta.width)).toBeCloseTo(100, 3);
|
||||
expect(Number(meta.positionY) + Number(meta.height)).toBeCloseTo(100, 3);
|
||||
};
|
||||
|
||||
// --- Selecting brings to front ---
|
||||
|
||||
const runSelectBringsToFrontFlow = async (surface: TEnvelopeEditorSurface) => {
|
||||
const externalId = `e2e-contents-zindex-${nanoid()}`;
|
||||
const root = surface.root;
|
||||
|
||||
await openContentsTab(surface, externalId);
|
||||
|
||||
// Two overlapping rectangles. The second is created on top.
|
||||
await placeContentOnPdf(root, 'Rectangle', { x: 200, y: 200 });
|
||||
await placeContentOnPdf(root, 'Rectangle', { x: 230, y: 220 });
|
||||
|
||||
const [first, second] = await getContentGroupsForPage(root);
|
||||
|
||||
// Konva renders children in order, so the later child is on top.
|
||||
const orderAfterPlacing = await getContentGroupOrder(root);
|
||||
|
||||
expect(orderAfterPlacing.indexOf(second.id)).toBeGreaterThan(orderAfterPlacing.indexOf(first.id));
|
||||
|
||||
// Select the first where the second does not cover it: it comes to the front.
|
||||
const { scale } = await getPageSize(root);
|
||||
|
||||
await selectContentOnCanvas(root, { x: (first.rect.x + 4) * scale, y: (first.rect.y + 4) * scale });
|
||||
|
||||
const orderAfterSelecting = await getContentGroupOrder(root);
|
||||
|
||||
expect(orderAfterSelecting.indexOf(first.id)).toBeGreaterThan(orderAfterSelecting.indexOf(second.id));
|
||||
|
||||
await waitForContentsAutosave(surface);
|
||||
|
||||
return { externalId };
|
||||
};
|
||||
|
||||
/**
|
||||
* The content group ids of a page in their current Konva child order.
|
||||
*/
|
||||
const getContentGroupOrder = async (root: Page) => (await getContentGroupsForPage(root)).map((group) => group.id);
|
||||
|
||||
const assertSelectedContentPersistedOnTop = async (surface: TEnvelopeEditorSurface, externalId: string) => {
|
||||
const envelope = await findEnvelopeWithContents(surface, externalId);
|
||||
|
||||
expect(envelope.contents).toHaveLength(2);
|
||||
|
||||
// The first rectangle was placed further left, so it is the one at the
|
||||
// smaller x. Having been selected, it must now be above the second.
|
||||
const [first, second] = [...envelope.contents].sort(
|
||||
(a, b) => Number(getMeta(a.contentMeta).positionX) - Number(getMeta(b.contentMeta).positionX),
|
||||
);
|
||||
|
||||
expect(first.contentMeta.zIndex).toBeGreaterThan(second.contentMeta.zIndex);
|
||||
};
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
test.describe('document editor', () => {
|
||||
test('place and persist one of each content type', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const { externalId } = await runPlaceAllContentTypesFlow(surface);
|
||||
|
||||
await assertAllContentTypesPersisted(surface, externalId);
|
||||
});
|
||||
|
||||
test('edit content settings via the sidebar', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const { externalId } = await runSidebarSettingsFlow(surface);
|
||||
|
||||
await assertSidebarSettingsPersisted(surface, externalId);
|
||||
});
|
||||
|
||||
test('edit content styles via the canvas action bar', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const { externalId } = await runActionBarStylesFlow(surface);
|
||||
|
||||
await assertActionBarStylesPersisted(surface, externalId);
|
||||
});
|
||||
|
||||
test('duplicate and remove contents via the canvas action bar', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const { externalId } = await runDuplicateAndRemoveFlow(surface);
|
||||
|
||||
await assertContentsRemoved(surface, externalId);
|
||||
});
|
||||
|
||||
test('fields are hidden while the contents tab is active', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
|
||||
await runFieldsAutoHideFlow(surface);
|
||||
});
|
||||
|
||||
test('resizing a content is pinned to the page bounds', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const { externalId } = await runResizePinnedToPageFlow(surface);
|
||||
|
||||
await assertResizePinnedToPage(surface, externalId);
|
||||
});
|
||||
|
||||
test('selecting a content brings it to the front', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const { externalId } = await runSelectBringsToFrontFlow(surface);
|
||||
|
||||
await assertSelectedContentPersistedOnTop(surface, externalId);
|
||||
});
|
||||
|
||||
test('contents cannot be added once the document has been sent', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const document = await seedPendingDocument(user, team.id, [user], { internalVersion: 2 });
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents/${document.id}/edit?step=addFields`,
|
||||
});
|
||||
|
||||
await expect(getPageCanvas(page)).toBeVisible();
|
||||
await selectEditorTab(page, 'Contents');
|
||||
|
||||
await expect(page.getByRole('alert').filter({ hasText: 'Content cannot be changed' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Rectangle', exact: true })).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('template editor', () => {
|
||||
test('place and persist one of each content type', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
const { externalId } = await runPlaceAllContentTypesFlow(surface);
|
||||
|
||||
await assertAllContentTypesPersisted(surface, externalId);
|
||||
});
|
||||
|
||||
test('edit content settings via the sidebar', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
const { externalId } = await runSidebarSettingsFlow(surface);
|
||||
|
||||
await assertSidebarSettingsPersisted(surface, externalId);
|
||||
});
|
||||
|
||||
test('edit content styles via the canvas action bar', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
const { externalId } = await runActionBarStylesFlow(surface);
|
||||
|
||||
await assertActionBarStylesPersisted(surface, externalId);
|
||||
});
|
||||
|
||||
test('duplicate and remove contents via the canvas action bar', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
const { externalId } = await runDuplicateAndRemoveFlow(surface);
|
||||
|
||||
await assertContentsRemoved(surface, externalId);
|
||||
});
|
||||
|
||||
test('fields are hidden while the contents tab is active', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
|
||||
await runFieldsAutoHideFlow(surface);
|
||||
});
|
||||
|
||||
test('resizing a content is pinned to the page bounds', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
const { externalId } = await runResizePinnedToPageFlow(surface);
|
||||
|
||||
await assertResizePinnedToPage(surface, externalId);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('embedded editor', () => {
|
||||
test('contents tab is not available', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'DOCUMENT',
|
||||
mode: 'edit',
|
||||
tokenNamePrefix: 'e2e-embed-contents',
|
||||
});
|
||||
|
||||
await clickEnvelopeEditorStep(surface.root, 'addFields');
|
||||
await expect(getPageCanvas(surface.root)).toBeVisible();
|
||||
|
||||
await expect(surface.root.getByRole('tab', { name: 'Contents' })).toHaveCount(0);
|
||||
await expect(surface.root.getByRole('button', { name: 'Rectangle', exact: true })).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
* 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}"]`);
|
||||
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
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, 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;
|
||||
|
||||
contents: Pick<EnvelopeContent, 'envelopeItemId' | 'dataContentId'>[];
|
||||
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);
|
||||
|
||||
// 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 url = getDataContentImageUrl({ envelopeId, dataContentId, token, presignToken });
|
||||
|
||||
void loadContentImage(url)
|
||||
.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();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,316 @@
|
||||
import type { TEnvelopeContentMeta } from '@documenso/lib/types/envelope-content-meta';
|
||||
import { EnvelopeContentType, ZEnvelopeContentMetaSchema } from '@documenso/lib/types/envelope-content-meta';
|
||||
import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor';
|
||||
import { getLocalContentOrderId, getNextContentZIndex, isContentOnTop } from '@documenso/lib/utils/envelope-content';
|
||||
import { clampPercentage, clampPercentageBox } from '@documenso/lib/utils/geometry';
|
||||
import { useCallback } from 'react';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
import { EDITOR_DUPLICATE_ITEM_OFFSET } from '../../constants/envelope-editor';
|
||||
import { createLocalFormId, useEditorFormArray } from './use-editor-form-array';
|
||||
|
||||
/**
|
||||
* The local editor representation of an envelope content.
|
||||
*
|
||||
* Note: Unlike fields, geometry (page, position, size and stacking order) is
|
||||
* stored within the content meta since it differs per content type, e.g.
|
||||
* lines use start/end coordinates instead of a position and size.
|
||||
*/
|
||||
export const ZLocalContentSchema = z.object({
|
||||
// This is the actual ID of the envelope content if created.
|
||||
id: z.string().optional(),
|
||||
// This is the local client side ID of the content.
|
||||
formId: z.string().min(1),
|
||||
// This is the ID of the envelope item to put the content on.
|
||||
envelopeItemId: z.string(),
|
||||
contentMeta: ZEnvelopeContentMetaSchema,
|
||||
// This is the ID of the uploaded data content (e.g. an image) attached to
|
||||
// the content, or null for none.
|
||||
dataContentId: z.string().nullable(),
|
||||
});
|
||||
|
||||
export type TLocalContent = z.infer<typeof ZLocalContentSchema>;
|
||||
|
||||
type EditorContentsProps = {
|
||||
envelope: TEditorEnvelope;
|
||||
|
||||
/**
|
||||
* Receives a getter rather than the contents so the values are read when
|
||||
* the save is sent, not when it was queued.
|
||||
*/
|
||||
handleContentsUpdate: (getContents: () => TLocalContent[]) => unknown;
|
||||
};
|
||||
|
||||
type UseEditorContentsResponse = {
|
||||
localContents: TLocalContent[];
|
||||
|
||||
// Selected content
|
||||
selectedContent: TLocalContent | undefined;
|
||||
setSelectedContent: (formId: string | null) => void;
|
||||
|
||||
// Content operations
|
||||
/**
|
||||
* Add a content. It is placed above every other content on its page.
|
||||
*/
|
||||
addContent: (content: Omit<TLocalContent, 'formId' | 'dataContentId'>) => TLocalContent;
|
||||
setContentPersistedIds: (
|
||||
formId: string,
|
||||
persisted: { id: string; dataContentId: string | null },
|
||||
sentDataContentId: string | null,
|
||||
) => void;
|
||||
removeContentsByFormId: (formIds: string[]) => void;
|
||||
updateContentByFormId: (formId: string, updates: Partial<TLocalContent>) => void;
|
||||
|
||||
/**
|
||||
* Merge a partial meta onto a content's current meta.
|
||||
*
|
||||
* The settings form provider is the single writer of a content's
|
||||
* presentational meta. Anything else which edits those keys must go through
|
||||
* the form (e.g. `form.setValue`) rather than calling this directly, so the
|
||||
* form never falls behind the store.
|
||||
*/
|
||||
patchContentMeta: (formId: string, patch: Partial<TEnvelopeContentMeta>) => void;
|
||||
|
||||
duplicateContent: (content: TLocalContent) => TLocalContent;
|
||||
|
||||
// Content utilities
|
||||
getContentByFormId: (formId: string) => TLocalContent | undefined;
|
||||
|
||||
/**
|
||||
* Place the content above every other content on its page, unless it is
|
||||
* already on top.
|
||||
*/
|
||||
bringContentToFront: (formId: string) => void;
|
||||
|
||||
resetForm: (contents?: TEditorEnvelope['contents']) => void;
|
||||
};
|
||||
|
||||
export const useEditorContents = ({
|
||||
envelope,
|
||||
handleContentsUpdate,
|
||||
}: EditorContentsProps): UseEditorContentsResponse => {
|
||||
const contentsArray = useEditorFormArray<TLocalContent, TEditorEnvelope['contents'][number]>({
|
||||
schema: ZLocalContentSchema,
|
||||
sources: envelope.contents,
|
||||
mapSource: (content) => ({
|
||||
id: content.id,
|
||||
envelopeItemId: content.envelopeItemId,
|
||||
contentMeta: content.contentMeta,
|
||||
dataContentId: content.dataContentId,
|
||||
}),
|
||||
onChange: handleContentsUpdate,
|
||||
});
|
||||
|
||||
/**
|
||||
* The contents sharing a page with the given content, read live from the
|
||||
* form so this is safe to call from long lived callbacks.
|
||||
*/
|
||||
const getPageSiblings = (content: Pick<TLocalContent, 'envelopeItemId' | 'contentMeta'>, excludeFormId?: string) =>
|
||||
contentsArray
|
||||
.getItems()
|
||||
.filter(
|
||||
(candidate) =>
|
||||
candidate.formId !== excludeFormId &&
|
||||
candidate.envelopeItemId === content.envelopeItemId &&
|
||||
candidate.contentMeta.page === content.contentMeta.page,
|
||||
);
|
||||
|
||||
/**
|
||||
* The zIndex which places a content above every other content on its page.
|
||||
*/
|
||||
const getTopZIndex = (content: Pick<TLocalContent, 'envelopeItemId' | 'contentMeta'>, excludeFormId?: string) =>
|
||||
getNextContentZIndex(getPageSiblings(content, excludeFormId).map((sibling) => sibling.contentMeta.zIndex));
|
||||
|
||||
const addContent = useCallback(
|
||||
(contentData: Omit<TLocalContent, 'formId' | 'dataContentId'>): TLocalContent => {
|
||||
const content: TLocalContent = {
|
||||
...contentData,
|
||||
formId: createLocalFormId(),
|
||||
dataContentId: null,
|
||||
contentMeta: {
|
||||
...restrictContentMetaPosValues(contentData.contentMeta),
|
||||
// New contents go on top.
|
||||
zIndex: getTopZIndex(contentData),
|
||||
},
|
||||
};
|
||||
|
||||
contentsArray.appendItems([content]);
|
||||
contentsArray.setSelected(content.formId, true);
|
||||
|
||||
return content;
|
||||
},
|
||||
[contentsArray.appendItems, contentsArray.setSelected],
|
||||
);
|
||||
|
||||
/**
|
||||
* Adopt the IDs assigned by the server after a save, without triggering
|
||||
* another save.
|
||||
*
|
||||
* The content ID is always taken from the response, since the server owns
|
||||
* row identity and a save may have replaced the row. Keeping a local ID
|
||||
* here would leave every later save pointing at a row which no longer
|
||||
* exists, which the server would then recreate over and over.
|
||||
*
|
||||
* The data content ID is different: the server may assign a different one
|
||||
* than was sent, e.g. when a duplicated content's image is cloned, so it is
|
||||
* only adopted while the local value is still the one that was sent and the
|
||||
* response of an older save cannot overwrite a newer upload.
|
||||
*/
|
||||
const setContentPersistedIds = (
|
||||
formId: string,
|
||||
persisted: { id: string; dataContentId: string | null },
|
||||
sentDataContentId: string | null,
|
||||
) => {
|
||||
contentsArray.updateByFormId(
|
||||
formId,
|
||||
(current) => {
|
||||
const currentDataContentId = current.dataContentId;
|
||||
|
||||
const nextDataContentId =
|
||||
currentDataContentId === sentDataContentId ? persisted.dataContentId : currentDataContentId;
|
||||
|
||||
if (persisted.id === current.id && nextDataContentId === currentDataContentId) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return { ...current, id: persisted.id, dataContentId: nextDataContentId };
|
||||
},
|
||||
{ shouldTriggerChange: false },
|
||||
);
|
||||
};
|
||||
|
||||
const updateContentByFormId = useCallback(
|
||||
(formId: string, updates: Partial<TLocalContent>) => {
|
||||
contentsArray.updateByFormId(formId, (content) => {
|
||||
const updated = { ...content, ...updates };
|
||||
|
||||
return { ...updated, contentMeta: restrictContentMetaPosValues(updated.contentMeta) };
|
||||
});
|
||||
},
|
||||
[contentsArray.updateByFormId],
|
||||
);
|
||||
|
||||
const patchContentMeta = useCallback(
|
||||
(formId: string, patch: Partial<TEnvelopeContentMeta>) => {
|
||||
const content = contentsArray.findItem(formId);
|
||||
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The patch only ever carries keys valid for the content's own type, so
|
||||
// the merge keeps the discriminant intact.
|
||||
const contentMeta = { ...content.contentMeta, ...patch } as TEnvelopeContentMeta;
|
||||
|
||||
updateContentByFormId(formId, { contentMeta });
|
||||
},
|
||||
[contentsArray.findItem, updateContentByFormId],
|
||||
);
|
||||
|
||||
const duplicateContent = useCallback(
|
||||
(content: TLocalContent): TLocalContent => {
|
||||
const newContent: TLocalContent = {
|
||||
...structuredClone(content),
|
||||
id: undefined,
|
||||
formId: createLocalFormId(),
|
||||
contentMeta: {
|
||||
...restrictContentMetaPosValues(offsetContentMetaPosValues(content.contentMeta)),
|
||||
// The duplicate goes on top of the original.
|
||||
zIndex: getTopZIndex(content),
|
||||
},
|
||||
};
|
||||
|
||||
contentsArray.appendItems([newContent]);
|
||||
|
||||
return newContent;
|
||||
},
|
||||
[contentsArray.appendItems],
|
||||
);
|
||||
|
||||
const bringContentToFront = useCallback(
|
||||
(formId: string) => {
|
||||
contentsArray.updateByFormId(formId, (content) => {
|
||||
const page = getPageSiblings(content);
|
||||
|
||||
// Nothing to do when it already renders last on its page. The
|
||||
// tiebreak matches how the page is rendered.
|
||||
const isAlreadyOnTop = isContentOnTop(
|
||||
page.map((candidate) => ({ id: getLocalContentOrderId(candidate), zIndex: candidate.contentMeta.zIndex })),
|
||||
getLocalContentOrderId(content),
|
||||
);
|
||||
|
||||
if (isAlreadyOnTop) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return {
|
||||
...content,
|
||||
contentMeta: { ...content.contentMeta, zIndex: getTopZIndex(content, formId) },
|
||||
};
|
||||
});
|
||||
},
|
||||
[contentsArray.updateByFormId],
|
||||
);
|
||||
|
||||
return {
|
||||
// Core state
|
||||
localContents: contentsArray.items,
|
||||
|
||||
// Content operations
|
||||
addContent,
|
||||
setContentPersistedIds,
|
||||
removeContentsByFormId: contentsArray.removeByFormId,
|
||||
updateContentByFormId,
|
||||
patchContentMeta,
|
||||
duplicateContent,
|
||||
|
||||
// Content utilities
|
||||
getContentByFormId: contentsArray.findItem,
|
||||
bringContentToFront,
|
||||
|
||||
// Selected content
|
||||
selectedContent: contentsArray.selectedItem,
|
||||
setSelectedContent: contentsArray.setSelected,
|
||||
|
||||
resetForm: contentsArray.resetForm,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Restrict the positional values of a content meta to be within the page bounds.
|
||||
*
|
||||
* Positional values are handled per content type since they differ, e.g. lines
|
||||
* use start/end coordinates instead of a position and size.
|
||||
*/
|
||||
const restrictContentMetaPosValues = (contentMeta: TEnvelopeContentMeta): TEnvelopeContentMeta => {
|
||||
return match(contentMeta)
|
||||
.with({ type: EnvelopeContentType.LINE }, (meta) => ({
|
||||
...meta,
|
||||
x1: clampPercentage(meta.x1),
|
||||
y1: clampPercentage(meta.y1),
|
||||
x2: clampPercentage(meta.x2),
|
||||
y2: clampPercentage(meta.y2),
|
||||
}))
|
||||
.otherwise((meta) => clampPercentageBox(meta));
|
||||
};
|
||||
|
||||
/**
|
||||
* Offset the position of a content meta, used when duplicating contents so the
|
||||
* duplicate does not fully overlap the original.
|
||||
*/
|
||||
const offsetContentMetaPosValues = (
|
||||
contentMeta: TEnvelopeContentMeta,
|
||||
offset = EDITOR_DUPLICATE_ITEM_OFFSET,
|
||||
): TEnvelopeContentMeta => {
|
||||
return match(contentMeta)
|
||||
.with({ type: EnvelopeContentType.LINE }, (meta) => ({
|
||||
...meta,
|
||||
y1: meta.y1 + offset,
|
||||
y2: meta.y2 + offset,
|
||||
}))
|
||||
.otherwise((meta) => ({
|
||||
...meta,
|
||||
positionX: meta.positionX + offset,
|
||||
positionY: meta.positionY + offset,
|
||||
}));
|
||||
};
|
||||
@@ -1,13 +1,12 @@
|
||||
import { getPdfPagesCount } from '@documenso/lib/constants/pdf-viewer';
|
||||
import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor';
|
||||
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { clampPercentageBox } from '@documenso/lib/utils/geometry';
|
||||
import type { Field } from '@prisma/client';
|
||||
import { FieldType } from '@prisma/client';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { z } from 'zod';
|
||||
import { EDITOR_DUPLICATE_ITEM_OFFSET } from '../../constants/envelope-editor';
|
||||
import { createLocalFormId, getOtherPageNumbers, useEditorFormArray } from './use-editor-form-array';
|
||||
|
||||
export const ZLocalFieldSchema = z.object({
|
||||
// This is the actual ID of the field if created.
|
||||
@@ -28,15 +27,14 @@ export const ZLocalFieldSchema = z.object({
|
||||
|
||||
export type TLocalField = z.infer<typeof ZLocalFieldSchema>;
|
||||
|
||||
const ZEditorFieldsFormSchema = z.object({
|
||||
fields: z.array(ZLocalFieldSchema),
|
||||
});
|
||||
|
||||
export type TEditorFieldsFormSchema = z.infer<typeof ZEditorFieldsFormSchema>;
|
||||
|
||||
type EditorFieldsProps = {
|
||||
envelope: TEditorEnvelope;
|
||||
handleFieldsUpdate: (fields: TLocalField[]) => unknown;
|
||||
|
||||
/**
|
||||
* Receives a getter rather than the fields so the values are read when the
|
||||
* save is sent, not when it was queued.
|
||||
*/
|
||||
handleFieldsUpdate: (getFields: () => TLocalField[]) => unknown;
|
||||
};
|
||||
|
||||
type UseEditorFieldsResponse = {
|
||||
@@ -51,12 +49,11 @@ type UseEditorFieldsResponse = {
|
||||
setFieldId: (formId: string, id: number) => void;
|
||||
removeFieldsByFormId: (formIds: string[]) => void;
|
||||
updateFieldByFormId: (formId: string, updates: Partial<TLocalField>) => void;
|
||||
duplicateField: (field: TLocalField, recipientId?: number) => TLocalField;
|
||||
duplicateFieldToAllPages: (field: TLocalField, recipientId?: number) => TLocalField[];
|
||||
duplicateField: (field: TLocalField) => TLocalField;
|
||||
duplicateFieldToAllPages: (field: TLocalField) => TLocalField[];
|
||||
|
||||
// Field utilities
|
||||
getFieldByFormId: (formId: string) => TLocalField | undefined;
|
||||
getFieldsByRecipient: (recipientId: number) => TLocalField[];
|
||||
|
||||
// Selected recipient
|
||||
selectedRecipient: TEditorEnvelope['recipients'][number] | null;
|
||||
@@ -66,133 +63,79 @@ type UseEditorFieldsResponse = {
|
||||
};
|
||||
|
||||
export const useEditorFields = ({ envelope, handleFieldsUpdate }: EditorFieldsProps): UseEditorFieldsResponse => {
|
||||
const [selectedFieldFormId, setSelectedFieldFormId] = useState<string | null>(null);
|
||||
const [selectedRecipientId, setSelectedRecipientId] = useState<number | null>(null);
|
||||
|
||||
const generateDefaultValues = (fields?: Field[]) => {
|
||||
const formFields = (fields || envelope.fields).map(
|
||||
(field): TLocalField => ({
|
||||
id: field.id,
|
||||
formId: nanoid(),
|
||||
envelopeItemId: field.envelopeItemId,
|
||||
page: field.page,
|
||||
type: field.type,
|
||||
positionX: Number(field.positionX),
|
||||
positionY: Number(field.positionY),
|
||||
width: Number(field.width),
|
||||
height: Number(field.height),
|
||||
recipientId: field.recipientId,
|
||||
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
fields: formFields,
|
||||
};
|
||||
};
|
||||
|
||||
const form = useForm<TEditorFieldsFormSchema>({
|
||||
defaultValues: generateDefaultValues(),
|
||||
resolver: zodResolver(ZEditorFieldsFormSchema),
|
||||
const fieldsArray = useEditorFormArray<TLocalField, Field>({
|
||||
schema: ZLocalFieldSchema,
|
||||
sources: envelope.fields,
|
||||
mapSource: (field) => ({
|
||||
id: field.id,
|
||||
envelopeItemId: field.envelopeItemId,
|
||||
page: field.page,
|
||||
type: field.type,
|
||||
positionX: Number(field.positionX),
|
||||
positionY: Number(field.positionY),
|
||||
width: Number(field.width),
|
||||
height: Number(field.height),
|
||||
recipientId: field.recipientId,
|
||||
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
|
||||
}),
|
||||
onChange: handleFieldsUpdate,
|
||||
});
|
||||
|
||||
const {
|
||||
append,
|
||||
remove,
|
||||
update,
|
||||
fields: localFields,
|
||||
} = useFieldArray({
|
||||
control: form.control,
|
||||
name: 'fields',
|
||||
keyName: 'react-hook-form-id',
|
||||
});
|
||||
const setSelectedRecipient = useCallback(
|
||||
(recipientId: number | null) => {
|
||||
const foundRecipient = envelope.recipients.find((recipient) => recipient.id === recipientId);
|
||||
|
||||
const triggerFieldsUpdate = () => {
|
||||
void handleFieldsUpdate(form.getValues().fields);
|
||||
};
|
||||
setSelectedRecipientId(foundRecipient?.id ?? null);
|
||||
},
|
||||
[envelope.recipients],
|
||||
);
|
||||
|
||||
const setSelectedField = (formId: string | null, bypassCheck = false) => {
|
||||
if (!formId) {
|
||||
setSelectedFieldFormId(null);
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* Selecting a field also selects its recipient.
|
||||
*/
|
||||
const setSelectedField = useCallback(
|
||||
(formId: string | null, bypassCheck = false) => {
|
||||
const foundField = formId ? fieldsArray.findItem(formId) : undefined;
|
||||
|
||||
const foundField = localFields.find((field) => field.formId === formId);
|
||||
const recipient = envelope.recipients.find((recipient) => recipient.id === foundField?.recipientId);
|
||||
if (foundField) {
|
||||
setSelectedRecipient(foundField.recipientId);
|
||||
}
|
||||
|
||||
if (recipient) {
|
||||
setSelectedRecipient(recipient.id);
|
||||
}
|
||||
|
||||
if (bypassCheck) {
|
||||
setSelectedFieldFormId(formId);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFieldFormId(foundField?.formId ?? null);
|
||||
};
|
||||
fieldsArray.setSelected(formId, bypassCheck);
|
||||
},
|
||||
[fieldsArray.findItem, fieldsArray.setSelected, setSelectedRecipient],
|
||||
);
|
||||
|
||||
const addField = useCallback(
|
||||
(fieldData: Omit<TLocalField, 'formId'>): TLocalField => {
|
||||
const field: TLocalField = {
|
||||
const field: TLocalField = clampPercentageBox({
|
||||
...fieldData,
|
||||
formId: nanoid(12),
|
||||
...restrictFieldPosValues(fieldData),
|
||||
};
|
||||
formId: createLocalFormId(),
|
||||
});
|
||||
|
||||
append(field);
|
||||
triggerFieldsUpdate();
|
||||
fieldsArray.appendItems([field]);
|
||||
setSelectedField(field.formId, true);
|
||||
|
||||
return field;
|
||||
},
|
||||
[append, triggerFieldsUpdate, setSelectedField],
|
||||
);
|
||||
|
||||
const removeFieldsByFormId = useCallback(
|
||||
(formIds: string[]) => {
|
||||
const indexes = formIds
|
||||
.map((formId) => localFields.findIndex((field) => field.formId === formId))
|
||||
.filter((index) => index !== -1);
|
||||
|
||||
if (indexes.length > 0) {
|
||||
remove(indexes);
|
||||
triggerFieldsUpdate();
|
||||
}
|
||||
},
|
||||
[localFields, remove, triggerFieldsUpdate],
|
||||
[fieldsArray.appendItems, setSelectedField],
|
||||
);
|
||||
|
||||
/**
|
||||
* Adopt the ID assigned by the server after a save, without triggering
|
||||
* another save.
|
||||
*/
|
||||
const setFieldId = (formId: string, id: number) => {
|
||||
const { fields } = form.getValues();
|
||||
|
||||
const index = fields.findIndex((field) => field.formId === formId);
|
||||
|
||||
if (index !== -1) {
|
||||
update(index, {
|
||||
...fields[index],
|
||||
id,
|
||||
});
|
||||
}
|
||||
fieldsArray.updateByFormId(formId, (field) => ({ ...field, id }), { shouldTriggerChange: false });
|
||||
};
|
||||
|
||||
const updateFieldByFormId = useCallback(
|
||||
(formId: string, updates: Partial<TLocalField>) => {
|
||||
const index = localFields.findIndex((field) => field.formId === formId);
|
||||
|
||||
if (index !== -1) {
|
||||
const updatedField = {
|
||||
...localFields[index],
|
||||
...updates,
|
||||
};
|
||||
|
||||
update(index, {
|
||||
...updatedField,
|
||||
...restrictFieldPosValues(updatedField),
|
||||
});
|
||||
triggerFieldsUpdate();
|
||||
}
|
||||
fieldsArray.updateByFormId(formId, (field) => clampPercentageBox({ ...field, ...updates }));
|
||||
},
|
||||
[localFields, update, triggerFieldsUpdate],
|
||||
[fieldsArray.updateByFormId],
|
||||
);
|
||||
|
||||
const duplicateField = useCallback(
|
||||
@@ -200,123 +143,65 @@ export const useEditorFields = ({ envelope, handleFieldsUpdate }: EditorFieldsPr
|
||||
const newField: TLocalField = {
|
||||
...structuredClone(field),
|
||||
id: undefined,
|
||||
formId: nanoid(12),
|
||||
recipientId: field.recipientId,
|
||||
positionX: field.positionX + 3,
|
||||
positionY: field.positionY + 3,
|
||||
formId: createLocalFormId(),
|
||||
positionX: field.positionX + EDITOR_DUPLICATE_ITEM_OFFSET,
|
||||
positionY: field.positionY + EDITOR_DUPLICATE_ITEM_OFFSET,
|
||||
};
|
||||
|
||||
append(newField);
|
||||
triggerFieldsUpdate();
|
||||
fieldsArray.appendItems([newField]);
|
||||
|
||||
return newField;
|
||||
},
|
||||
[append, triggerFieldsUpdate],
|
||||
[fieldsArray.appendItems],
|
||||
);
|
||||
|
||||
const duplicateFieldToAllPages = useCallback(
|
||||
(field: TLocalField): TLocalField[] => {
|
||||
const totalPages = getPdfPagesCount();
|
||||
const newFields: TLocalField[] = [];
|
||||
|
||||
if (totalPages < 1) {
|
||||
return newFields;
|
||||
}
|
||||
|
||||
for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) {
|
||||
if (pageNumber === field.page) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const newField: TLocalField = {
|
||||
const newFields = getOtherPageNumbers(field.page).map(
|
||||
(page): TLocalField => ({
|
||||
...structuredClone(field),
|
||||
id: undefined,
|
||||
formId: nanoid(12),
|
||||
page: pageNumber,
|
||||
};
|
||||
formId: createLocalFormId(),
|
||||
page,
|
||||
}),
|
||||
);
|
||||
|
||||
append(newField);
|
||||
newFields.push(newField);
|
||||
if (newFields.length > 0) {
|
||||
fieldsArray.appendItems(newFields);
|
||||
}
|
||||
|
||||
triggerFieldsUpdate();
|
||||
return newFields;
|
||||
},
|
||||
[append, triggerFieldsUpdate],
|
||||
);
|
||||
|
||||
const getFieldByFormId = useCallback(
|
||||
(formId: string): TLocalField | undefined => {
|
||||
return localFields.find((field) => field.formId === formId) as TLocalField | undefined;
|
||||
},
|
||||
[localFields],
|
||||
);
|
||||
|
||||
const getFieldsByRecipient = useCallback(
|
||||
(recipientId: number): TLocalField[] => {
|
||||
return localFields.filter((field) => field.recipientId === recipientId);
|
||||
},
|
||||
[localFields],
|
||||
[fieldsArray.appendItems],
|
||||
);
|
||||
|
||||
const selectedRecipient = useMemo(() => {
|
||||
return envelope.recipients.find((recipient) => recipient.id === selectedRecipientId) || null;
|
||||
}, [selectedRecipientId, envelope.recipients]);
|
||||
|
||||
const selectedField = useMemo(() => {
|
||||
return localFields.find((field) => field.formId === selectedFieldFormId);
|
||||
}, [selectedFieldFormId, localFields]);
|
||||
|
||||
/**
|
||||
* Keep the selected field form ID in sync with the local fields.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const foundField = localFields.find((field) => field.formId === selectedFieldFormId);
|
||||
setSelectedFieldFormId(foundField?.formId ?? null);
|
||||
}, [selectedFieldFormId, localFields]);
|
||||
|
||||
const setSelectedRecipient = (recipientId: number | null) => {
|
||||
const foundRecipient = envelope.recipients.find((recipient) => recipient.id === recipientId);
|
||||
|
||||
setSelectedRecipientId(foundRecipient?.id ?? null);
|
||||
};
|
||||
|
||||
const resetForm = (fields?: Field[]) => {
|
||||
form.reset(generateDefaultValues(fields));
|
||||
};
|
||||
|
||||
return {
|
||||
// Core state
|
||||
localFields,
|
||||
localFields: fieldsArray.items,
|
||||
|
||||
// Field operations
|
||||
addField,
|
||||
setFieldId,
|
||||
removeFieldsByFormId,
|
||||
removeFieldsByFormId: fieldsArray.removeByFormId,
|
||||
updateFieldByFormId,
|
||||
duplicateField,
|
||||
duplicateFieldToAllPages,
|
||||
|
||||
// Field utilities
|
||||
getFieldByFormId,
|
||||
getFieldsByRecipient,
|
||||
getFieldByFormId: fieldsArray.findItem,
|
||||
|
||||
// Selected field
|
||||
selectedField,
|
||||
selectedField: fieldsArray.selectedItem,
|
||||
setSelectedField,
|
||||
|
||||
// Selected recipient
|
||||
selectedRecipient,
|
||||
setSelectedRecipient,
|
||||
|
||||
resetForm,
|
||||
};
|
||||
};
|
||||
|
||||
const restrictFieldPosValues = (field: Pick<TLocalField, 'positionX' | 'positionY' | 'width' | 'height'>) => {
|
||||
return {
|
||||
positionX: Math.max(0, Math.min(100, field.positionX)),
|
||||
positionY: Math.max(0, Math.min(100, field.positionY)),
|
||||
width: Math.max(0, Math.min(100, field.width)),
|
||||
height: Math.max(0, Math.min(100, field.height)),
|
||||
resetForm: fieldsArray.resetForm,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import { getPdfPagesCount } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { type Resolver, useFieldArray, useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useLatestRef } from './use-latest-ref';
|
||||
|
||||
/**
|
||||
* The length of the client side IDs given to items created in the editor.
|
||||
*/
|
||||
const LOCAL_FORM_ID_LENGTH = 12;
|
||||
|
||||
type EditorItem = {
|
||||
/**
|
||||
* The local client side ID of the item, stable for its editor lifetime.
|
||||
*/
|
||||
formId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The form is typed against the base item internally, since react-hook-form
|
||||
* cannot resolve field paths against an open generic. Values are cast back to
|
||||
* the caller's item type at the boundary.
|
||||
*/
|
||||
type EditorFormArrayValues = {
|
||||
items: EditorItem[];
|
||||
};
|
||||
|
||||
type UseEditorFormArrayOptions<TItem extends EditorItem, TSource> = {
|
||||
schema: z.ZodType<TItem, z.ZodTypeDef, unknown>;
|
||||
|
||||
/**
|
||||
* The persisted items the local list starts from and is reset to.
|
||||
*/
|
||||
sources: TSource[];
|
||||
|
||||
/**
|
||||
* Map a persisted item to its local shape. The form ID is assigned by the
|
||||
* hook.
|
||||
*/
|
||||
mapSource: (source: TSource) => Omit<TItem, 'formId'>;
|
||||
|
||||
/**
|
||||
* Called after any change which needs saving. Receives a getter so the
|
||||
* values are read when the save is sent, not when it was queued, which
|
||||
* matters when a save in flight corrects the local items (e.g. assigns
|
||||
* server IDs).
|
||||
*/
|
||||
onChange: (getItems: () => TItem[]) => unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* The local, autosaved list backing an editor surface (fields, contents).
|
||||
*
|
||||
* All lookups and writes go through the live form values rather than the
|
||||
* render captured list. Callers can be arbitrarily far behind the list, e.g.
|
||||
* a Konva handler bound at render time or an upload finishing after its item
|
||||
* was deleted, and an index resolved from a stale list would hit whichever
|
||||
* item has since moved into that slot.
|
||||
*/
|
||||
export const useEditorFormArray = <TItem extends EditorItem, TSource>({
|
||||
schema,
|
||||
sources,
|
||||
mapSource,
|
||||
onChange,
|
||||
}: UseEditorFormArrayOptions<TItem, TSource>) => {
|
||||
const [selectedFormId, setSelectedFormId] = useState<string | null>(null);
|
||||
|
||||
// The callers pass these as inline functions, so they are read through refs
|
||||
// to keep every operation below referentially stable. Long lived callbacks
|
||||
// (e.g. Konva handlers) capture those operations and must not go stale.
|
||||
const mapSourceRef = useLatestRef(mapSource);
|
||||
const onChangeRef = useLatestRef(onChange);
|
||||
|
||||
const toDefaultValues = useCallback(
|
||||
(items: TSource[]): EditorFormArrayValues => ({
|
||||
items: items.map((source) => ({ ...mapSourceRef.current(source), formId: createLocalFormId() })),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
// Only read on mount, so compute it once rather than on every render.
|
||||
const [initialValues] = useState(() => toDefaultValues(sources));
|
||||
|
||||
const form = useForm<EditorFormArrayValues>({
|
||||
defaultValues: initialValues,
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
resolver: zodResolver(z.object({ items: z.array(schema) })) as Resolver<EditorFormArrayValues>,
|
||||
});
|
||||
|
||||
const { append, remove, update, fields } = useFieldArray({
|
||||
control: form.control,
|
||||
name: 'items',
|
||||
keyName: 'react-hook-form-id',
|
||||
});
|
||||
|
||||
// `useFieldArray` merges its own key into each item, which the callers do
|
||||
// not want to see.
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const items = fields as unknown as TItem[];
|
||||
|
||||
/**
|
||||
* The live items. This is the form's own array, so callers must treat the
|
||||
* items as read only and replace rather than mutate them.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const getItems = useCallback(() => form.getValues().items as TItem[], [form]);
|
||||
|
||||
const findIndex = useCallback((formId: string) => getItems().findIndex((item) => item.formId === formId), [getItems]);
|
||||
|
||||
const findItem = useCallback(
|
||||
(formId: string): TItem | undefined => getItems().find((item) => item.formId === formId),
|
||||
[getItems],
|
||||
);
|
||||
|
||||
const triggerChange = useCallback(() => {
|
||||
void onChangeRef.current(getItems);
|
||||
}, [getItems]);
|
||||
|
||||
/**
|
||||
* Select an item by form ID. Unless bypassed, an ID which is not in the
|
||||
* list clears the selection.
|
||||
*/
|
||||
const setSelected = useCallback(
|
||||
(formId: string | null, bypassCheck = false) => {
|
||||
if (!formId || bypassCheck) {
|
||||
setSelectedFormId(formId);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFormId(findItem(formId)?.formId ?? null);
|
||||
},
|
||||
[findItem],
|
||||
);
|
||||
|
||||
const appendItems = useCallback(
|
||||
(newItems: TItem[]) => {
|
||||
if (newItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A single append so the list updates once however many items there are.
|
||||
append(newItems);
|
||||
triggerChange();
|
||||
},
|
||||
[append, triggerChange],
|
||||
);
|
||||
|
||||
const removeByFormId = useCallback(
|
||||
(formIds: string[]) => {
|
||||
const indexes = formIds.map((formId) => findIndex(formId)).filter((index) => index !== -1);
|
||||
|
||||
if (indexes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
remove(indexes);
|
||||
triggerChange();
|
||||
},
|
||||
[findIndex, remove, triggerChange],
|
||||
);
|
||||
|
||||
/**
|
||||
* Replace an item with the result of `updater`. Returning the same item
|
||||
* (by reference) leaves the list untouched and does not trigger a save.
|
||||
*/
|
||||
const updateByFormId = useCallback(
|
||||
(formId: string, updater: (current: TItem) => TItem, { shouldTriggerChange = true } = {}) => {
|
||||
const index = findIndex(formId);
|
||||
|
||||
if (index === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = getItems()[index];
|
||||
const next = updater(current);
|
||||
|
||||
if (next === current) {
|
||||
return;
|
||||
}
|
||||
|
||||
update(index, next);
|
||||
|
||||
if (shouldTriggerChange) {
|
||||
triggerChange();
|
||||
}
|
||||
},
|
||||
[findIndex, getItems, update, triggerChange],
|
||||
);
|
||||
|
||||
const selectedItem = useMemo(() => items.find((item) => item.formId === selectedFormId), [items, selectedFormId]);
|
||||
|
||||
// Drop the selection once its item leaves the list.
|
||||
useEffect(() => {
|
||||
if (selectedFormId && !items.some((item) => item.formId === selectedFormId)) {
|
||||
setSelectedFormId(null);
|
||||
}
|
||||
}, [items, selectedFormId]);
|
||||
|
||||
const sourcesRef = useLatestRef(sources);
|
||||
|
||||
const resetForm = useCallback(
|
||||
(nextSources?: TSource[]) => {
|
||||
form.reset(toDefaultValues(nextSources ?? sourcesRef.current));
|
||||
},
|
||||
[form, toDefaultValues],
|
||||
);
|
||||
|
||||
return {
|
||||
items,
|
||||
getItems,
|
||||
findItem,
|
||||
appendItems,
|
||||
removeByFormId,
|
||||
updateByFormId,
|
||||
selectedItem,
|
||||
setSelected,
|
||||
resetForm,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* A fresh local ID for an item created in the editor.
|
||||
*/
|
||||
export const createLocalFormId = () => nanoid(LOCAL_FORM_ID_LENGTH);
|
||||
|
||||
/**
|
||||
* Every page of the current document except the given one, for duplicating
|
||||
* an item across pages.
|
||||
*/
|
||||
export const getOtherPageNumbers = (currentPage: number) => {
|
||||
const totalPages = getPdfPagesCount();
|
||||
|
||||
const pages: number[] = [];
|
||||
|
||||
for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) {
|
||||
if (pageNumber !== currentPage) {
|
||||
pages.push(pageNumber);
|
||||
}
|
||||
}
|
||||
|
||||
return pages;
|
||||
};
|
||||
@@ -6,12 +6,18 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
* Only one save runs at a time and the latest edit always wins. If the user
|
||||
* keeps editing while a save is on the wire, their newest changes get saved
|
||||
* right after, never dropped.
|
||||
*
|
||||
* A getter may be queued instead of a value, in which case it is read when
|
||||
* the save is about to be sent rather than when it was queued. Callers whose
|
||||
* state is corrected by the response of a save (e.g. rows which are assigned
|
||||
* an ID by the server) need this, otherwise the queued save would be sent
|
||||
* with data captured before that correction.
|
||||
*/
|
||||
export function useEnvelopeAutosave<T>(saveFn: (data: T) => Promise<void>, delay = 1000) {
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// The edit waiting to be saved. Wrapped in an object so null always means "nothing queued".
|
||||
const pendingRef = useRef<{ value: T } | null>(null);
|
||||
const pendingRef = useRef<{ value: T | (() => T) } | null>(null);
|
||||
|
||||
// The save currently running, if any. Shared so we never kick off two at once.
|
||||
const commitPromiseRef = useRef<Promise<void> | null>(null);
|
||||
@@ -45,7 +51,8 @@ export function useEnvelopeAutosave<T>(saveFn: (data: T) => Promise<void>, delay
|
||||
const { value } = pendingRef.current;
|
||||
pendingRef.current = null;
|
||||
|
||||
await saveFnRef.current(value);
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
await saveFnRef.current(typeof value === 'function' ? (value as () => T)() : value);
|
||||
}
|
||||
} finally {
|
||||
// eslint-disable-next-line require-atomic-updates
|
||||
@@ -61,7 +68,7 @@ export function useEnvelopeAutosave<T>(saveFn: (data: T) => Promise<void>, delay
|
||||
}, []);
|
||||
|
||||
const triggerSave = useCallback(
|
||||
(data: T) => {
|
||||
(data: T | (() => T)) => {
|
||||
pendingRef.current = { value: data };
|
||||
|
||||
setIsPending(true);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useRef } from 'react';
|
||||
|
||||
/**
|
||||
* A ref which always holds the latest value.
|
||||
*
|
||||
* Useful for long lived callbacks (e.g. Konva event handlers bound once when
|
||||
* a stage is created) which need to read current state without stale closures.
|
||||
*/
|
||||
export const useLatestRef = <T>(value: T) => {
|
||||
const ref = useRef(value);
|
||||
|
||||
ref.current = value;
|
||||
|
||||
return ref;
|
||||
};
|
||||
@@ -1,18 +1,124 @@
|
||||
import Konva from 'konva';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { EnvelopePageItemsVisibility } from '../../types/envelope-page-items-visibility';
|
||||
import { resolvePageItemsVisibility } from '../../types/envelope-page-items-visibility';
|
||||
import { areContentImagesReady, hasFailedContentImage } from '../../universal/content-renderer/content-image-status';
|
||||
import { createPageItemsVisibilityApplier } from '../../universal/konva/page-items-visibility';
|
||||
import type { PageRenderData } from '../providers/envelope-render-provider';
|
||||
import { useCurrentEnvelopeRender } from '../providers/envelope-render-provider';
|
||||
|
||||
type RenderFunction = (props: { stage: Konva.Stage; pageLayer: Konva.Layer }) => void;
|
||||
|
||||
export const usePageRenderer = (renderFunction: RenderFunction, pageData: PageRenderData) => {
|
||||
const { pageWidth, pageHeight, scale, imageLoadingState, pageNumber } = pageData;
|
||||
type UsePageRendererOptions = {
|
||||
/**
|
||||
* Additional fields visibility input which is composed with the viewer
|
||||
* controls visibility, where the most restrictive input wins.
|
||||
*
|
||||
* Used by renderers with extra context, e.g. the editor muting fields while
|
||||
* the contents tab is active.
|
||||
*/
|
||||
fieldsVisibility?: EnvelopePageItemsVisibility;
|
||||
|
||||
/**
|
||||
* Additional contents visibility input which is composed with the viewer
|
||||
* controls visibility, where the most restrictive input wins.
|
||||
*/
|
||||
contentsVisibility?: EnvelopePageItemsVisibility;
|
||||
};
|
||||
|
||||
export const usePageRenderer = (
|
||||
renderFunction: RenderFunction,
|
||||
pageData: PageRenderData,
|
||||
options: UsePageRendererOptions = {},
|
||||
) => {
|
||||
const { pageWidth, pageHeight, scale, imageLoadingState, pageNumber, onReadyChange } = pageData;
|
||||
|
||||
const { viewerControls, contents, contentImages, currentEnvelopeItem, pageSizes } = useCurrentEnvelopeRender();
|
||||
|
||||
const konvaContainer = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Publish the page size so page relative geometry can be computed outside
|
||||
// the renderer, e.g. when sizing a content to an uploaded image.
|
||||
useEffect(() => {
|
||||
if (currentEnvelopeItem) {
|
||||
pageSizes.register(currentEnvelopeItem.id, pageNumber, { width: pageWidth, height: pageHeight });
|
||||
}
|
||||
}, [currentEnvelopeItem?.id, pageNumber, pageWidth, pageHeight]);
|
||||
|
||||
const pageContentImagesOptions = {
|
||||
contents,
|
||||
loadingStatuses: contentImages.loadingStatuses,
|
||||
requestedDataContentIds: contentImages.requestedDataContentIds,
|
||||
pageNumber,
|
||||
envelopeItemId: currentEnvelopeItem?.id ?? '',
|
||||
};
|
||||
|
||||
const hasFailedImage = hasFailedContentImage(pageContentImagesOptions);
|
||||
|
||||
/**
|
||||
* Whether the images of this page's contents have finished loading, so the
|
||||
* page renders with its images in place rather than them popping in.
|
||||
*
|
||||
* Latched once true: readiness only delays the initial render and never
|
||||
* tears down a live page, e.g. when the editor's contents gain a new image
|
||||
* which is then drawn progressively.
|
||||
*/
|
||||
const hasBeenReady = useRef(false);
|
||||
|
||||
if (!hasBeenReady.current && areContentImagesReady(pageContentImagesOptions)) {
|
||||
hasBeenReady.current = true;
|
||||
}
|
||||
|
||||
const isReady = imageLoadingState === 'loaded' && hasBeenReady.current;
|
||||
|
||||
// The page holds back its image until everything drawn on it is ready, so
|
||||
// the two appear together rather than the page looking finished while its
|
||||
// contents are still loading.
|
||||
useEffect(() => {
|
||||
onReadyChange?.(isReady);
|
||||
}, [isReady, onReadyChange]);
|
||||
|
||||
const stage = useRef<Konva.Stage | null>(null);
|
||||
const pageLayer = useRef<Konva.Layer | null>(null);
|
||||
|
||||
const fieldsVisibility = resolvePageItemsVisibility(
|
||||
viewerControls.fieldsVisibility,
|
||||
options.fieldsVisibility ?? 'visible',
|
||||
);
|
||||
|
||||
const contentsVisibility = resolvePageItemsVisibility(
|
||||
viewerControls.contentsVisibility,
|
||||
options.contentsVisibility ?? 'visible',
|
||||
);
|
||||
|
||||
// One applier per item kind, since each tracks its own muted state for the
|
||||
// lifetime of the page (including across stage recreation).
|
||||
const [applyFieldsVisibility] = useState(createPageItemsVisibilityApplier);
|
||||
const [applyContentsVisibility] = useState(createPageItemsVisibilityApplier);
|
||||
|
||||
/**
|
||||
* Apply the current fields/contents visibility to all named groups on the
|
||||
* page layer.
|
||||
*
|
||||
* This is automatically applied when the page is created and when the
|
||||
* visibility changes. Renderers which re-render groups outside of those
|
||||
* moments should call this after drawing so newly created groups pick up
|
||||
* the current visibility.
|
||||
*/
|
||||
const applyPageItemsVisibility = useCallback(() => {
|
||||
const layer = pageLayer.current;
|
||||
|
||||
if (!layer) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyFieldsVisibility(layer.find('.field-group'), fieldsVisibility);
|
||||
applyContentsVisibility(layer.find('.content-group'), contentsVisibility);
|
||||
|
||||
layer.batchDraw();
|
||||
}, [fieldsVisibility, contentsVisibility]);
|
||||
|
||||
/**
|
||||
* The raw viewport with no scaling. Basically the actual PDF size.
|
||||
*/
|
||||
@@ -40,7 +146,7 @@ export const usePageRenderer = (renderFunction: RenderFunction, pageData: PageRe
|
||||
useEffect(() => {
|
||||
const { current: container } = konvaContainer;
|
||||
|
||||
if (!container || imageLoadingState !== 'loaded') {
|
||||
if (!container || !isReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -65,6 +171,8 @@ export const usePageRenderer = (renderFunction: RenderFunction, pageData: PageRe
|
||||
pageLayer: pageLayer.current,
|
||||
});
|
||||
|
||||
applyPageItemsVisibility();
|
||||
|
||||
void document.fonts.ready.then(() => {
|
||||
pageLayer.current?.batchDraw();
|
||||
});
|
||||
@@ -73,7 +181,14 @@ export const usePageRenderer = (renderFunction: RenderFunction, pageData: PageRe
|
||||
stage.current?.destroy();
|
||||
stage.current = null;
|
||||
};
|
||||
}, [imageLoadingState, scaledViewport]);
|
||||
}, [isReady, scaledViewport]);
|
||||
|
||||
/**
|
||||
* Reapply the visibility whenever it changes.
|
||||
*/
|
||||
useEffect(() => {
|
||||
applyPageItemsVisibility();
|
||||
}, [applyPageItemsVisibility]);
|
||||
|
||||
return {
|
||||
konvaContainer,
|
||||
@@ -81,5 +196,21 @@ export const usePageRenderer = (renderFunction: RenderFunction, pageData: PageRe
|
||||
pageLayer,
|
||||
unscaledViewport,
|
||||
scaledViewport,
|
||||
fieldsVisibility,
|
||||
contentsVisibility,
|
||||
applyPageItemsVisibility,
|
||||
|
||||
/**
|
||||
* Whether the page bitmap and content images are ready and the stage has
|
||||
* been (or is about to be) created.
|
||||
*/
|
||||
isReady,
|
||||
|
||||
/**
|
||||
* Whether a content image on this page failed to load. Renderers decide
|
||||
* how to surface this, e.g. an error state for signers or a placeholder
|
||||
* for authors.
|
||||
*/
|
||||
hasFailedImage,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { ContentImageSource } from '../universal/content-renderer/content-renderer';
|
||||
|
||||
type LoadContentImageDependencies = {
|
||||
fetch: (url: string) => Promise<Response>;
|
||||
createImageBitmap: (blob: Blob) => Promise<ImageBitmap>;
|
||||
};
|
||||
|
||||
const defaultDependencies = (): LoadContentImageDependencies => ({
|
||||
fetch: async (url) => await fetch(url),
|
||||
createImageBitmap: async (blob) => await createImageBitmap(blob),
|
||||
});
|
||||
|
||||
/**
|
||||
* What is known about a content image's file, for display.
|
||||
*/
|
||||
export type ContentImageDetails = {
|
||||
/**
|
||||
* The size of the stored bytes.
|
||||
*/
|
||||
fileSize: number;
|
||||
};
|
||||
|
||||
export type LoadedContentImage = {
|
||||
image: ContentImageSource;
|
||||
details: ContentImageDetails;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch and decode a content image, retrying once after a network or server
|
||||
* error. Client errors (e.g. not found) fail immediately.
|
||||
*
|
||||
* The image is decoded off the main thread into an `ImageBitmap`, which is
|
||||
* what the canvas renderer wants and carries no cross origin taint.
|
||||
*/
|
||||
export const loadContentImage = async (
|
||||
url: string,
|
||||
dependencies: LoadContentImageDependencies = defaultDependencies(),
|
||||
): Promise<LoadedContentImage> => {
|
||||
try {
|
||||
return await fetchAndDecode(url, dependencies);
|
||||
} catch (error) {
|
||||
if (!isRetryable(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return await fetchAndDecode(url, dependencies);
|
||||
}
|
||||
};
|
||||
|
||||
type ContentImageResponseError = Error & { status: number };
|
||||
|
||||
const createResponseError = (status: number): ContentImageResponseError =>
|
||||
Object.assign(new Error(`Failed to load content image (${status})`), { status });
|
||||
|
||||
const isResponseError = (error: unknown): error is ContentImageResponseError =>
|
||||
error instanceof Error && 'status' in error && typeof error.status === 'number';
|
||||
|
||||
const fetchAndDecode = async (
|
||||
url: string,
|
||||
{ fetch, createImageBitmap }: LoadContentImageDependencies,
|
||||
): Promise<LoadedContentImage> => {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw createResponseError(response.status);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
|
||||
return {
|
||||
image: await createImageBitmap(blob),
|
||||
details: {
|
||||
fileSize: blob.size,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Network failures surface as thrown `TypeError`s, and server errors are
|
||||
* worth one more attempt. Anything else is a definitive answer.
|
||||
*/
|
||||
const isRetryable = (error: unknown) => {
|
||||
if (isResponseError(error)) {
|
||||
return error.status >= 500;
|
||||
}
|
||||
|
||||
return error instanceof TypeError;
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type TEditorEnvelope,
|
||||
} from '@documenso/lib/types/envelope-editor';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TSetEnvelopeContentsResponse } from '@documenso/trpc/server/envelope-router/set-envelope-contents.types';
|
||||
import type { TSetEnvelopeFieldsResponse } from '@documenso/trpc/server/envelope-router/set-envelope-fields.types';
|
||||
import type { TSetEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/set-envelope-recipients.types';
|
||||
import type { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types';
|
||||
@@ -21,6 +22,8 @@ import { useSearchParams } from 'react-router';
|
||||
import type { TDocumentEmailSettings } from '../../types/document-email';
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '../../utils/teams';
|
||||
import { useAnalytics } from '../hooks/use-analytics';
|
||||
import type { TLocalContent } from '../hooks/use-editor-contents';
|
||||
import { useEditorContents } from '../hooks/use-editor-contents';
|
||||
import type { TLocalField } from '../hooks/use-editor-fields';
|
||||
import { useEditorFields } from '../hooks/use-editor-fields';
|
||||
import { useEditorRecipients } from '../hooks/use-editor-recipients';
|
||||
@@ -28,6 +31,12 @@ import { useEnvelopeAutosave } from '../hooks/use-envelope-autosave';
|
||||
|
||||
export type EnvelopeEditorStep = 'upload' | 'addFields' | 'preview';
|
||||
|
||||
/**
|
||||
* The active tab within the fields step of the editor, which determines
|
||||
* whether fields or contents are currently being edited on the canvas.
|
||||
*/
|
||||
export type EnvelopeEditorTab = 'fields' | 'contents';
|
||||
|
||||
type UpdateEnvelopePayload = Pick<TUpdateEnvelopeRequest, 'data' | 'meta'>;
|
||||
|
||||
type EnvelopeEditorProviderValue = {
|
||||
@@ -54,8 +63,12 @@ type EnvelopeEditorProviderValue = {
|
||||
getRecipientColorKey: (recipientId: number) => TRecipientColor;
|
||||
|
||||
editorFields: ReturnType<typeof useEditorFields>;
|
||||
editorContents: ReturnType<typeof useEditorContents>;
|
||||
editorRecipients: ReturnType<typeof useEditorRecipients>;
|
||||
|
||||
selectedEditorTab: EnvelopeEditorTab;
|
||||
setSelectedEditorTab: (tab: EnvelopeEditorTab) => void;
|
||||
|
||||
isAutosaving: boolean;
|
||||
flushAutosave: () => Promise<TEditorEnvelope>;
|
||||
autosaveError: boolean;
|
||||
@@ -144,6 +157,8 @@ export const EnvelopeEditorProvider = ({
|
||||
|
||||
const [autosaveError, setAutosaveError] = useState<boolean>(false);
|
||||
|
||||
const [selectedEditorTab, setSelectedEditorTab] = useState<EnvelopeEditorTab>('fields');
|
||||
|
||||
const isCscMode = IS_INSTANCE_CSC_MODE();
|
||||
|
||||
/**
|
||||
@@ -192,7 +207,12 @@ export const EnvelopeEditorProvider = ({
|
||||
|
||||
const editorFields = useEditorFields({
|
||||
envelope,
|
||||
handleFieldsUpdate: (fields) => setFieldsDebounced(fields),
|
||||
handleFieldsUpdate: (getFields) => setFieldsDebounced(getFields),
|
||||
});
|
||||
|
||||
const editorContents = useEditorContents({
|
||||
envelope,
|
||||
handleContentsUpdate: (getContents) => setContentsDebounced(getContents),
|
||||
});
|
||||
|
||||
const editorRecipients = useEditorRecipients({
|
||||
@@ -201,6 +221,7 @@ export const EnvelopeEditorProvider = ({
|
||||
|
||||
const setRecipientsMutation = trpc.envelope.recipient.set.useMutation();
|
||||
const setFieldsMutation = trpc.envelope.field.set.useMutation();
|
||||
const setContentsMutation = trpc.envelope.content.set.useMutation();
|
||||
const updateEnvelopeMutation = trpc.envelope.update.useMutation();
|
||||
|
||||
/**
|
||||
@@ -334,6 +355,79 @@ export const EnvelopeEditorProvider = ({
|
||||
await flushSetFields();
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles debouncing the content updates to the server.
|
||||
*
|
||||
* Will set the local envelope contents after the update is complete.
|
||||
*/
|
||||
const {
|
||||
triggerSave: setContentsDebounced,
|
||||
flush: flushSetContents,
|
||||
isPending: isContentsMutationPending,
|
||||
} = useEnvelopeAutosave(async (localContents: TLocalContent[]) => {
|
||||
try {
|
||||
let contents: TSetEnvelopeContentsResponse['data'] = [];
|
||||
|
||||
const currentEnvelope = getEnvelope();
|
||||
|
||||
if (!isEmbedded) {
|
||||
const response = await setContentsMutation.mutateAsync({
|
||||
envelopeId: currentEnvelope.id,
|
||||
contents: localContents.map((content) => ({
|
||||
id: content.id,
|
||||
formId: content.formId,
|
||||
envelopeItemId: content.envelopeItemId,
|
||||
contentMeta: content.contentMeta,
|
||||
dataContentId: content.dataContentId,
|
||||
})),
|
||||
});
|
||||
|
||||
contents = response.data;
|
||||
} else {
|
||||
contents = mapLocalContentsToContents({ envelope: currentEnvelope, localContents });
|
||||
}
|
||||
|
||||
setEnvelope((prev) => ({
|
||||
...prev,
|
||||
contents,
|
||||
}));
|
||||
|
||||
setAutosaveError(false);
|
||||
|
||||
// Insert the IDs into the local contents.
|
||||
contents.forEach((content) => {
|
||||
const localContent = localContents.find((localContent) => localContent.formId === content.formId);
|
||||
|
||||
if (!localContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
editorContents.setContentPersistedIds(
|
||||
localContent.formId,
|
||||
{ id: content.id, dataContentId: content.dataContentId },
|
||||
localContent.dataContentId,
|
||||
);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: isEmbedded ? 'embed' : 'editor',
|
||||
location: 'autosave_contents',
|
||||
envelopeId: envelope.id,
|
||||
});
|
||||
|
||||
setAutosaveError(true);
|
||||
|
||||
toast({
|
||||
title: t`Save failed`,
|
||||
description: t`We encountered an error while attempting to save your changes. Your changes cannot be saved at this time.`,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
/**
|
||||
* Handles debouncing the envelope updates to the server.
|
||||
*
|
||||
@@ -454,6 +548,7 @@ export const EnvelopeEditorProvider = ({
|
||||
});
|
||||
|
||||
editorFields.resetForm(fetchedEnvelopeData.data.fields);
|
||||
editorContents.resetForm(fetchedEnvelopeData.data.contents);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -462,8 +557,10 @@ export const EnvelopeEditorProvider = ({
|
||||
};
|
||||
|
||||
const isAutosaving = useMemo(() => {
|
||||
return isFieldsMutationPending || isRecipientsMutationPending || isEnvelopeMutationPending;
|
||||
}, [isFieldsMutationPending, isRecipientsMutationPending, isEnvelopeMutationPending]);
|
||||
return (
|
||||
isFieldsMutationPending || isContentsMutationPending || isRecipientsMutationPending || isEnvelopeMutationPending
|
||||
);
|
||||
}, [isFieldsMutationPending, isContentsMutationPending, isRecipientsMutationPending, isEnvelopeMutationPending]);
|
||||
|
||||
const relativePath = useMemo(() => {
|
||||
let documentRootPath = formatDocumentsPath(envelope.team.url);
|
||||
@@ -519,10 +616,11 @@ export const EnvelopeEditorProvider = ({
|
||||
});
|
||||
|
||||
editorFields.resetForm(currentEnvelope.fields);
|
||||
editorContents.resetForm(currentEnvelope.contents);
|
||||
};
|
||||
|
||||
const flushAutosave = async (): Promise<TEditorEnvelope> => {
|
||||
await Promise.all([flushSetFields(), flushSetRecipients(), flushUpdateEnvelope()]);
|
||||
await Promise.all([flushSetFields(), flushSetContents(), flushSetRecipients(), flushUpdateEnvelope()]);
|
||||
|
||||
// Flush all registered external flushes (e.g., upload page's debounced item updates).
|
||||
const externalFlushes = Array.from(externalFlushCallbacksRef.current.values());
|
||||
@@ -553,7 +651,10 @@ export const EnvelopeEditorProvider = ({
|
||||
setRecipientsDebounced,
|
||||
setRecipientsAsync,
|
||||
editorFields,
|
||||
editorContents,
|
||||
editorRecipients,
|
||||
selectedEditorTab,
|
||||
setSelectedEditorTab,
|
||||
autosaveError,
|
||||
flushAutosave,
|
||||
isAutosaving,
|
||||
@@ -620,6 +721,28 @@ const mapLocalRecipientsToRecipients = ({
|
||||
});
|
||||
};
|
||||
|
||||
type MapLocalContentsToContentsOptions = {
|
||||
localContents: TLocalContent[];
|
||||
envelope: TEditorEnvelope;
|
||||
};
|
||||
|
||||
const mapLocalContentsToContents = ({
|
||||
envelope,
|
||||
localContents,
|
||||
}: MapLocalContentsToContentsOptions): TEditorEnvelope['contents'] => {
|
||||
return localContents.map((content) => {
|
||||
return {
|
||||
// Local contents that have not been persisted yet use their formId as a
|
||||
// placeholder ID until the server returns the real one.
|
||||
id: content.id ?? content.formId,
|
||||
envelopeId: envelope.id,
|
||||
envelopeItemId: content.envelopeItemId,
|
||||
contentMeta: content.contentMeta,
|
||||
dataContentId: content.dataContentId,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
type MapLocalFieldsToFieldsOptions = {
|
||||
localFields: TLocalField[];
|
||||
envelope: TEditorEnvelope;
|
||||
|
||||
@@ -2,12 +2,46 @@ import type { DocumentDataVersion } from '@documenso/lib/types/document';
|
||||
import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download';
|
||||
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
|
||||
import { getRecipientColor } from '@documenso/ui/lib/recipient-colors';
|
||||
import type { Field, Recipient } from '@prisma/client';
|
||||
import type { EnvelopeContent, Field, Recipient } from '@prisma/client';
|
||||
import type React from 'react';
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { clamp } from 'remeda';
|
||||
|
||||
import type { TEnvelope } from '../../types/envelope';
|
||||
import { areContentsImprinted } from '../../utils/envelope';
|
||||
|
||||
/**
|
||||
* The minimum content data required to render a content.
|
||||
*/
|
||||
export type EnvelopeRenderContent = Pick<EnvelopeContent, 'id' | 'envelopeItemId' | 'contentMeta' | 'dataContentId'>;
|
||||
|
||||
import type { EnvelopePageItemsVisibility } from '../../types/envelope-page-items-visibility';
|
||||
import type { FieldRenderMode } from '../../universal/field-renderer/field-renderer';
|
||||
import { type ContentImages, useContentImages } from '../hooks/use-content-images';
|
||||
|
||||
export const ENVELOPE_VIEWER_MIN_ZOOM = 0.5;
|
||||
export const ENVELOPE_VIEWER_MAX_ZOOM = 2;
|
||||
export const ENVELOPE_VIEWER_ZOOM_STEP = 0.25;
|
||||
|
||||
/**
|
||||
* User controls for the envelope PDF viewer, shared across the editor, signing
|
||||
* and preview surfaces.
|
||||
*
|
||||
* Written by the viewer toolbar and consumed by the PDF viewer (zoom) and
|
||||
* `usePageRenderer` (fields/contents visibility).
|
||||
*/
|
||||
export type EnvelopeViewerControls = {
|
||||
zoom: number;
|
||||
setZoom: (zoom: number) => void;
|
||||
zoomIn: () => void;
|
||||
zoomOut: () => void;
|
||||
|
||||
fieldsVisibility: EnvelopePageItemsVisibility;
|
||||
setFieldsVisibility: (visibility: EnvelopePageItemsVisibility) => void;
|
||||
|
||||
contentsVisibility: EnvelopePageItemsVisibility;
|
||||
setContentsVisibility: (visibility: EnvelopePageItemsVisibility) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The signature data for an inserted signature field.
|
||||
@@ -28,10 +62,34 @@ export type PageRenderData = {
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
imageLoadingState: ImageLoadingState;
|
||||
|
||||
/**
|
||||
* Report whether everything drawn on the page is ready.
|
||||
*
|
||||
* A page renderer may have to load things of its own (e.g. the images of
|
||||
* the page's contents), which it can only start once the page image is
|
||||
* ready. The page waits for this so it is never shown half drawn.
|
||||
*/
|
||||
onReadyChange?: (isReady: boolean) => void;
|
||||
};
|
||||
|
||||
export type ImageLoadingState = 'loading' | 'loaded' | 'error';
|
||||
|
||||
export type PageSize = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* The unscaled sizes of the pages rendered so far, registered by the page
|
||||
* renderers so page relative geometry can be computed outside of them, e.g.
|
||||
* by the settings panel.
|
||||
*/
|
||||
export type PageSizeRegistry = {
|
||||
register: (envelopeItemId: string, pageNumber: number, size: PageSize) => void;
|
||||
get: (envelopeItemId: string, pageNumber: number) => PageSize | null;
|
||||
};
|
||||
|
||||
type EnvelopeRenderOverrideSettings = {
|
||||
mode?: FieldRenderMode;
|
||||
showRecipientTooltip?: boolean;
|
||||
@@ -60,6 +118,15 @@ type EnvelopeRenderProviderValue = {
|
||||
currentEnvelopeItem: EnvelopeRenderItem | null;
|
||||
setCurrentEnvelopeItem: (envelopeItemId: string) => void;
|
||||
fields: Field[];
|
||||
contents: EnvelopeRenderContent[];
|
||||
|
||||
/**
|
||||
* The images of the image contents, loaded ahead of rendering.
|
||||
*/
|
||||
contentImages: ContentImages;
|
||||
|
||||
pageSizes: PageSizeRegistry;
|
||||
|
||||
signatures: EnvelopeRenderFieldSignature[];
|
||||
recipients: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>[];
|
||||
getRecipientColorKey: (recipientId: number) => TRecipientColor;
|
||||
@@ -67,6 +134,8 @@ type EnvelopeRenderProviderValue = {
|
||||
renderError: boolean;
|
||||
setRenderError: (renderError: boolean) => void;
|
||||
overrideSettings?: EnvelopeRenderOverrideSettings;
|
||||
|
||||
viewerControls: EnvelopeViewerControls;
|
||||
};
|
||||
|
||||
interface EnvelopeRenderProviderProps {
|
||||
@@ -100,6 +169,13 @@ interface EnvelopeRenderProviderProps {
|
||||
*/
|
||||
fields?: Field[];
|
||||
|
||||
/**
|
||||
* Optional contents which are passed down to renderers for custom rendering needs.
|
||||
*
|
||||
* Only pass if the CustomRenderer you are passing in wants contents.
|
||||
*/
|
||||
contents?: EnvelopeRenderContent[];
|
||||
|
||||
/**
|
||||
* Optional inserted signature data for signature fields.
|
||||
*
|
||||
@@ -157,6 +233,7 @@ export const EnvelopeRenderProvider = ({
|
||||
envelope,
|
||||
envelopeItems: envelopeItemsFromProps,
|
||||
fields,
|
||||
contents,
|
||||
signatures,
|
||||
token,
|
||||
presignToken,
|
||||
@@ -166,6 +243,36 @@ export const EnvelopeRenderProvider = ({
|
||||
}: EnvelopeRenderProviderProps) => {
|
||||
const [renderError, setRenderError] = useState<boolean>(false);
|
||||
|
||||
const [zoom, setZoomInternal] = useState<number>(1);
|
||||
const [fieldsVisibility, setFieldsVisibility] = useState<EnvelopePageItemsVisibility>('visible');
|
||||
const [contentsVisibility, setContentsVisibility] = useState<EnvelopePageItemsVisibility>('visible');
|
||||
|
||||
const setZoom = useCallback((value: number) => {
|
||||
setZoomInternal(clamp(value, { min: ENVELOPE_VIEWER_MIN_ZOOM, max: ENVELOPE_VIEWER_MAX_ZOOM }));
|
||||
}, []);
|
||||
|
||||
const zoomIn = useCallback(() => {
|
||||
setZoomInternal((prev) => Math.min(ENVELOPE_VIEWER_MAX_ZOOM, prev + ENVELOPE_VIEWER_ZOOM_STEP));
|
||||
}, []);
|
||||
|
||||
const zoomOut = useCallback(() => {
|
||||
setZoomInternal((prev) => Math.max(ENVELOPE_VIEWER_MIN_ZOOM, prev - ENVELOPE_VIEWER_ZOOM_STEP));
|
||||
}, []);
|
||||
|
||||
const viewerControls = useMemo(
|
||||
(): EnvelopeViewerControls => ({
|
||||
zoom,
|
||||
setZoom,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
fieldsVisibility,
|
||||
setFieldsVisibility,
|
||||
contentsVisibility,
|
||||
setContentsVisibility,
|
||||
}),
|
||||
[zoom, setZoom, zoomIn, zoomOut, fieldsVisibility, contentsVisibility],
|
||||
);
|
||||
|
||||
const envelopeItems = useMemo(
|
||||
() =>
|
||||
[...envelopeItemsFromProps]
|
||||
@@ -213,6 +320,34 @@ export const EnvelopeRenderProvider = ({
|
||||
}
|
||||
}, [currentItem, envelopeItems]);
|
||||
|
||||
// Once an envelope is sealed the contents are imprinted onto the current
|
||||
// PDF and are not rendered, so their images are not loaded either.
|
||||
const isContentsImprinted = version === 'current' && areContentsImprinted(envelope.status);
|
||||
|
||||
const contentImages = useContentImages({
|
||||
envelopeId: envelope.id,
|
||||
envelopeItemId: currentItem?.id ?? null,
|
||||
contents: isContentsImprinted ? [] : (contents ?? []),
|
||||
token,
|
||||
presignToken,
|
||||
});
|
||||
|
||||
// Sizes do not drive rendering, so a ref avoids re-rendering the tree as
|
||||
// pages register themselves.
|
||||
const pageSizesRef = useRef(new Map<string, PageSize>());
|
||||
|
||||
const pageSizes = useMemo(
|
||||
(): PageSizeRegistry => ({
|
||||
register: (envelopeItemId, pageNumber, size) => {
|
||||
pageSizesRef.current.set(`${envelopeItemId}:${pageNumber}`, size);
|
||||
},
|
||||
get: (envelopeItemId, pageNumber) => {
|
||||
return pageSizesRef.current.get(`${envelopeItemId}:${pageNumber}`) ?? null;
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const recipientIds = useMemo(() => recipients.map((recipient) => recipient.id).sort(), [recipients]);
|
||||
|
||||
const getRecipientColorKey = useCallback(
|
||||
@@ -230,12 +365,16 @@ export const EnvelopeRenderProvider = ({
|
||||
currentEnvelopeItem: currentItem,
|
||||
setCurrentEnvelopeItem,
|
||||
fields: fields ?? [],
|
||||
contents: contents ?? [],
|
||||
contentImages,
|
||||
pageSizes,
|
||||
signatures: signatures ?? [],
|
||||
recipients,
|
||||
getRecipientColorKey,
|
||||
renderError,
|
||||
setRenderError,
|
||||
overrideSettings,
|
||||
viewerControls,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -148,3 +148,5 @@ export const CSC_INSTANCE_SIGNATURE_LEVEL = (): TSignatureLevel => {
|
||||
};
|
||||
|
||||
export const DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL = 'https://documen.so/enterprise-cta';
|
||||
|
||||
export const MAX_POSTGRES_INT = 2147483647;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { env } from '../utils/env';
|
||||
|
||||
/**
|
||||
* The maximum size of an image uploaded as envelope content, in MB.
|
||||
*/
|
||||
export const APP_CONTENT_IMAGE_UPLOAD_SIZE_LIMIT = Number(env('NEXT_PUBLIC_CONTENT_IMAGE_SIZE_UPLOAD_LIMIT')) || 10;
|
||||
|
||||
/**
|
||||
* The MIME types accepted for envelope content image uploads.
|
||||
*
|
||||
* SVG is deliberately excluded, since it can embed scripts and external
|
||||
* references and is not rasterized consistently across renderers.
|
||||
*/
|
||||
export const APP_CONTENT_IMAGE_MIME_TYPES = ['image/png', 'image/jpeg', 'image/webp'] as const;
|
||||
|
||||
/**
|
||||
* The number of contents allowed on a single envelope.
|
||||
*
|
||||
* 0 = Unlimited contents.
|
||||
*/
|
||||
export const DEFAULT_ENVELOPE_CONTENT_COUNT = 0;
|
||||
|
||||
/**
|
||||
* The number of image contents allowed on a single envelope.
|
||||
*
|
||||
* 0 = Unlimited image contents.
|
||||
*/
|
||||
export const DEFAULT_ENVELOPE_CONTENT_IMAGE_COUNT = 0;
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* How far a duplicated field is offset from its original, in page percent.
|
||||
*/
|
||||
export const EDITOR_DUPLICATE_ITEM_OFFSET = 3;
|
||||
@@ -8,17 +8,17 @@ import { getLastPageDimensions } from '@documenso/lib/server-only/pdf/get-page-s
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { signPdf } from '@documenso/signing';
|
||||
import { PDF } from '@libpdf/core';
|
||||
import type { DocumentData, Envelope, EnvelopeItem, Field } from '@prisma/client';
|
||||
import type { DocumentData, Envelope, EnvelopeContent, EnvelopeItem, Field } from '@prisma/client';
|
||||
import { DocumentStatus, EnvelopeType, RecipientRole, SigningStatus, WebhookTriggerEvents } from '@prisma/client';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { groupBy } from 'remeda';
|
||||
|
||||
import { NEXT_PRIVATE_USE_PLAYWRIGHT_PDF } from '../../../constants/app';
|
||||
import { AppError, AppErrorCode } from '../../../errors/app-error';
|
||||
import { loadContentImages } from '../../../server-only/data-content/load-content-images';
|
||||
import { getAuditLogsPdf } from '../../../server-only/htmltopdf/get-audit-logs-pdf';
|
||||
import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-pdf';
|
||||
import { insertFieldInPDFV1 } from '../../../server-only/pdf/insert-field-in-pdf-v1';
|
||||
import { insertFieldInPDFV2 } from '../../../server-only/pdf/insert-field-in-pdf-v2';
|
||||
import { insertPageOverlays } from '../../../server-only/pdf/insert-page-overlay';
|
||||
import { legacy_insertFieldInPDF } from '../../../server-only/pdf/legacy-insert-field-in-pdf';
|
||||
import { getTeamSettings } from '../../../server-only/team/get-team-settings';
|
||||
import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook';
|
||||
@@ -67,6 +67,7 @@ export const run = async ({ payload, io }: { payload: TSealDocumentJobDefinition
|
||||
signature: true,
|
||||
},
|
||||
},
|
||||
contents: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -211,12 +212,19 @@ export const run = async ({ payload, io }: { payload: TSealDocumentJobDefinition
|
||||
const newDocumentData: Array<{ oldDocumentDataId: string; newDocumentDataId: string }> = [];
|
||||
|
||||
for (const { envelopeItem, pdfData } of prefetchedItems) {
|
||||
const envelopeItemFields = envelope.envelopeItems.find((item) => item.id === envelopeItem.id)?.field;
|
||||
const originalEnvelopeItem = envelope.envelopeItems.find((item) => item.id === envelopeItem.id);
|
||||
|
||||
if (!envelopeItemFields) {
|
||||
if (!originalEnvelopeItem) {
|
||||
throw new Error(`Envelope item fields not found for envelope item ${envelopeItem.id}`);
|
||||
}
|
||||
|
||||
const envelopeItemFields = originalEnvelopeItem.field;
|
||||
|
||||
// Contents are inserted into the PDF when the envelope is sent, so the
|
||||
// current bytes already carry them and must not be drawn again. Only a
|
||||
// reseal starts over from the `initialData` and has to reinsert them.
|
||||
const envelopeItemContents = isResealing ? originalEnvelopeItem.contents : [];
|
||||
|
||||
let certificateDoc: PDF | null = null;
|
||||
let auditLogDoc: PDF | null = null;
|
||||
|
||||
@@ -278,6 +286,7 @@ export const run = async ({ payload, io }: { payload: TSealDocumentJobDefinition
|
||||
envelope,
|
||||
envelopeItem,
|
||||
envelopeItemFields,
|
||||
envelopeItemContents,
|
||||
isRejected,
|
||||
rejectionReason,
|
||||
pdfData,
|
||||
@@ -361,6 +370,7 @@ type DecorateAndSignPdfOptions = {
|
||||
envelope: Pick<Envelope, 'id' | 'title' | 'useLegacyFieldInsertion' | 'internalVersion'>;
|
||||
envelopeItem: EnvelopeItem & { documentData: DocumentData };
|
||||
envelopeItemFields: Field[];
|
||||
envelopeItemContents: EnvelopeContent[];
|
||||
isRejected: boolean;
|
||||
rejectionReason: string;
|
||||
pdfData: Uint8Array;
|
||||
@@ -375,6 +385,7 @@ const decorateAndSignPdf = async ({
|
||||
envelope,
|
||||
envelopeItem,
|
||||
envelopeItemFields,
|
||||
envelopeItemContents,
|
||||
isRejected,
|
||||
rejectionReason,
|
||||
pdfData,
|
||||
@@ -388,10 +399,9 @@ const decorateAndSignPdf = async ({
|
||||
// Upgrade to PDF 1.7 for better compatibility with signing
|
||||
pdfDoc.upgradeVersion('1.7');
|
||||
|
||||
// Add rejection stamp if the document is rejected
|
||||
if (isRejected) {
|
||||
await addRejectionStampToPdf(pdfDoc, rejectionReason);
|
||||
}
|
||||
// The pages of the original document, before the certificate and audit log
|
||||
// pages are appended, so the rejection stamp is only applied to those.
|
||||
const documentPageCount = pdfDoc.getPageCount();
|
||||
|
||||
if (certificateDoc) {
|
||||
await pdfDoc.copyPagesFrom(
|
||||
@@ -430,56 +440,20 @@ const decorateAndSignPdf = async ({
|
||||
|
||||
// Handle V2 envelope insertions.
|
||||
if (envelope.internalVersion === 2) {
|
||||
const fieldsGroupedByPage = groupBy(envelopeItemFields, (field) => field.page);
|
||||
const images = await loadContentImages(envelopeItemContents);
|
||||
|
||||
for (const [pageNumber, fields] of Object.entries(fieldsGroupedByPage)) {
|
||||
const page = pdfDoc.getPage(Number(pageNumber) - 1);
|
||||
await insertPageOverlays({
|
||||
pdfDoc,
|
||||
fields: envelopeItemFields,
|
||||
contents: envelopeItemContents,
|
||||
images,
|
||||
});
|
||||
}
|
||||
|
||||
if (!page) {
|
||||
throw new Error(`Page ${pageNumber} does not exist`);
|
||||
}
|
||||
|
||||
const pageWidth = page.width;
|
||||
const pageHeight = page.height;
|
||||
|
||||
const overlayBytes = await insertFieldInPDFV2({
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
fields,
|
||||
});
|
||||
|
||||
const overlayPdf = await PDF.load(overlayBytes);
|
||||
|
||||
const embeddedPage = await pdfDoc.embedPage(overlayPdf, 0);
|
||||
|
||||
// Rotate the page to the orientation that the react-pdf renders on the frontend.
|
||||
let translateX = 0;
|
||||
let translateY = 0;
|
||||
|
||||
switch (page.rotation) {
|
||||
case 90:
|
||||
translateX = pageHeight;
|
||||
translateY = 0;
|
||||
break;
|
||||
case 180:
|
||||
translateX = pageWidth;
|
||||
translateY = pageHeight;
|
||||
break;
|
||||
case 270:
|
||||
translateX = 0;
|
||||
translateY = pageWidth;
|
||||
break;
|
||||
}
|
||||
|
||||
// Draw the overlay on the page
|
||||
page.drawPage(embeddedPage, {
|
||||
x: translateX,
|
||||
y: translateY,
|
||||
rotate: {
|
||||
angle: page.rotation,
|
||||
},
|
||||
});
|
||||
}
|
||||
// Add the rejection stamp after the fields and contents have been inserted
|
||||
// so it is drawn on top of them, rather than being hidden by opaque contents.
|
||||
if (isRejected) {
|
||||
await addRejectionStampToPdf(pdfDoc, rejectionReason, { pageCount: documentPageCount });
|
||||
}
|
||||
|
||||
// Re-flatten the form to handle our checkbox and radio fields that
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
export const ADMIN_SEARCH_RESULTS_PER_TYPE = 5;
|
||||
import { MAX_POSTGRES_INT } from '../../constants/app';
|
||||
|
||||
const MAX_POSTGRES_INT = 2147483647;
|
||||
export const ADMIN_SEARCH_RESULTS_PER_TYPE = 5;
|
||||
|
||||
const GROUP_ORDER = ['document', 'user', 'organisation', 'team', 'recipient', 'subscription'] as const;
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { DataContentType, ZDataContentMetaSchema } from '../../types/data-content-meta';
|
||||
import { generateDatabaseId } from '../../universal/id';
|
||||
import { putFileServerSide } from '../../universal/upload/put-file.server';
|
||||
import { getNormalizedImageFileName, type NormalizeImageOptions, normalizeImage } from './normalize-image';
|
||||
|
||||
export type CreateDataContentImageOptions = {
|
||||
file: {
|
||||
name: string;
|
||||
arrayBuffer: () => Promise<ArrayBuffer>;
|
||||
};
|
||||
normalizeOptions?: NormalizeImageOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize an uploaded image, store it and create the data content record
|
||||
* describing it.
|
||||
*
|
||||
* The record is created unlinked. It is attached to an envelope content via
|
||||
* `setEnvelopeContents`.
|
||||
*/
|
||||
export const createDataContentImage = async ({ file, normalizeOptions }: CreateDataContentImageOptions) => {
|
||||
const normalized = await normalizeImage(Buffer.from(await file.arrayBuffer()), normalizeOptions);
|
||||
|
||||
const fileName = getNormalizedImageFileName(file.name, normalized.mimeType);
|
||||
|
||||
// Validate the metadata before anything is written to storage so a bad
|
||||
// name or dimension cannot leave behind an orphaned object.
|
||||
const parsedMetadata = ZDataContentMetaSchema.safeParse({
|
||||
type: DataContentType.IMAGE,
|
||||
width: normalized.width,
|
||||
height: normalized.height,
|
||||
mimeType: normalized.mimeType,
|
||||
fileName,
|
||||
fileSize: normalized.buffer.byteLength,
|
||||
});
|
||||
|
||||
if (!parsedMetadata.success) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'Invalid image meta',
|
||||
});
|
||||
}
|
||||
|
||||
const { type, data } = await putFileServerSide({
|
||||
name: fileName,
|
||||
type: normalized.mimeType,
|
||||
arrayBuffer: async () => Promise.resolve(normalized.buffer),
|
||||
});
|
||||
|
||||
return await prisma.dataContent.create({
|
||||
data: {
|
||||
id: generateDatabaseId('data'),
|
||||
type,
|
||||
data,
|
||||
metadata: parsedMetadata.data,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { Image as SkiaImage } from '@documenso/skia-canvas';
|
||||
import type { EnvelopeContent } from '@prisma/client';
|
||||
|
||||
import { DataContentType } from '../../types/data-content-meta';
|
||||
import type { ContentImageMap, ContentImageSource } from '../../universal/content-renderer/content-renderer';
|
||||
import { getFileServerSide } from '../../universal/upload/get-file.server';
|
||||
import { getDataContentIds } from '../../utils/envelope-content';
|
||||
|
||||
/**
|
||||
* Load and decode the images attached to the given contents, keyed by data
|
||||
* content ID, ready for the content renderer to draw on the server.
|
||||
*
|
||||
* Data contents which are not images are skipped.
|
||||
*/
|
||||
export const loadContentImages = async (
|
||||
contents: Pick<EnvelopeContent, 'dataContentId'>[],
|
||||
): Promise<ContentImageMap> => {
|
||||
const dataContentIds = getDataContentIds(contents);
|
||||
|
||||
if (dataContentIds.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const dataContents = await prisma.dataContent.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: dataContentIds,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const entries = await Promise.all(
|
||||
dataContents.map(async (dataContent): Promise<[string, ContentImageSource] | null> => {
|
||||
if (dataContent.metadata.type !== DataContentType.IMAGE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bytes = await getFileServerSide({
|
||||
type: dataContent.type,
|
||||
data: dataContent.data,
|
||||
});
|
||||
|
||||
return [dataContent.id, new SkiaImage(Buffer.from(bytes)) as unknown as ContentImageSource];
|
||||
}),
|
||||
);
|
||||
|
||||
return new Map(entries.filter((entry) => entry !== null));
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user