mirror of
https://github.com/documenso/documenso.git
synced 2026-09-30 08:44:39 +10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78ceb48425 | ||
|
|
b75a66d5c0 |
@@ -4,7 +4,6 @@ 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';
|
||||
@@ -32,7 +31,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 { Plural, Trans, useLingui } from '@lingui/react/macro';
|
||||
import { 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';
|
||||
@@ -163,25 +162,6 @@ 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';
|
||||
@@ -195,26 +175,8 @@ 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,
|
||||
contentsMissingImages,
|
||||
contentLimits,
|
||||
]);
|
||||
}, [envelope.recipients, recipientsMissingRequiredEmail, recipientsMissingSignatureFields]);
|
||||
|
||||
const onFormSubmit = async ({ meta }: TEnvelopeDistributeFormSchema) => {
|
||||
try {
|
||||
@@ -558,33 +520,6 @@ 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,7 +44,6 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E
|
||||
defaultValues: {
|
||||
includeRecipients: true,
|
||||
includeFields: true,
|
||||
includeContents: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -68,14 +67,13 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E
|
||||
});
|
||||
|
||||
const onDuplicate = async () => {
|
||||
const { includeRecipients, includeFields, includeContents } = form.getValues();
|
||||
const { includeRecipients, includeFields } = form.getValues();
|
||||
|
||||
try {
|
||||
await duplicateEnvelope({
|
||||
envelopeId,
|
||||
includeRecipients,
|
||||
includeFields: includeRecipients && includeFields,
|
||||
includeContents,
|
||||
});
|
||||
} catch {
|
||||
toast({
|
||||
@@ -161,23 +159,6 @@ 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 { formatFileSize, megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
|
||||
import { 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,6 +195,18 @@ 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,7 +66,6 @@ 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,
|
||||
@@ -104,6 +103,7 @@ type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDoc
|
||||
|
||||
export type TemplateUseDialogProps = {
|
||||
envelopeId: string;
|
||||
templateId: number;
|
||||
templateSigningOrder?: DocumentSigningOrder | null;
|
||||
recipients: TRecipientLite[];
|
||||
documentDistributionMethod?: DocumentDistributionMethod;
|
||||
@@ -116,6 +116,7 @@ export function TemplateUseDialog({
|
||||
documentDistributionMethod = DocumentDistributionMethod.EMAIL,
|
||||
documentRootPath,
|
||||
envelopeId,
|
||||
templateId,
|
||||
templateSigningOrder,
|
||||
trigger,
|
||||
}: TemplateUseDialogProps) {
|
||||
@@ -144,7 +145,6 @@ 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.envelope.use.useMutation();
|
||||
const { mutateAsync: createDocumentFromTemplate } = trpc.template.createDocumentFromTemplate.useMutation();
|
||||
|
||||
/**
|
||||
* Track the most recently uploaded file so its name can be used as the document name.
|
||||
@@ -217,29 +217,24 @@ export function TemplateUseDialog({
|
||||
(item): item is typeof item & { data: File } => item.data !== undefined,
|
||||
);
|
||||
|
||||
// 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,
|
||||
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,
|
||||
recipients: data.recipients,
|
||||
distributeDocument: data.distributeDocument,
|
||||
includeContents: data.includeContents,
|
||||
customDocumentData: customFilesToUpload.map((item, index) => ({
|
||||
identifier: index,
|
||||
envelopeItemId: item.envelopeItemId,
|
||||
})),
|
||||
customDocumentData,
|
||||
...(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`),
|
||||
@@ -247,7 +242,7 @@ export function TemplateUseDialog({
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
let documentPath = `${documentRootPath}/${createdEnvelopeId}`;
|
||||
let documentPath = `${documentRootPath}/${envelopeId}`;
|
||||
|
||||
if (data.distributeDocument && documentDistributionMethod === DocumentDistributionMethod.NONE) {
|
||||
documentPath += '?action=view-signing-links';
|
||||
@@ -469,46 +464,6 @@ 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,5 +1,4 @@
|
||||
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';
|
||||
@@ -99,6 +98,16 @@ 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'],
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,31 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,136 +0,0 @@
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import {
|
||||
APP_CONTENT_IMAGE_MIME_TYPES,
|
||||
APP_CONTENT_IMAGE_UPLOAD_SIZE_LIMIT,
|
||||
} from '@documenso/lib/constants/envelope-content';
|
||||
import { formatFileSize, megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { FileImageIcon, ImageUpIcon, RefreshCwIcon, TrashIcon } from 'lucide-react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { ContentImageUploadDialog } from '~/components/general/envelope-editor/content-image-upload-dialog';
|
||||
|
||||
type EditorContentImageSettingsProps = {
|
||||
formId: string;
|
||||
dataContentId?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Settings for image contents: the image itself.
|
||||
*
|
||||
* Uploads go through the shared upload dialog. This panel only offers the
|
||||
* ways to start one (drop, click, replace) and to remove the current image.
|
||||
* Geometry (page, position, size and rotation) is managed on the canvas.
|
||||
*/
|
||||
export const EditorContentImageSettings = ({ formId, dataContentId }: EditorContentImageSettingsProps) => {
|
||||
const { t } = useLingui();
|
||||
const { editorContents } = useCurrentEnvelopeEditor();
|
||||
const { contentImages } = useCurrentEnvelopeRender();
|
||||
|
||||
const image = dataContentId ? contentImages.images.get(dataContentId) : undefined;
|
||||
const details = dataContentId ? contentImages.details.get(dataContentId) : undefined;
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
accept: Object.fromEntries(APP_CONTENT_IMAGE_MIME_TYPES.map((mimeType) => [mimeType, []])),
|
||||
multiple: false,
|
||||
maxSize: megabytesToBytes(APP_CONTENT_IMAGE_UPLOAD_SIZE_LIMIT),
|
||||
// A dropped file goes straight to the dialog; a click opens the picker
|
||||
// through it instead of the dropzone's own input.
|
||||
noClick: true,
|
||||
onDrop: (acceptedFiles) => {
|
||||
const [file] = acceptedFiles;
|
||||
|
||||
if (file) {
|
||||
void ContentImageUploadDialog.call({ formId, file });
|
||||
}
|
||||
},
|
||||
// A file which is too large or not a supported image goes to the dialog
|
||||
// as well, which shows why it cannot be used. When several files were
|
||||
// dropped, the first is used.
|
||||
onDropRejected: (fileRejections) => {
|
||||
const [rejection] = fileRejections;
|
||||
|
||||
if (rejection) {
|
||||
void ContentImageUploadDialog.call({ formId, file: rejection.file });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (dataContentId) {
|
||||
return (
|
||||
<div className="mt-2 flex items-center gap-3 rounded-lg border border-border p-2">
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded-md bg-muted/40">
|
||||
<FileImageIcon className="h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-foreground text-xs">
|
||||
<Trans>Image</Trans>
|
||||
</p>
|
||||
|
||||
<p className="truncate text-muted-foreground text-xs">
|
||||
{[details ? formatFileSize(details.fileSize) : null, image ? `${image.width} * ${image.height}` : null]
|
||||
.filter((part) => part !== null)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-row gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 px-2 text-xs"
|
||||
title={t`Replace image`}
|
||||
onClick={() => void ContentImageUploadDialog.call({ formId })}
|
||||
>
|
||||
<RefreshCwIcon className="h-4 w-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 px-2 text-xs"
|
||||
title={t`Remove image`}
|
||||
onClick={() => editorContents.updateContentByFormId(formId, { dataContentId: null, data: undefined })}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
{...getRootProps({
|
||||
role: 'button',
|
||||
tabIndex: 0,
|
||||
onClick: () => void ContentImageUploadDialog.call({ formId }),
|
||||
onKeyDown: (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
void ContentImageUploadDialog.call({ formId });
|
||||
}
|
||||
},
|
||||
})}
|
||||
className={cn(
|
||||
'mt-2 flex cursor-pointer flex-col items-center justify-center gap-1.5 rounded-lg border border-border border-dashed px-4 py-6 text-center transition-colors hover:border-primary/60',
|
||||
isDragActive && 'border-primary bg-primary/5',
|
||||
)}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<ImageUpIcon className="h-6 w-6 text-muted-foreground" strokeWidth={1.5} />
|
||||
<p className="text-foreground text-sm">
|
||||
<Trans>Click to upload or drag and drop</Trans>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<Trans>PNG, JPEG or WebP up to {APP_CONTENT_IMAGE_UPLOAD_SIZE_LIMIT} MB</Trans>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,43 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,105 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,78 +0,0 @@
|
||||
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.
|
||||
// biome-ignore lint/suspicious/noExplicitAny: See above
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type FormControlType = Control<any>;
|
||||
|
||||
export const EditorGenericFontSizeField = ({
|
||||
|
||||
@@ -54,8 +54,6 @@ 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,
|
||||
@@ -187,54 +185,6 @@ 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,18 +1,7 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { AlertTriangleIcon } from 'lucide-react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
export const DirectTemplateInvalidPageView = () => {
|
||||
return (
|
||||
<div className="mx-auto flex h-[70vh] w-full max-w-md flex-col items-center justify-center">
|
||||
<div>
|
||||
@@ -23,20 +12,10 @@ export const DirectTemplateInvalidPageView = ({ reason }: DirectTemplateInvalidP
|
||||
</h1>
|
||||
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
{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()}
|
||||
<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>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+1
-7
@@ -235,10 +235,7 @@ export const DocumentSigningPageViewV2 = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="embed--DocumentContainer min-w-0 flex-1 overflow-x-auto overflow-y-auto"
|
||||
ref={scrollableContainerRef}
|
||||
>
|
||||
<div className="embed--DocumentContainer min-w-0 flex-1 overflow-y-auto" ref={scrollableContainerRef}>
|
||||
<div className="flex flex-col">
|
||||
{/* Horizontal envelope item selector */}
|
||||
{envelopeItems.length > 1 && (
|
||||
@@ -270,9 +267,6 @@ 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
@@ -1,131 +0,0 @@
|
||||
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
@@ -1,423 +0,0 @@
|
||||
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
@@ -1,169 +0,0 @@
|
||||
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
@@ -1,40 +0,0 @@
|
||||
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
@@ -1,34 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,59 +0,0 @@
|
||||
import { CONTENT_GROUP_NODE_NAME } from '@documenso/lib/universal/content-renderer/content-renderer';
|
||||
import type Konva from 'konva';
|
||||
import type { RefObject } from 'react';
|
||||
|
||||
/**
|
||||
* The shared canvas handles and page metrics passed to the envelope canvas
|
||||
* hooks, sourced from `usePageRenderer` and the page render data.
|
||||
*/
|
||||
export type EnvelopeCanvas = {
|
||||
stage: RefObject<Konva.Stage | null>;
|
||||
pageLayer: RefObject<Konva.Layer | null>;
|
||||
|
||||
/**
|
||||
* The scale of the stage relative to the unscaled page.
|
||||
*/
|
||||
scale: number;
|
||||
|
||||
pageNumber: number;
|
||||
|
||||
/**
|
||||
* The raw page size in pixels.
|
||||
*/
|
||||
unscaledViewport: { width: number; height: number };
|
||||
|
||||
/**
|
||||
* The page size in pixels as rendered on the stage.
|
||||
*/
|
||||
scaledViewport: { width: number; height: number };
|
||||
};
|
||||
|
||||
export type EnvelopeCanvasSelectionKind = 'field' | 'content';
|
||||
|
||||
/**
|
||||
* The current canvas selection. Fields and contents share one transformer, so
|
||||
* only one kind can be selected at a time.
|
||||
*/
|
||||
export type EnvelopeCanvasSelection = {
|
||||
kind: EnvelopeCanvasSelectionKind;
|
||||
groups: Konva.Group[];
|
||||
} | null;
|
||||
|
||||
/**
|
||||
* The Konva group names for each selectable kind.
|
||||
*/
|
||||
export const ENVELOPE_CANVAS_GROUP_NAMES: Record<EnvelopeCanvasSelectionKind, string> = {
|
||||
// Defined by the field renderer in `field-generic-items.ts`.
|
||||
field: 'field-group',
|
||||
content: CONTENT_GROUP_NODE_NAME,
|
||||
};
|
||||
|
||||
/**
|
||||
* A box in scaled stage coordinates, e.g. from a client rect.
|
||||
*/
|
||||
export type EnvelopeCanvasBox = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
-112
@@ -1,112 +0,0 @@
|
||||
import Konva from 'konva';
|
||||
|
||||
import type { EnvelopeCanvasSelectionKind } from './envelope-canvas-types';
|
||||
|
||||
type ReconcileEnvelopeCanvasGroupsOptions<T> = {
|
||||
layer: Konva.Layer;
|
||||
|
||||
/**
|
||||
* The Konva group name of the items being reconciled.
|
||||
*/
|
||||
groupName: string;
|
||||
|
||||
items: T[];
|
||||
getRenderId: (item: T) => string;
|
||||
render: (item: T) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sync a layer's groups of a given name with a list of items.
|
||||
*
|
||||
* Groups whose ID no longer matches an item are destroyed, then every item is
|
||||
* (re)rendered so existing groups pick up any changes.
|
||||
*/
|
||||
export const reconcileEnvelopeCanvasGroups = <T>({
|
||||
layer,
|
||||
groupName,
|
||||
items,
|
||||
getRenderId,
|
||||
render,
|
||||
}: ReconcileEnvelopeCanvasGroupsOptions<T>) => {
|
||||
const renderIds = new Set(items.map(getRenderId));
|
||||
|
||||
layer.find('Group').forEach((group) => {
|
||||
if (group.name() === groupName && !renderIds.has(group.id())) {
|
||||
group.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
for (const item of items) {
|
||||
render(item);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Filter selected groups down to those which are still attached to the stage
|
||||
* and still correspond to an item on the page, e.g. after items are deleted
|
||||
* or the page is resynced.
|
||||
*/
|
||||
export const getLiveEnvelopeCanvasGroups = (groups: Konva.Group[], isOnPage: (renderId: string) => boolean) => {
|
||||
return groups.filter((group) => Boolean(group.getStage()) && Boolean(group.getParent()) && isOnPage(group.id()));
|
||||
};
|
||||
|
||||
type SyncEditorSelectionToCanvasOptions = {
|
||||
layer: Konva.Layer;
|
||||
kind: EnvelopeCanvasSelectionKind;
|
||||
|
||||
/**
|
||||
* The form ID of the item selected within the editor, if any.
|
||||
*/
|
||||
editorFormId: string | null;
|
||||
isOnPage: (renderId: string) => boolean;
|
||||
|
||||
/**
|
||||
* The currently selected groups of this kind.
|
||||
*/
|
||||
selectedGroups: Konva.Group[];
|
||||
|
||||
select: (kind: EnvelopeCanvasSelectionKind, nodes: Konva.Node[]) => void;
|
||||
clear: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sync the editor's single selected item onto the canvas selection.
|
||||
*
|
||||
* Creating an item marks it as selected within the editor, so this makes a
|
||||
* newly placed item show its transformer immediately without a second click.
|
||||
* It also clears the canvas selection when the editor selection is cleared,
|
||||
* so a stale transformer can't linger.
|
||||
*
|
||||
* Must run after the groups have been rendered so the item's group exists.
|
||||
*/
|
||||
export const syncEditorSelectionToCanvas = ({
|
||||
layer,
|
||||
kind,
|
||||
editorFormId,
|
||||
isOnPage,
|
||||
selectedGroups,
|
||||
select,
|
||||
clear,
|
||||
}: SyncEditorSelectionToCanvasOptions) => {
|
||||
const isSingleSelection = selectedGroups.length === 1;
|
||||
|
||||
if (editorFormId && isOnPage(editorFormId)) {
|
||||
const isAlreadySelected = isSingleSelection && selectedGroups[0].id() === editorFormId;
|
||||
|
||||
if (isAlreadySelected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const groupToSelect = layer.findOne(`#${editorFormId}`);
|
||||
|
||||
if (groupToSelect instanceof Konva.Group) {
|
||||
select(kind, [groupToSelect]);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (editorFormId === null && isSingleSelection) {
|
||||
clear();
|
||||
}
|
||||
};
|
||||
-448
@@ -1,448 +0,0 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import type { TLocalContent } from '@documenso/lib/client-only/hooks/use-editor-contents';
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import type { TEnvelopeContentMeta } from '@documenso/lib/types/envelope-content-meta';
|
||||
import {
|
||||
CONTENT_META_DEFAULT_VALUES,
|
||||
CONTENT_SHAPE_META_DEFAULT_VALUES_BY_SHAPE,
|
||||
EnvelopeContentShapeType,
|
||||
EnvelopeContentType,
|
||||
} from '@documenso/lib/types/envelope-content-meta';
|
||||
import {
|
||||
readContentGroupTransform,
|
||||
resolveContentMetaFromTransform,
|
||||
} from '@documenso/lib/universal/content-renderer/content-geometry';
|
||||
import {
|
||||
MIN_CONTENT_HEIGHT_PX,
|
||||
MIN_CONTENT_WIDTH_PX,
|
||||
} from '@documenso/lib/universal/content-renderer/content-renderer';
|
||||
import { renderContent } from '@documenso/lib/universal/content-renderer/render-content';
|
||||
import { getLocalContentOrderId, sortContentsForRender } from '@documenso/lib/utils/envelope-content';
|
||||
import { getClientSideContentTranslations } from '@documenso/lib/utils/envelope-content-translations';
|
||||
import type { PercentageBox } from '@documenso/lib/utils/geometry';
|
||||
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import Konva from 'konva';
|
||||
import type { KonvaEventObject } from 'konva/lib/Node';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import type { ContentDragDropItem } from '../envelope-editor-content-drag-drop';
|
||||
import type { EnvelopeCanvas, EnvelopeCanvasBox } from './envelope-canvas-types';
|
||||
import { ENVELOPE_CANVAS_GROUP_NAMES } from './envelope-canvas-types';
|
||||
import {
|
||||
getLiveEnvelopeCanvasGroups,
|
||||
reconcileEnvelopeCanvasGroups,
|
||||
syncEditorSelectionToCanvas,
|
||||
} from './reconcile-envelope-canvas-groups';
|
||||
import { findEnvelopeCanvasGroupsInBox } from './use-envelope-canvas-marquee';
|
||||
import { useEnvelopeCanvasPendingCreation } from './use-envelope-canvas-pending-creation';
|
||||
import type { EnvelopeCanvasSelectionApi } from './use-envelope-canvas-selection';
|
||||
|
||||
const PENDING_CONTENT_NODE_NAME = 'pending-content-creation';
|
||||
|
||||
type UseEnvelopeCanvasContentsLayerOptions = {
|
||||
canvas: EnvelopeCanvas;
|
||||
selection: EnvelopeCanvasSelectionApi;
|
||||
|
||||
/**
|
||||
* Whether contents can currently be edited on the canvas.
|
||||
*/
|
||||
isEditable: boolean;
|
||||
|
||||
/**
|
||||
* Whether the envelope has used up the organisation's content allowance,
|
||||
* in which case no further contents can be created from the canvas.
|
||||
*/
|
||||
isContentLimitReached: boolean;
|
||||
|
||||
applyPageItemsVisibility: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders and manages the contents of the current page on the canvas.
|
||||
*
|
||||
* Owns content rendering, z-ordering relative to fields, geometry write back,
|
||||
* the marquee-to-create flow and the selection actions for contents.
|
||||
*/
|
||||
export const useEnvelopeCanvasContentsLayer = ({
|
||||
canvas,
|
||||
selection,
|
||||
isEditable,
|
||||
isContentLimitReached,
|
||||
applyPageItemsVisibility,
|
||||
}: UseEnvelopeCanvasContentsLayerOptions) => {
|
||||
const { i18n } = useLingui();
|
||||
const analytics = useAnalytics();
|
||||
const { envelope, editorContents, selectedEditorTab } = useCurrentEnvelopeEditor();
|
||||
const { currentEnvelopeItem, contentImages, setRenderError } = useCurrentEnvelopeRender();
|
||||
|
||||
const { stage, pageLayer, scale, pageNumber, unscaledViewport } = canvas;
|
||||
const { contentGroups: selectedGroups } = selection;
|
||||
|
||||
/**
|
||||
* The rectangle drawn via the marquee which is pending a content type choice.
|
||||
*/
|
||||
const pending = useEnvelopeCanvasPendingCreation({ canvas, nodeName: PENDING_CONTENT_NODE_NAME });
|
||||
|
||||
/**
|
||||
* The page's contents in stacking order, so rendering them in sequence
|
||||
* puts the last one on top. Local contents are ordered by their form ID as
|
||||
* the tiebreak, the same way persisted contents are ordered by their ID.
|
||||
*/
|
||||
const localPageContents = useMemo(
|
||||
() =>
|
||||
sortContentsForRender(
|
||||
editorContents.localContents
|
||||
.filter(
|
||||
(content) => content.contentMeta.page === pageNumber && content.envelopeItemId === currentEnvelopeItem?.id,
|
||||
)
|
||||
.map((content) => ({ ...content, id: getLocalContentOrderId(content) })),
|
||||
(content) => content.contentMeta.zIndex,
|
||||
),
|
||||
[editorContents.localContents, pageNumber, currentEnvelopeItem?.id],
|
||||
);
|
||||
|
||||
const isOnPage = (formId: string) => localPageContents.some((content) => content.formId === formId);
|
||||
|
||||
/**
|
||||
* Write the new geometry of a content back into its content meta after a drag
|
||||
* or resize/rotate gesture.
|
||||
*/
|
||||
const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
|
||||
const isDragEvent = event.type === 'dragend';
|
||||
|
||||
const contentGroup = event.target as Konva.Group;
|
||||
const contentFormId = contentGroup.id();
|
||||
|
||||
const content = editorContents.getContentByFormId(contentFormId);
|
||||
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
|
||||
editorContents.updateContentByFormId(contentFormId, {
|
||||
contentMeta: resolveContentMetaFromTransform(
|
||||
content.contentMeta,
|
||||
readContentGroupTransform(contentGroup),
|
||||
isDragEvent ? 'drag' : 'transform',
|
||||
unscaledViewport.width,
|
||||
unscaledViewport.height,
|
||||
),
|
||||
});
|
||||
|
||||
// Select the content if it is not already selected.
|
||||
if (isDragEvent && !selection.isSelected(contentGroup)) {
|
||||
selection.select('content', [contentGroup]);
|
||||
}
|
||||
|
||||
pageLayer.current?.batchDraw();
|
||||
};
|
||||
|
||||
const unsafeRenderContent = (content: TLocalContent) => {
|
||||
if (!pageLayer.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { contentGroup } = renderContent(
|
||||
{
|
||||
renderId: content.formId,
|
||||
contentMeta: content.contentMeta,
|
||||
dataContentId: content.dataContentId,
|
||||
},
|
||||
{
|
||||
pageLayer: pageLayer.current,
|
||||
pageWidth: unscaledViewport.width,
|
||||
pageHeight: unscaledViewport.height,
|
||||
scale,
|
||||
mode: 'edit',
|
||||
editable: isEditable,
|
||||
translations: getClientSideContentTranslations(i18n),
|
||||
hoverOutlineColor: getRecipientColorStyles('green').baseRing,
|
||||
images: contentImages.images,
|
||||
},
|
||||
);
|
||||
|
||||
// Contents are inert while the fields tab is active so they behave as part
|
||||
// of the document. Their stacking relative to the fields is applied to
|
||||
// all of them together afterwards, see `applyContentsStacking`.
|
||||
contentGroup.listening(isEditable);
|
||||
|
||||
if (!isEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
contentGroup.off('click');
|
||||
contentGroup.off('transformend');
|
||||
contentGroup.off('dragend');
|
||||
|
||||
// A plain click selects just this content, shift + click toggles it
|
||||
// in/out of the current selection.
|
||||
contentGroup.on('click', (event) => {
|
||||
if (event.evt.shiftKey) {
|
||||
selection.toggle('content', contentGroup);
|
||||
} else {
|
||||
selection.select('content', [contentGroup]);
|
||||
}
|
||||
|
||||
pageLayer.current?.batchDraw();
|
||||
});
|
||||
|
||||
contentGroup.on('transformend', handleResizeOrMove);
|
||||
contentGroup.on('dragend', handleResizeOrMove);
|
||||
};
|
||||
|
||||
const renderContentOnLayer = (content: TLocalContent) => {
|
||||
try {
|
||||
unsafeRenderContent(content);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'editor',
|
||||
location: 'envelope_page_render',
|
||||
envelopeId: envelope.id,
|
||||
});
|
||||
|
||||
setRenderError(true);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Place the contents as a band relative to the fields, keeping their
|
||||
* stacking order intact: above the muted fields while the contents tab is
|
||||
* active so they are fully visible and clickable, beneath the fields
|
||||
* otherwise as part of the document.
|
||||
*
|
||||
* Konva stacks by child order, so the band is moved one group at a time.
|
||||
* Sending to the bottom iterates in reverse so the last content stays on
|
||||
* top, as when sending to the top in order.
|
||||
*/
|
||||
const applyContentsStacking = () => {
|
||||
const layer = pageLayer.current;
|
||||
|
||||
if (!layer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const groups = localPageContents.flatMap((content) => {
|
||||
const group = layer.findOne(`#${content.formId}`);
|
||||
|
||||
return group instanceof Konva.Group ? [group] : [];
|
||||
});
|
||||
|
||||
if (selectedEditorTab === 'contents') {
|
||||
for (const group of groups) {
|
||||
group.moveToTop();
|
||||
}
|
||||
} else {
|
||||
for (const group of [...groups].reverse()) {
|
||||
group.moveToBottom();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const renderAll = () => {
|
||||
for (const content of localPageContents) {
|
||||
renderContentOnLayer(content);
|
||||
}
|
||||
|
||||
applyContentsStacking();
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a marquee selection box into a content selection, or into a
|
||||
* pending content creation when nothing was selected and the box is large
|
||||
* enough.
|
||||
*/
|
||||
const selectInBox = (box: EnvelopeCanvasBox) => {
|
||||
const currentStage = stage.current;
|
||||
|
||||
if (!currentStage || !isEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const groupsInBox = findEnvelopeCanvasGroupsInBox(currentStage, ENVELOPE_CANVAS_GROUP_NAMES.content, box);
|
||||
|
||||
selection.select('content', groupsInBox);
|
||||
|
||||
const unscaledBoxWidth = box.width / scale;
|
||||
const unscaledBoxHeight = box.height / scale;
|
||||
|
||||
// Create a content if no items are selected or the size is too small.
|
||||
if (
|
||||
groupsInBox.length === 0 &&
|
||||
!isContentLimitReached &&
|
||||
unscaledBoxWidth > MIN_CONTENT_WIDTH_PX &&
|
||||
unscaledBoxHeight > MIN_CONTENT_HEIGHT_PX
|
||||
) {
|
||||
pending.setPendingFromBox(box);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a content of the given palette item from the pending creation
|
||||
* rectangle.
|
||||
*
|
||||
* Unlike a palette drop, which places a content at its default size, the
|
||||
* content is fitted to the box the author drew.
|
||||
*/
|
||||
const createFromPending = (item: ContentDragDropItem) => {
|
||||
const box = pending.getPendingBox();
|
||||
|
||||
pending.clearPending();
|
||||
|
||||
if (!box || !currentEnvelopeItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
editorContents.addContent({
|
||||
envelopeItemId: currentEnvelopeItem.id,
|
||||
contentMeta: buildContentMetaFromBox(item, pageNumber, box),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Render contents when they are added, removed or updated, and when an
|
||||
* image arrives after the page was created (a failed image simply keeps
|
||||
* its placeholder so the author can re-upload).
|
||||
*/
|
||||
useEffect(() => {
|
||||
const layer = pageLayer.current;
|
||||
|
||||
if (!layer || !stage.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
reconcileEnvelopeCanvasGroups({
|
||||
layer,
|
||||
groupName: ENVELOPE_CANVAS_GROUP_NAMES.content,
|
||||
items: localPageContents,
|
||||
getRenderId: (content) => content.formId,
|
||||
render: renderContentOnLayer,
|
||||
});
|
||||
|
||||
applyContentsStacking();
|
||||
|
||||
// Reconcile selection state with live content nodes after flush/sync updates.
|
||||
const liveSelectedGroups = getLiveEnvelopeCanvasGroups(selectedGroups, isOnPage);
|
||||
|
||||
if (liveSelectedGroups.length !== selectedGroups.length) {
|
||||
selection.select('content', liveSelectedGroups);
|
||||
}
|
||||
|
||||
// Only sync while editable, otherwise a transformer would be attached
|
||||
// to an inert group.
|
||||
if (isEditable) {
|
||||
syncEditorSelectionToCanvas({
|
||||
layer,
|
||||
kind: 'content',
|
||||
editorFormId: editorContents.selectedContent?.formId ?? null,
|
||||
isOnPage,
|
||||
selectedGroups,
|
||||
select: selection.select,
|
||||
clear: selection.clear,
|
||||
});
|
||||
}
|
||||
|
||||
applyPageItemsVisibility();
|
||||
|
||||
selection.refreshTransformer();
|
||||
|
||||
layer.batchDraw();
|
||||
}, [
|
||||
localPageContents,
|
||||
selectedGroups,
|
||||
selectedEditorTab,
|
||||
isEditable,
|
||||
editorContents.selectedContent?.formId,
|
||||
contentImages.images,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Selecting a single content brings it to the front of its page, so the
|
||||
* content being worked on is never hidden behind another.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (isEditable && selectedGroups.length === 1) {
|
||||
editorContents.bringContentToFront(selectedGroups[0].id());
|
||||
}
|
||||
}, [isEditable, selectedGroups]);
|
||||
|
||||
/**
|
||||
* Clear any active content selection and pending content creation when
|
||||
* contents are no longer editable, e.g. when switching back to the fields
|
||||
* tab or hiding contents via the viewer toolbar.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isEditable && (selectedGroups.length > 0 || pending.pendingCreation)) {
|
||||
pending.clearPending();
|
||||
selection.clear();
|
||||
pageLayer.current?.batchDraw();
|
||||
}
|
||||
}, [isEditable, selectedGroups, pending.pendingCreation]);
|
||||
|
||||
const getSelectedContents = () =>
|
||||
selectedGroups
|
||||
.map((group) => editorContents.getContentByFormId(group.id()))
|
||||
.filter((content) => content !== undefined);
|
||||
|
||||
const deleteSelected = () => {
|
||||
editorContents.removeContentsByFormId(selectedGroups.map((group) => group.id()));
|
||||
|
||||
selection.clear();
|
||||
};
|
||||
|
||||
const duplicateSelected = () => {
|
||||
for (const content of getSelectedContents()) {
|
||||
editorContents.duplicateContent(content);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
localPageContents,
|
||||
renderAll,
|
||||
selectInBox,
|
||||
pendingCreation: pending.pendingCreation,
|
||||
createFromPending,
|
||||
clearPending: pending.clearPending,
|
||||
deleteSelected,
|
||||
duplicateSelected,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the meta of a content created from a box drawn on the page, merging
|
||||
* the box into the default values of the item.
|
||||
*
|
||||
* Box contents take the box as their bounds. Lines have no box of their own,
|
||||
* so they run horizontally along its top edge, from the top left to the top
|
||||
* right corner.
|
||||
*/
|
||||
const buildContentMetaFromBox = (item: ContentDragDropItem, page: number, box: PercentageBox): TEnvelopeContentMeta => {
|
||||
// Shapes share a content type and are told apart by `shape`.
|
||||
const defaultMeta =
|
||||
item.type === EnvelopeContentType.SHAPE
|
||||
? CONTENT_SHAPE_META_DEFAULT_VALUES_BY_SHAPE[item.shape ?? EnvelopeContentShapeType.RECTANGLE]
|
||||
: CONTENT_META_DEFAULT_VALUES[item.type];
|
||||
|
||||
const meta = { ...structuredClone(defaultMeta), page };
|
||||
|
||||
if (meta.type === EnvelopeContentType.LINE) {
|
||||
return {
|
||||
...meta,
|
||||
x1: box.positionX,
|
||||
y1: box.positionY,
|
||||
x2: box.positionX + box.width,
|
||||
y2: box.positionY,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...meta,
|
||||
positionX: box.positionX,
|
||||
positionY: box.positionY,
|
||||
width: box.width,
|
||||
height: box.height,
|
||||
};
|
||||
};
|
||||
-472
@@ -1,472 +0,0 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import type { EnvelopePageItemsVisibility } from '@documenso/lib/types/envelope-page-items-visibility';
|
||||
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
|
||||
import { MIN_FIELD_HEIGHT_PX, MIN_FIELD_WIDTH_PX } from '@documenso/lib/universal/field-renderer/field-renderer';
|
||||
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
|
||||
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
|
||||
import { getOverlappingFieldPairs } from '@documenso/lib/utils/fields-overlap';
|
||||
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import type { FieldType } from '@prisma/client';
|
||||
import Konva from 'konva';
|
||||
import type { KonvaEventObject } from 'konva/lib/Node';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import type { EnvelopeCanvas, EnvelopeCanvasBox } from './envelope-canvas-types';
|
||||
import { ENVELOPE_CANVAS_GROUP_NAMES } from './envelope-canvas-types';
|
||||
import {
|
||||
getLiveEnvelopeCanvasGroups,
|
||||
reconcileEnvelopeCanvasGroups,
|
||||
syncEditorSelectionToCanvas,
|
||||
} from './reconcile-envelope-canvas-groups';
|
||||
import { findEnvelopeCanvasGroupsInBox } from './use-envelope-canvas-marquee';
|
||||
import { useEnvelopeCanvasPendingCreation } from './use-envelope-canvas-pending-creation';
|
||||
import type { EnvelopeCanvasSelectionApi } from './use-envelope-canvas-selection';
|
||||
|
||||
const PENDING_FIELD_NODE_NAME = 'pending-field-creation';
|
||||
|
||||
type UseEnvelopeCanvasFieldsLayerOptions = {
|
||||
canvas: EnvelopeCanvas;
|
||||
selection: EnvelopeCanvasSelectionApi;
|
||||
fieldsVisibility: EnvelopePageItemsVisibility;
|
||||
applyPageItemsVisibility: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders and manages the fields of the current page on the canvas.
|
||||
*
|
||||
* Owns field rendering, overlap highlighting, geometry write back, the
|
||||
* marquee-to-create flow and the selection actions for fields.
|
||||
*/
|
||||
export const useEnvelopeCanvasFieldsLayer = ({
|
||||
canvas,
|
||||
selection,
|
||||
fieldsVisibility,
|
||||
applyPageItemsVisibility,
|
||||
}: UseEnvelopeCanvasFieldsLayerOptions) => {
|
||||
const { i18n } = useLingui();
|
||||
const analytics = useAnalytics();
|
||||
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
|
||||
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
|
||||
|
||||
const { stage, pageLayer, scale, pageNumber, unscaledViewport, scaledViewport } = canvas;
|
||||
const { fieldGroups: selectedGroups, isTransforming } = selection;
|
||||
|
||||
/**
|
||||
* The rectangle drawn via the marquee which is pending a field type choice.
|
||||
*/
|
||||
const pending = useEnvelopeCanvasPendingCreation({ canvas, nodeName: PENDING_FIELD_NODE_NAME });
|
||||
|
||||
const localPageFields = useMemo(
|
||||
() =>
|
||||
editorFields.localFields.filter(
|
||||
(field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
||||
),
|
||||
[editorFields.localFields, pageNumber, currentEnvelopeItem?.id],
|
||||
);
|
||||
|
||||
const isOnPage = (formId: string) => localPageFields.some((field) => field.formId === formId);
|
||||
|
||||
/**
|
||||
* Debounce the fields used for overlap highlighting so we don't recompute on every
|
||||
* small drag/resize tick. Overlaps only occur within the same page and envelope
|
||||
* item, so computing from this page's fields alone is sufficient.
|
||||
*/
|
||||
const debouncedPageFields = useDebouncedValue(localPageFields, 300);
|
||||
|
||||
const overlappingFieldFormIds = useMemo(() => {
|
||||
const formIds = new Set<string>();
|
||||
|
||||
const pairs = getOverlappingFieldPairs(
|
||||
debouncedPageFields.map((field) => ({
|
||||
id: field.formId,
|
||||
envelopeItemId: field.envelopeItemId,
|
||||
page: field.page,
|
||||
positionX: field.positionX,
|
||||
positionY: field.positionY,
|
||||
width: field.width,
|
||||
height: field.height,
|
||||
})),
|
||||
);
|
||||
|
||||
for (const pair of pairs) {
|
||||
formIds.add(pair.fieldA.id);
|
||||
formIds.add(pair.fieldB.id);
|
||||
}
|
||||
|
||||
return formIds;
|
||||
}, [debouncedPageFields]);
|
||||
|
||||
/**
|
||||
* Write the new geometry of a field back after a drag or resize gesture.
|
||||
*/
|
||||
const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
|
||||
const isDragEvent = event.type === 'dragend';
|
||||
|
||||
const fieldGroup = event.target as Konva.Group;
|
||||
const fieldFormId = fieldGroup.id();
|
||||
|
||||
// Note: This values are scaled.
|
||||
const {
|
||||
width: fieldPixelWidth,
|
||||
height: fieldPixelHeight,
|
||||
x: fieldX,
|
||||
y: fieldY,
|
||||
} = fieldGroup.getClientRect({
|
||||
skipStroke: true,
|
||||
skipShadow: true,
|
||||
});
|
||||
|
||||
const pageHeight = scaledViewport.height;
|
||||
const pageWidth = scaledViewport.width;
|
||||
|
||||
// Calculate x and y as a percentage of the page width and height
|
||||
const positionPercentX = (fieldX / pageWidth) * 100;
|
||||
const positionPercentY = (fieldY / pageHeight) * 100;
|
||||
|
||||
// Get the bounds as a percentage of the page width and height
|
||||
const fieldPageWidth = (fieldPixelWidth / pageWidth) * 100;
|
||||
const fieldPageHeight = (fieldPixelHeight / pageHeight) * 100;
|
||||
|
||||
const fieldUpdates: Partial<TLocalField> = {
|
||||
positionX: positionPercentX,
|
||||
positionY: positionPercentY,
|
||||
};
|
||||
|
||||
// Do not update the width/height unless the field has actually been resized.
|
||||
// This is because our calculations will shift the width/height slightly
|
||||
// due to the way we convert between pixel and percentage.
|
||||
if (!isDragEvent) {
|
||||
fieldUpdates.width = fieldPageWidth;
|
||||
fieldUpdates.height = fieldPageHeight;
|
||||
}
|
||||
|
||||
editorFields.updateFieldByFormId(fieldFormId, fieldUpdates);
|
||||
|
||||
// Select the field if it is not already selected.
|
||||
if (isDragEvent && !selection.isSelected(fieldGroup)) {
|
||||
selection.select('field', [fieldGroup]);
|
||||
}
|
||||
|
||||
pageLayer.current?.batchDraw();
|
||||
};
|
||||
|
||||
/**
|
||||
* Draws (or removes) a dashed warning outline over a field that significantly
|
||||
* overlaps another field. The highlight is a child of the field group so it moves
|
||||
* and resizes with the field, and sits on top of the field's own rect (which is
|
||||
* re-styled on every render and would otherwise clobber a direct stroke change).
|
||||
*/
|
||||
const syncOverlapHighlight = (fieldGroup: Konva.Group, isOverlapping: boolean) => {
|
||||
const existingHighlight = fieldGroup.findOne('.field-overlap-highlight');
|
||||
|
||||
// Skip while a field is actively being dragged/resized. The highlight is driven
|
||||
// by debounced field data, so it would lag behind and distort during the gesture.
|
||||
// It is repainted once the gesture settles (the effect re-runs on isTransforming).
|
||||
if (isTransforming || !isOverlapping) {
|
||||
existingHighlight?.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const fieldRect = fieldGroup.findOne('.field-rect');
|
||||
|
||||
if (!fieldRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
const highlightAttrs = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: fieldRect.width(),
|
||||
height: fieldRect.height(),
|
||||
stroke: '#f59e0b',
|
||||
strokeWidth: 2,
|
||||
dash: [6, 4],
|
||||
cornerRadius: 2,
|
||||
strokeScaleEnabled: false,
|
||||
listening: false,
|
||||
} satisfies Partial<Konva.RectConfig>;
|
||||
|
||||
if (existingHighlight instanceof Konva.Rect) {
|
||||
existingHighlight.setAttrs(highlightAttrs);
|
||||
existingHighlight.moveToTop();
|
||||
return;
|
||||
}
|
||||
|
||||
const highlight = new Konva.Rect({
|
||||
name: 'field-overlap-highlight',
|
||||
...highlightAttrs,
|
||||
});
|
||||
|
||||
fieldGroup.add(highlight);
|
||||
highlight.moveToTop();
|
||||
};
|
||||
|
||||
const unsafeRenderField = (field: TLocalField) => {
|
||||
if (!pageLayer.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Muted fields are rendered with the read-only styling and cannot be
|
||||
// edited, mirroring how other recipients' fields look during signing.
|
||||
const isMuted = fieldsVisibility === 'muted';
|
||||
|
||||
const recipient = envelope.recipients.find((r) => r.id === field.recipientId);
|
||||
|
||||
const isFieldEditable =
|
||||
!isMuted && recipient !== undefined && canRecipientFieldsBeModified(recipient, envelope.fields);
|
||||
|
||||
const { fieldGroup } = renderField({
|
||||
scale,
|
||||
pageLayer: pageLayer.current,
|
||||
field: {
|
||||
renderId: field.formId,
|
||||
...field,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: field.fieldMeta,
|
||||
},
|
||||
translations: getClientSideFieldTranslations(i18n),
|
||||
pageWidth: unscaledViewport.width,
|
||||
pageHeight: unscaledViewport.height,
|
||||
color: isMuted ? 'readOnly' : getRecipientColorKey(field.recipientId),
|
||||
editable: isFieldEditable,
|
||||
mode: 'edit',
|
||||
});
|
||||
|
||||
syncOverlapHighlight(fieldGroup, overlappingFieldFormIds.has(field.formId));
|
||||
|
||||
if (!isFieldEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
fieldGroup.off('click');
|
||||
fieldGroup.off('transformend');
|
||||
fieldGroup.off('dragend');
|
||||
|
||||
// A plain click selects just this field, shift + click toggles it in/out
|
||||
// of the current selection.
|
||||
fieldGroup.on('click', (event) => {
|
||||
pending.clearPending();
|
||||
|
||||
if (event.evt.shiftKey) {
|
||||
selection.toggle('field', fieldGroup);
|
||||
} else {
|
||||
selection.select('field', [fieldGroup]);
|
||||
}
|
||||
|
||||
pageLayer.current?.batchDraw();
|
||||
});
|
||||
|
||||
fieldGroup.on('transformend', handleResizeOrMove);
|
||||
fieldGroup.on('dragend', handleResizeOrMove);
|
||||
};
|
||||
|
||||
const renderFieldOnLayer = (field: TLocalField) => {
|
||||
try {
|
||||
unsafeRenderField(field);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'editor',
|
||||
location: 'envelope_page_render',
|
||||
envelopeId: envelope.id,
|
||||
});
|
||||
|
||||
setRenderError(true);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Render every field on the page. Called when the page canvas is created.
|
||||
*/
|
||||
const renderAll = () => {
|
||||
for (const field of localPageFields) {
|
||||
renderFieldOnLayer(field);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a marquee selection box into a field selection, or into a pending
|
||||
* field creation when nothing was selected and the box is large enough.
|
||||
*/
|
||||
const selectInBox = (box: EnvelopeCanvasBox) => {
|
||||
const currentStage = stage.current;
|
||||
|
||||
if (!currentStage) {
|
||||
return;
|
||||
}
|
||||
|
||||
// While fields are hidden they cannot be selected or created.
|
||||
if (fieldsVisibility !== 'visible') {
|
||||
return;
|
||||
}
|
||||
|
||||
const groupsInBox = findEnvelopeCanvasGroupsInBox(currentStage, ENVELOPE_CANVAS_GROUP_NAMES.field, box);
|
||||
|
||||
selection.select('field', groupsInBox);
|
||||
|
||||
const unscaledBoxWidth = box.width / scale;
|
||||
const unscaledBoxHeight = box.height / scale;
|
||||
|
||||
// Create a field if no items are selected or the size is too small.
|
||||
if (
|
||||
groupsInBox.length === 0 &&
|
||||
unscaledBoxWidth > MIN_FIELD_WIDTH_PX &&
|
||||
unscaledBoxHeight > MIN_FIELD_HEIGHT_PX &&
|
||||
editorFields.selectedRecipient &&
|
||||
canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields)
|
||||
) {
|
||||
pending.setPendingFromBox(box);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a field of the given type from the pending creation rectangle.
|
||||
*/
|
||||
const createFromPending = (type: FieldType) => {
|
||||
const box = pending.getPendingBox();
|
||||
|
||||
pending.clearPending();
|
||||
|
||||
if (!box || !currentEnvelopeItem || !editorFields.selectedRecipient) {
|
||||
return;
|
||||
}
|
||||
|
||||
editorFields.addField({
|
||||
envelopeItemId: currentEnvelopeItem.id,
|
||||
page: pageNumber,
|
||||
type,
|
||||
positionX: box.positionX,
|
||||
positionY: box.positionY,
|
||||
width: box.width,
|
||||
height: box.height,
|
||||
recipientId: editorFields.selectedRecipient.id,
|
||||
fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[type]),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Render fields when they are added, removed or updated.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const layer = pageLayer.current;
|
||||
|
||||
if (!layer || !stage.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
reconcileEnvelopeCanvasGroups({
|
||||
layer,
|
||||
groupName: ENVELOPE_CANVAS_GROUP_NAMES.field,
|
||||
items: localPageFields,
|
||||
getRenderId: (field) => field.formId,
|
||||
render: renderFieldOnLayer,
|
||||
});
|
||||
|
||||
// Reconcile selection state with live field nodes after flush/sync updates.
|
||||
const liveSelectedGroups = getLiveEnvelopeCanvasGroups(selectedGroups, isOnPage);
|
||||
|
||||
if (liveSelectedGroups.length !== selectedGroups.length) {
|
||||
selection.select('field', liveSelectedGroups);
|
||||
}
|
||||
|
||||
syncEditorSelectionToCanvas({
|
||||
layer,
|
||||
kind: 'field',
|
||||
editorFormId: editorFields.selectedField?.formId ?? null,
|
||||
isOnPage,
|
||||
selectedGroups,
|
||||
select: selection.select,
|
||||
clear: selection.clear,
|
||||
});
|
||||
|
||||
applyPageItemsVisibility();
|
||||
|
||||
selection.refreshTransformer();
|
||||
|
||||
layer.batchDraw();
|
||||
}, [
|
||||
localPageFields,
|
||||
selectedGroups,
|
||||
overlappingFieldFormIds,
|
||||
isTransforming,
|
||||
editorFields.selectedField?.formId,
|
||||
fieldsVisibility,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Clear any active selection and pending field creation when fields are no
|
||||
* longer visible, since the transformer and floating toolbars would
|
||||
* otherwise remain anchored to hidden fields.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (fieldsVisibility !== 'visible' && (selectedGroups.length > 0 || pending.pendingCreation)) {
|
||||
pending.clearPending();
|
||||
selection.clear();
|
||||
pageLayer.current?.batchDraw();
|
||||
}
|
||||
}, [fieldsVisibility, selectedGroups, pending.pendingCreation]);
|
||||
|
||||
const getSelectedFields = () =>
|
||||
selectedGroups.map((group) => editorFields.getFieldByFormId(group.id())).filter((field) => field !== undefined);
|
||||
|
||||
const deleteSelected = () => {
|
||||
editorFields.removeFieldsByFormId(selectedGroups.map((group) => group.id()));
|
||||
|
||||
selection.clear();
|
||||
};
|
||||
|
||||
const duplicateSelected = () => {
|
||||
for (const field of getSelectedFields()) {
|
||||
editorFields.duplicateField(field);
|
||||
}
|
||||
};
|
||||
|
||||
const duplicateSelectedOnAllPages = () => {
|
||||
for (const field of getSelectedFields()) {
|
||||
editorFields.duplicateFieldToAllPages(field);
|
||||
}
|
||||
|
||||
selection.clear();
|
||||
};
|
||||
|
||||
const changeSelectedRecipient = (recipientId: number) => {
|
||||
for (const field of getSelectedFields()) {
|
||||
if (field.recipientId !== recipientId) {
|
||||
editorFields.updateFieldByFormId(field.formId, { recipientId, id: undefined });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const changeSelectedType = (type: FieldType) => {
|
||||
for (const field of getSelectedFields()) {
|
||||
if (field.type !== type) {
|
||||
editorFields.updateFieldByFormId(field.formId, {
|
||||
type,
|
||||
fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[type]),
|
||||
id: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
localPageFields,
|
||||
renderAll,
|
||||
selectInBox,
|
||||
pendingCreation: pending.pendingCreation,
|
||||
createFromPending,
|
||||
clearPending: pending.clearPending,
|
||||
deleteSelected,
|
||||
duplicateSelected,
|
||||
duplicateSelectedOnAllPages,
|
||||
changeSelectedRecipient,
|
||||
changeSelectedType,
|
||||
};
|
||||
};
|
||||
-195
@@ -1,195 +0,0 @@
|
||||
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 };
|
||||
};
|
||||
-201
@@ -1,201 +0,0 @@
|
||||
import { useLatestRef } from '@documenso/lib/client-only/hooks/use-latest-ref';
|
||||
import { KONVA_SELECTION_FILL_COLOR } from '@documenso/lib/universal/konva/constants';
|
||||
import Konva from 'konva';
|
||||
import { useCallback } from 'react';
|
||||
import { clamp } from 'remeda';
|
||||
|
||||
import type { EnvelopeCanvasBox } from './envelope-canvas-types';
|
||||
|
||||
type UseEnvelopeCanvasMarqueeOptions = {
|
||||
/**
|
||||
* Called when a marquee drag ends, with the selection box in scaled stage
|
||||
* coordinates. The caller decides what the box selects.
|
||||
*/
|
||||
onSelect: (box: EnvelopeCanvasBox) => void;
|
||||
|
||||
/**
|
||||
* Called when an empty area of the stage is clicked without dragging.
|
||||
*/
|
||||
onEmptyClick: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Drag-to-select marquee on an empty area of the stage.
|
||||
*
|
||||
* The callbacks are read through refs, so the once-bound stage handlers always
|
||||
* delegate to the latest render's logic.
|
||||
*/
|
||||
export const useEnvelopeCanvasMarquee = ({ onSelect, onEmptyClick }: UseEnvelopeCanvasMarqueeOptions) => {
|
||||
const onSelectRef = useLatestRef(onSelect);
|
||||
const onEmptyClickRef = useLatestRef(onEmptyClick);
|
||||
|
||||
/**
|
||||
* Bind the marquee to a stage. Called once when the page canvas is created.
|
||||
*/
|
||||
const bind = useCallback((stage: Konva.Stage, layer: Konva.Layer) => {
|
||||
const selectionRectangle = new Konva.Rect({
|
||||
name: 'marquee-selection',
|
||||
fill: KONVA_SELECTION_FILL_COLOR,
|
||||
visible: false,
|
||||
});
|
||||
|
||||
layer.add(selectionRectangle);
|
||||
|
||||
let x1 = 0;
|
||||
let y1 = 0;
|
||||
|
||||
/**
|
||||
* The pointer in layer coordinates, pinned to the page.
|
||||
*
|
||||
* Pointer positions are in scaled stage coordinates, while the rectangle
|
||||
* lives on the scaled layer, so positions are divided by the stage scale.
|
||||
*
|
||||
* The pointer is clamped to the page so the rectangle stops at the page
|
||||
* edge while the mouse itself is free to leave it, matching how a
|
||||
* transformer resize behaves.
|
||||
*/
|
||||
const getPointer = () => {
|
||||
const pointerPosition = stage.getPointerPosition();
|
||||
|
||||
if (!pointerPosition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
x: clamp(pointerPosition.x / stage.scaleX(), { min: 0, max: stage.width() / stage.scaleX() }),
|
||||
y: clamp(pointerPosition.y / stage.scaleY(), { min: 0, max: stage.height() / stage.scaleY() }),
|
||||
};
|
||||
};
|
||||
|
||||
// The stage only receives pointer events while the pointer is over its
|
||||
// container, so a drag which leaves the page would freeze the rectangle
|
||||
// and never finish. Like Konva's own transformer, the move and up events
|
||||
// are tracked on the window for the duration of a drag instead.
|
||||
const onWindowPointerMove = (evt: MouseEvent | TouchEvent) => {
|
||||
// The stage is rebuilt on zoom, which throws away this rectangle.
|
||||
if (!selectionRectangle.getStage()) {
|
||||
stopTrackingWindow();
|
||||
return;
|
||||
}
|
||||
|
||||
// The stage cannot see the pointer once it is outside the container, so
|
||||
// register its position from the window event.
|
||||
stage.setPointersPositions(evt);
|
||||
|
||||
const pointer = getPointer();
|
||||
|
||||
if (!pointer) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectionRectangle.moveToTop();
|
||||
|
||||
selectionRectangle.setAttrs({
|
||||
x: Math.min(x1, pointer.x),
|
||||
y: Math.min(y1, pointer.y),
|
||||
width: Math.abs(pointer.x - x1),
|
||||
height: Math.abs(pointer.y - y1),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the pointer was actually dragged, as opposed to a plain click
|
||||
* which leaves the rectangle without an area.
|
||||
*/
|
||||
const isMarqueeDrawn = () => {
|
||||
return selectionRectangle.visible() && selectionRectangle.width() > 0 && selectionRectangle.height() > 0;
|
||||
};
|
||||
|
||||
const onWindowPointerUp = () => {
|
||||
stopTrackingWindow();
|
||||
|
||||
if (!selectionRectangle.getStage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide in a timeout so the click handler below can still detect that a
|
||||
// marquee drag just finished.
|
||||
setTimeout(() => {
|
||||
selectionRectangle.visible(false);
|
||||
});
|
||||
|
||||
// A plain click is not a selection, the click handler below deals with
|
||||
// it. Selecting here would also match anything whose bounding box merely
|
||||
// contains the point (e.g. a diagonal line), and since this runs after
|
||||
// other window listeners it would override e.g. a content being placed.
|
||||
if (!isMarqueeDrawn()) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSelectRef.current(selectionRectangle.getClientRect());
|
||||
};
|
||||
|
||||
const startTrackingWindow = () => {
|
||||
window.addEventListener('mousemove', onWindowPointerMove);
|
||||
window.addEventListener('touchmove', onWindowPointerMove);
|
||||
window.addEventListener('mouseup', onWindowPointerUp);
|
||||
window.addEventListener('touchend', onWindowPointerUp);
|
||||
window.addEventListener('touchcancel', onWindowPointerUp);
|
||||
};
|
||||
|
||||
const stopTrackingWindow = () => {
|
||||
window.removeEventListener('mousemove', onWindowPointerMove);
|
||||
window.removeEventListener('touchmove', onWindowPointerMove);
|
||||
window.removeEventListener('mouseup', onWindowPointerUp);
|
||||
window.removeEventListener('touchend', onWindowPointerUp);
|
||||
window.removeEventListener('touchcancel', onWindowPointerUp);
|
||||
};
|
||||
|
||||
stage.on('mousedown.marquee touchstart.marquee', (e) => {
|
||||
// Do nothing if the pointer is down on a shape.
|
||||
if (e.target !== stage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pointer = getPointer();
|
||||
|
||||
if (!pointer) {
|
||||
return;
|
||||
}
|
||||
|
||||
x1 = pointer.x;
|
||||
y1 = pointer.y;
|
||||
|
||||
selectionRectangle.setAttrs({
|
||||
x: x1,
|
||||
y: y1,
|
||||
width: 0,
|
||||
height: 0,
|
||||
visible: true,
|
||||
});
|
||||
|
||||
startTrackingWindow();
|
||||
});
|
||||
|
||||
stage.on('click.marquee tap.marquee', (e) => {
|
||||
// A marquee drag just finished, the selection was handled on mouse up.
|
||||
if (isMarqueeDrawn()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.target === stage) {
|
||||
onEmptyClickRef.current();
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { bind };
|
||||
};
|
||||
|
||||
/**
|
||||
* Find the groups of a given name which intersect a box, excluding groups
|
||||
* which are not draggable (i.e. not currently editable).
|
||||
*/
|
||||
export const findEnvelopeCanvasGroupsInBox = (stage: Konva.Stage, groupName: string, box: EnvelopeCanvasBox) => {
|
||||
return stage
|
||||
.find(`.${groupName}`)
|
||||
.filter(
|
||||
(node): node is Konva.Group =>
|
||||
node instanceof Konva.Group && node.draggable() && Konva.Util.haveIntersection(box, node.getClientRect()),
|
||||
);
|
||||
};
|
||||
-88
@@ -1,88 +0,0 @@
|
||||
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 };
|
||||
};
|
||||
-258
@@ -1,258 +0,0 @@
|
||||
import { useLatestRef } from '@documenso/lib/client-only/hooks/use-latest-ref';
|
||||
import type { TransformerSelectionConfig } from '@documenso/lib/universal/konva/transformer';
|
||||
import {
|
||||
boundTransformerBoxToPage,
|
||||
DEFAULT_TRANSFORMER_SELECTION_CONFIG,
|
||||
} from '@documenso/lib/universal/konva/transformer';
|
||||
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
|
||||
import Konva from 'konva';
|
||||
import type { Transformer } from 'konva/lib/shapes/Transformer';
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { EnvelopeCanvasSelection, EnvelopeCanvasSelectionKind } from './envelope-canvas-types';
|
||||
import { ENVELOPE_CANVAS_GROUP_NAMES } from './envelope-canvas-types';
|
||||
|
||||
/**
|
||||
* A stable empty array so consumers of a kind which is not selected keep a
|
||||
* stable dependency and don't re-render on every selection change.
|
||||
*/
|
||||
const EMPTY_GROUPS: Konva.Group[] = [];
|
||||
|
||||
/** How far past a resize handle you can still grab it, in screen pixels. */
|
||||
const TRANSFORMER_ANCHOR_HIT_STROKE_PX = 24;
|
||||
|
||||
type UseEnvelopeCanvasSelectionOptions = {
|
||||
/**
|
||||
* Resolve the transformer configuration for a selection.
|
||||
*/
|
||||
getTransformerConfig: (kind: EnvelopeCanvasSelectionKind, groups: Konva.Group[]) => TransformerSelectionConfig;
|
||||
|
||||
/**
|
||||
* Called synchronously whenever the selection changes, so the editor
|
||||
* selection can be kept in sync without a frame of drift.
|
||||
*/
|
||||
onChange: (selection: EnvelopeCanvasSelection) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The single canvas selection shared by fields and contents, along with the
|
||||
* Konva transformer which acts upon it.
|
||||
*
|
||||
* The exposed functions are stable and read their options through refs, so
|
||||
* they are safe to call from Konva handlers bound once at stage creation.
|
||||
*/
|
||||
export const useEnvelopeCanvasSelection = ({ getTransformerConfig, onChange }: UseEnvelopeCanvasSelectionOptions) => {
|
||||
const transformerRef = useRef<Transformer | null>(null);
|
||||
|
||||
const [selection, setSelectionState] = useState<EnvelopeCanvasSelection>(null);
|
||||
|
||||
/**
|
||||
* Whether a selected item is being dragged, resized or rotated.
|
||||
*/
|
||||
const [isTransforming, setIsTransforming] = useState(false);
|
||||
|
||||
const getTransformerConfigRef = useLatestRef(getTransformerConfig);
|
||||
const onChangeRef = useLatestRef(onChange);
|
||||
|
||||
const applyTransformerConfig = useCallback((transformer: Transformer, currentSelection: EnvelopeCanvasSelection) => {
|
||||
const config = currentSelection
|
||||
? getTransformerConfigRef.current(currentSelection.kind, currentSelection.groups)
|
||||
: DEFAULT_TRANSFORMER_SELECTION_CONFIG;
|
||||
|
||||
transformer.enabledAnchors(config.enabledAnchors);
|
||||
transformer.rotateEnabled(config.rotateEnabled);
|
||||
transformer.keepRatio(config.keepRatio);
|
||||
transformer.borderEnabled(config.borderEnabled);
|
||||
}, []);
|
||||
|
||||
const selectionRef = useLatestRef(selection);
|
||||
|
||||
/**
|
||||
* Re-sync the transformer after the items change without the selection
|
||||
* changing: re-resolve its configuration (e.g. an image attached to the
|
||||
* selected content locks its ratio) and raise it back above the items.
|
||||
*
|
||||
* The handles straddle the selected item's edges, so any item stacked above
|
||||
* the transformer hides part of them and takes their clicks. Rendering
|
||||
* appends new items on top and the contents stacking raises every content,
|
||||
* so each reconcile calls this afterwards.
|
||||
*/
|
||||
const refreshTransformer = useCallback(() => {
|
||||
const transformer = transformerRef.current;
|
||||
|
||||
if (!transformer) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyTransformerConfig(transformer, selectionRef.current);
|
||||
transformer.moveToTop();
|
||||
transformer.forceUpdate();
|
||||
}, [applyTransformerConfig]);
|
||||
|
||||
const applySelection = useCallback((nextSelection: EnvelopeCanvasSelection) => {
|
||||
const transformer = transformerRef.current;
|
||||
|
||||
if (transformer) {
|
||||
// Configure the transformer before assigning nodes, since assigning
|
||||
// nodes triggers an update using the current configuration.
|
||||
applyTransformerConfig(transformer, nextSelection);
|
||||
transformer.nodes(nextSelection?.groups ?? []);
|
||||
}
|
||||
|
||||
if (nextSelection?.groups.length === 1) {
|
||||
nextSelection.groups[0].moveToTop();
|
||||
}
|
||||
|
||||
// Above the item just raised, so its handles stay visible and clickable.
|
||||
transformer?.moveToTop();
|
||||
|
||||
setSelectionState(nextSelection);
|
||||
onChangeRef.current(nextSelection);
|
||||
}, []);
|
||||
|
||||
const select = useCallback(
|
||||
(kind: EnvelopeCanvasSelectionKind, nodes: Konva.Node[]) => {
|
||||
const groupName = ENVELOPE_CANVAS_GROUP_NAMES[kind];
|
||||
|
||||
const groups = nodes.filter(
|
||||
(node): node is Konva.Group =>
|
||||
node instanceof Konva.Group &&
|
||||
node.hasName(groupName) &&
|
||||
Boolean(node.getStage()) &&
|
||||
Boolean(node.getParent()),
|
||||
);
|
||||
|
||||
applySelection(groups.length > 0 ? { kind, groups } : null);
|
||||
},
|
||||
[applySelection],
|
||||
);
|
||||
|
||||
/**
|
||||
* Toggle a node in or out of the current selection (shift-click semantics).
|
||||
*
|
||||
* Nodes of a different kind than the toggled node are dropped, since only
|
||||
* one kind can be selected at a time.
|
||||
*/
|
||||
const toggle = useCallback(
|
||||
(kind: EnvelopeCanvasSelectionKind, node: Konva.Node) => {
|
||||
const currentNodes = transformerRef.current?.nodes() ?? [];
|
||||
const isAlreadySelected = currentNodes.includes(node);
|
||||
|
||||
select(kind, isAlreadySelected ? currentNodes.filter((current) => current !== node) : [...currentNodes, node]);
|
||||
},
|
||||
[select],
|
||||
);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
applySelection(null);
|
||||
}, [applySelection]);
|
||||
|
||||
const isSelected = useCallback((node: Konva.Node) => {
|
||||
return (transformerRef.current?.nodes() ?? []).includes(node);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Re-resolve the selection against the groups currently on the layer.
|
||||
*
|
||||
* The stage is destroyed and rebuilt whenever the page is rescaled (e.g.
|
||||
* zooming), which leaves the selection holding detached nodes: the handles
|
||||
* disappear, the action bar positions itself from an empty rect, and the
|
||||
* next reconcile drops the selection altogether. Groups keep their render
|
||||
* ID across the rebuild, so the equivalent new groups are selected instead.
|
||||
*/
|
||||
const reattachSelection = useCallback(
|
||||
(layer: Konva.Layer) => {
|
||||
const currentSelection = selectionRef.current;
|
||||
|
||||
if (!currentSelection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedIds = new Set(currentSelection.groups.map((group) => group.id()));
|
||||
|
||||
const groups = layer
|
||||
.find(`.${ENVELOPE_CANVAS_GROUP_NAMES[currentSelection.kind]}`)
|
||||
.filter((group) => selectedIds.has(group.id()));
|
||||
|
||||
select(currentSelection.kind, groups);
|
||||
},
|
||||
[select],
|
||||
);
|
||||
|
||||
/**
|
||||
* Create the transformer on a layer. Called once when the page canvas is
|
||||
* created, and again whenever the stage is recreated.
|
||||
*/
|
||||
const attach = useCallback((layer: Konva.Layer) => {
|
||||
// Match the brand green used for the first recipient's fields rather than
|
||||
// Konva's default blue.
|
||||
const selectionColor = getRecipientColorStyles('green').baseRing;
|
||||
|
||||
const transformer = new Konva.Transformer({
|
||||
...DEFAULT_TRANSFORMER_SELECTION_CONFIG,
|
||||
rotationSnaps: [0, 45, 90, 135, 180, 225, 270, 315],
|
||||
rotationSnapTolerance: 5,
|
||||
keepRatio: false,
|
||||
borderStroke: selectionColor,
|
||||
anchorStroke: selectionColor,
|
||||
ignoreStroke: true,
|
||||
flipEnabled: false,
|
||||
anchorStyleFunc: (anchor) => {
|
||||
// The stage is scaled to the page, so the hit area is divided by that
|
||||
// scale to stay a constant size on screen.
|
||||
const stageScale = layer.getStage()?.scaleX() ?? 1;
|
||||
|
||||
anchor.hitStrokeWidth(TRANSFORMER_ANCHOR_HIT_STROKE_PX / stageScale);
|
||||
},
|
||||
boundBoxFunc: (oldBox, newBox) => {
|
||||
// Boxes are in absolute stage coordinates and the stage is the page,
|
||||
// so pin the resize to the stage's own size.
|
||||
const stage = layer.getStage();
|
||||
|
||||
const bounded = stage
|
||||
? boundTransformerBoxToPage(newBox, { width: stage.width(), height: stage.height() })
|
||||
: newBox;
|
||||
|
||||
// Enforce minimum size
|
||||
if (bounded.width < 30 || bounded.height < 20) {
|
||||
return oldBox;
|
||||
}
|
||||
|
||||
return bounded;
|
||||
},
|
||||
});
|
||||
|
||||
layer.add(transformer);
|
||||
|
||||
// Konva fires transform events (resize and rotate) directly on the
|
||||
// transformer and its nodes without bubbling, so they cannot be observed
|
||||
// on the stage.
|
||||
transformer.on('transformstart', () => setIsTransforming(true));
|
||||
transformer.on('transformend', () => setIsTransforming(false));
|
||||
|
||||
transformerRef.current = transformer;
|
||||
|
||||
return transformer;
|
||||
}, []);
|
||||
|
||||
const fieldGroups = useMemo(() => (selection?.kind === 'field' ? selection.groups : EMPTY_GROUPS), [selection]);
|
||||
|
||||
const contentGroups = useMemo(() => (selection?.kind === 'content' ? selection.groups : EMPTY_GROUPS), [selection]);
|
||||
|
||||
return {
|
||||
selection,
|
||||
fieldGroups,
|
||||
contentGroups,
|
||||
isTransforming,
|
||||
setIsTransforming,
|
||||
attach,
|
||||
reattachSelection,
|
||||
select,
|
||||
toggle,
|
||||
clear,
|
||||
isSelected,
|
||||
refreshTransformer,
|
||||
};
|
||||
};
|
||||
|
||||
export type EnvelopeCanvasSelectionApi = ReturnType<typeof useEnvelopeCanvasSelection>;
|
||||
@@ -1,323 +0,0 @@
|
||||
import { decodeLocalContentImage } from '@documenso/lib/client-only/load-content-image';
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import {
|
||||
APP_CONTENT_IMAGE_MIME_TYPES,
|
||||
APP_CONTENT_IMAGE_UPLOAD_SIZE_LIMIT,
|
||||
} from '@documenso/lib/constants/envelope-content';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { EnvelopeContentType } from '@documenso/lib/types/envelope-content-meta';
|
||||
import { resolveAttachedImageBox } from '@documenso/lib/universal/content-renderer/content-image-box';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
|
||||
import { PRESIGNED_DATA_CONTENT_ID_PREFIX } from '@documenso/lib/utils/embed-config';
|
||||
import type { Size } from '@documenso/lib/utils/geometry';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter } from '@documenso/ui/primitives/dialog';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createCallable } from 'react-call';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
/**
|
||||
* The DOM id of the dialog's file input, so the e2e suite can find it.
|
||||
*/
|
||||
export const getContentImageInputId = (formId: string) => `content-image-input-${formId}`;
|
||||
|
||||
type ContentImageUploadDialogProps = {
|
||||
formId: string;
|
||||
|
||||
/**
|
||||
* A file to upload straight away, e.g. one dropped onto the settings
|
||||
* panel. Without it the file picker is opened first.
|
||||
*/
|
||||
file?: File;
|
||||
};
|
||||
|
||||
type UploadStatus = 'picking' | 'uploading' | 'invalid-image' | 'too-large' | 'failed';
|
||||
|
||||
/**
|
||||
* Why the server would reject the file, if it would, so the user finds out
|
||||
* before it is uploaded. Applies to dropped and picked files alike.
|
||||
*/
|
||||
const getRejectionReason = (file: File): 'too-large' | 'invalid-image' | null => {
|
||||
if (file.size > megabytesToBytes(APP_CONTENT_IMAGE_UPLOAD_SIZE_LIMIT)) {
|
||||
return 'too-large';
|
||||
}
|
||||
|
||||
if (!APP_CONTENT_IMAGE_MIME_TYPES.some((accepted) => accepted === file.type)) {
|
||||
return 'invalid-image';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Attach an image to an image content.
|
||||
*
|
||||
* Blocks the editor from the moment it is opened until the upload settles,
|
||||
* so there is only ever one upload in flight. Pending autosaves are flushed
|
||||
* before uploading, so the content always has a server id by then.
|
||||
*
|
||||
* Invoke with `ContentImageUploadDialog.call({ formId })`; `Root` must be
|
||||
* mounted inside the envelope editor and render providers.
|
||||
*/
|
||||
export const ContentImageUploadDialog = createCallable<ContentImageUploadDialogProps, void>(
|
||||
({ call, formId, file: droppedFile }) => {
|
||||
const { t } = useLingui();
|
||||
const { editorContents, flushAutosave, envelope, isEmbedded } = useCurrentEnvelopeEditor();
|
||||
const { contentImages, pageSizes } = useCurrentEnvelopeRender();
|
||||
|
||||
const { mutateAsync: uploadImage } = trpc.envelope.content.uploadImage.useMutation();
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [status, setStatus] = useState<UploadStatus>(() => {
|
||||
if (!droppedFile) {
|
||||
return 'picking';
|
||||
}
|
||||
|
||||
return getRejectionReason(droppedFile) ?? 'uploading';
|
||||
});
|
||||
|
||||
/**
|
||||
* Point the content at its new image, fitting its box to the image.
|
||||
*
|
||||
* `data` is the file of an image which has not been uploaded yet.
|
||||
*/
|
||||
const attachImage = (dataContentId: string, image: Size, data?: File) => {
|
||||
// Re-read in case the content moved while the image was on its way.
|
||||
const latest = editorContents.getContentByFormId(formId);
|
||||
|
||||
if (!latest || latest.contentMeta.type !== EnvelopeContentType.IMAGE) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { contentMeta } = latest;
|
||||
const page = pageSizes.get(latest.envelopeItemId, contentMeta.page);
|
||||
|
||||
const box = page
|
||||
? resolveAttachedImageBox({
|
||||
image,
|
||||
page,
|
||||
box: {
|
||||
positionX: contentMeta.positionX,
|
||||
positionY: contentMeta.positionY,
|
||||
width: contentMeta.width,
|
||||
height: contentMeta.height,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
editorContents.updateContentByFormId(formId, {
|
||||
dataContentId,
|
||||
data,
|
||||
contentMeta: box ? { ...contentMeta, ...box } : contentMeta,
|
||||
});
|
||||
};
|
||||
|
||||
const uploadToServer = async (file: File) => {
|
||||
// The content must exist on the server before an image can be
|
||||
// attached to it.
|
||||
await flushAutosave();
|
||||
|
||||
const content = editorContents.getContentByFormId(formId);
|
||||
|
||||
if (!content?.id || content.contentMeta.type !== EnvelopeContentType.IMAGE) {
|
||||
throw new AppError('CONTENT_NOT_PERSISTED');
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('payload', JSON.stringify({ envelopeId: envelope.id, envelopeContentId: content.id }));
|
||||
formData.append('file', file);
|
||||
|
||||
const { dataContent } = await uploadImage(formData);
|
||||
|
||||
// Decode the local file at the size the server normalized it to, so
|
||||
// the preview matches what the route would serve.
|
||||
const image = await createImageBitmap(file, {
|
||||
resizeWidth: dataContent.metadata.width,
|
||||
resizeHeight: dataContent.metadata.height,
|
||||
resizeQuality: 'high',
|
||||
}).catch(() => null);
|
||||
|
||||
if (image) {
|
||||
contentImages.setImage(dataContent.id, image, {
|
||||
fileSize: dataContent.metadata.fileSize,
|
||||
});
|
||||
}
|
||||
|
||||
attachImage(dataContent.id, dataContent.metadata);
|
||||
};
|
||||
|
||||
const upload = async (file: File) => {
|
||||
setStatus('uploading');
|
||||
|
||||
try {
|
||||
if (isEmbedded) {
|
||||
// Decoded the way the server will store it, which also rejects what
|
||||
// the server would, since it only sees the image once the envelope is
|
||||
// saved.
|
||||
const { image, details } = await decodeLocalContentImage(file);
|
||||
|
||||
const dataContentId = `${PRESIGNED_DATA_CONTENT_ID_PREFIX}${nanoid()}`;
|
||||
|
||||
contentImages.setImage(dataContentId, image, details);
|
||||
|
||||
attachImage(dataContentId, image, file);
|
||||
} else {
|
||||
await uploadToServer(file);
|
||||
}
|
||||
|
||||
call.end();
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
setStatus(
|
||||
match(error.code)
|
||||
.with('INVALID_IMAGE_FILE', (): UploadStatus => 'invalid-image')
|
||||
.with('FILE_TOO_LARGE', (): UploadStatus => 'too-large')
|
||||
.otherwise((): UploadStatus => 'failed'),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Dismissing the picker without choosing closes the dialog too. Browsers
|
||||
// fire `cancel` on the input for this, but React does not expose it as a
|
||||
// prop, so it is attached natively.
|
||||
useEffect(() => {
|
||||
const input = inputRef.current;
|
||||
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onCancel = () => call.end();
|
||||
|
||||
input.addEventListener('cancel', onCancel);
|
||||
|
||||
return () => input.removeEventListener('cancel', onCancel);
|
||||
}, [call]);
|
||||
|
||||
// Start immediately: upload the dropped file, or open the picker so the
|
||||
// user does not have to click twice. A dropped file the server would
|
||||
// reject opens on the reason instead.
|
||||
const hasStartedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasStartedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasStartedRef.current = true;
|
||||
|
||||
if (!droppedFile) {
|
||||
inputRef.current?.click();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!getRejectionReason(droppedFile)) {
|
||||
void upload(droppedFile);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onFilePicked = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
|
||||
// Allow the same file to be picked again after a failure.
|
||||
event.target.value = '';
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rejectionReason = getRejectionReason(file);
|
||||
|
||||
if (rejectionReason) {
|
||||
setStatus(rejectionReason);
|
||||
return;
|
||||
}
|
||||
|
||||
void upload(file);
|
||||
};
|
||||
|
||||
// Clicked directly rather than via state, since the status may already
|
||||
// be `picking` and a state change alone would not reopen the picker.
|
||||
const reopenPicker = () => {
|
||||
setStatus('picking');
|
||||
inputRef.current?.click();
|
||||
};
|
||||
|
||||
const isBusy = status === 'uploading';
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={inputRef}
|
||||
id={getContentImageInputId(formId)}
|
||||
type="file"
|
||||
accept={APP_CONTENT_IMAGE_MIME_TYPES.join(',')}
|
||||
className="hidden"
|
||||
onChange={onFilePicked}
|
||||
/>
|
||||
|
||||
<Dialog open={true} onOpenChange={(open) => !open && !isBusy && call.end()}>
|
||||
<DialogContent
|
||||
className={cn('max-w-xs', {
|
||||
hidden: status === 'picking',
|
||||
})}
|
||||
hideClose
|
||||
onPointerDownOutside={(event) => isBusy && event.preventDefault()}
|
||||
onEscapeKeyDown={(event) => isBusy && event.preventDefault()}
|
||||
>
|
||||
{match(status)
|
||||
.with('picking', () => null) // Only show the overlay since the actual browser uploader should be shown.
|
||||
.with('uploading', () => (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center gap-2 py-4"
|
||||
data-testid="content-image-uploading"
|
||||
>
|
||||
<Loader2Icon className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
<DialogDescription>
|
||||
<Trans>Uploading Image</Trans>
|
||||
</DialogDescription>
|
||||
</div>
|
||||
))
|
||||
.with('invalid-image', () => (
|
||||
<DialogDescription className="text-destructive">
|
||||
<Trans>This image could not be read. Use a PNG, JPEG or WebP file.</Trans>
|
||||
</DialogDescription>
|
||||
))
|
||||
.with('too-large', () => (
|
||||
<DialogDescription className="text-destructive">
|
||||
<Trans>This image is larger than {APP_CONTENT_IMAGE_UPLOAD_SIZE_LIMIT} MB.</Trans>
|
||||
</DialogDescription>
|
||||
))
|
||||
.with('failed', () => (
|
||||
<DialogDescription className="text-destructive">
|
||||
<Trans>The image could not be uploaded. Please try again.</Trans>
|
||||
</DialogDescription>
|
||||
))
|
||||
.exhaustive()}
|
||||
|
||||
{!isBusy && (
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => call.end()}>
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="button" onClick={reopenPicker}>
|
||||
{status === 'picking' ? t`Choose image` : t`Try again`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1,246 +0,0 @@
|
||||
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),
|
||||
};
|
||||
};
|
||||
-411
@@ -1,411 +0,0 @@
|
||||
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
||||
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
|
||||
import { useLatestRef } from '@documenso/lib/client-only/hooks/use-latest-ref';
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import {
|
||||
CONTENT_HIGHLIGHT_META_DEFAULT_VALUES,
|
||||
CONTENT_IMAGE_META_DEFAULT_VALUES,
|
||||
CONTENT_LINE_META_DEFAULT_VALUES,
|
||||
CONTENT_SHAPE_META_DEFAULT_VALUES_BY_SHAPE,
|
||||
CONTENT_TEXT_META_DEFAULT_VALUES,
|
||||
CONTENT_TYPE_DATA_CONTENT_TYPE,
|
||||
EnvelopeContentShapeType,
|
||||
EnvelopeContentType,
|
||||
type TEnvelopeContentMeta,
|
||||
} from '@documenso/lib/types/envelope-content-meta';
|
||||
import { CONTENT_IMAGE_DEFAULT_SIZE } from '@documenso/lib/universal/content-renderer/content-image-box';
|
||||
import { canContentBeChanged } from '@documenso/lib/utils/envelope';
|
||||
import { resolveEnvelopeContentLimits } from '@documenso/lib/utils/envelope-content';
|
||||
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Plural, Trans, useLingui } from '@lingui/react/macro';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { HighlighterIcon, ImageIcon, ScanLineIcon, SquareIcon, TextIcon } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
const MIN_HEIGHT_PX = 12;
|
||||
const MIN_WIDTH_PX = 36;
|
||||
|
||||
const DEFAULT_HEIGHT_PX = MIN_HEIGHT_PX * 2.5;
|
||||
const DEFAULT_WIDTH_PX = MIN_WIDTH_PX * 2.5;
|
||||
|
||||
export type ContentDragDropItem = {
|
||||
key: string;
|
||||
type: EnvelopeContentType;
|
||||
shape?: EnvelopeContentShapeType;
|
||||
icon: LucideIcon;
|
||||
name: MessageDescriptor;
|
||||
};
|
||||
|
||||
export const contentButtonList: ContentDragDropItem[] = [
|
||||
{
|
||||
key: EnvelopeContentType.TEXT,
|
||||
type: EnvelopeContentType.TEXT,
|
||||
icon: TextIcon,
|
||||
name: msg`Text`,
|
||||
},
|
||||
{
|
||||
key: EnvelopeContentType.LINE,
|
||||
type: EnvelopeContentType.LINE,
|
||||
icon: ScanLineIcon,
|
||||
name: msg`Line`,
|
||||
},
|
||||
{
|
||||
key: `${EnvelopeContentType.SHAPE}:${EnvelopeContentShapeType.RECTANGLE}`,
|
||||
type: EnvelopeContentType.SHAPE,
|
||||
shape: EnvelopeContentShapeType.RECTANGLE,
|
||||
icon: SquareIcon,
|
||||
name: msg`Rectangle`,
|
||||
},
|
||||
{
|
||||
key: EnvelopeContentType.HIGHLIGHT,
|
||||
type: EnvelopeContentType.HIGHLIGHT,
|
||||
icon: HighlighterIcon,
|
||||
name: msg`Highlight`,
|
||||
},
|
||||
{
|
||||
key: EnvelopeContentType.IMAGE,
|
||||
type: EnvelopeContentType.IMAGE,
|
||||
icon: ImageIcon,
|
||||
name: msg`Image`,
|
||||
},
|
||||
];
|
||||
|
||||
type EnvelopeEditorContentDragDropProps = {
|
||||
selectedEnvelopeItemId: string | null;
|
||||
};
|
||||
|
||||
export const EnvelopeEditorContentDragDrop = ({ selectedEnvelopeItemId }: EnvelopeEditorContentDragDropProps) => {
|
||||
const { envelope, editorContents, setIsPlacingItem } = useCurrentEnvelopeEditor();
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const { t } = useLingui();
|
||||
|
||||
const contentLimits = resolveEnvelopeContentLimits(
|
||||
editorContents.localContents.map((content) => content.contentMeta.type),
|
||||
organisation.organisationClaim,
|
||||
);
|
||||
|
||||
const [selectedContent, setSelectedContent] = useState<ContentDragDropItem | null>(null);
|
||||
|
||||
// Let the canvas know a content is being placed, so it can get its selection
|
||||
// out of the way of the placement click.
|
||||
useEffect(() => {
|
||||
setIsPlacingItem(selectedContent !== null);
|
||||
|
||||
return () => {
|
||||
setIsPlacingItem(false);
|
||||
};
|
||||
}, [selectedContent, setIsPlacingItem]);
|
||||
|
||||
const { isWithinPageBounds, getPage } = useDocumentElement();
|
||||
|
||||
const [isContentWithinBounds, setIsContentWithinBounds] = useState(false);
|
||||
const [coords, setCoords] = useState({
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
const contentBounds = useRef({
|
||||
height: 0,
|
||||
width: 0,
|
||||
});
|
||||
|
||||
const onMouseMove = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
setIsContentWithinBounds(
|
||||
isWithinPageBounds(event, PDF_VIEWER_PAGE_SELECTOR, contentBounds.current.width, contentBounds.current.height),
|
||||
);
|
||||
|
||||
setCoords({
|
||||
x: event.clientX - contentBounds.current.width / 2,
|
||||
y: event.clientY - contentBounds.current.height / 2,
|
||||
});
|
||||
},
|
||||
[isWithinPageBounds],
|
||||
);
|
||||
|
||||
const onMouseClick = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
if (!selectedContent || !selectedEnvelopeItemId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const $page = getPage(event, PDF_VIEWER_PAGE_SELECTOR);
|
||||
|
||||
if (
|
||||
!$page ||
|
||||
!isWithinPageBounds(event, PDF_VIEWER_PAGE_SELECTOR, contentBounds.current.width, contentBounds.current.height)
|
||||
) {
|
||||
setSelectedContent(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const { top, left, height, width } = getBoundingClientRect($page);
|
||||
|
||||
const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10);
|
||||
|
||||
// Calculate x and y as a percentage of the page width and height
|
||||
let pageX = ((event.pageX - left) / width) * 100;
|
||||
let pageY = ((event.pageY - top) / height) * 100;
|
||||
|
||||
// Get the bounds as a percentage of the page width and height
|
||||
const contentPageWidth = (contentBounds.current.width / width) * 100;
|
||||
const contentPageHeight = (contentBounds.current.height / height) * 100;
|
||||
|
||||
// And center it based on the bounds
|
||||
pageX -= contentPageWidth / 2;
|
||||
pageY -= contentPageHeight / 2;
|
||||
|
||||
editorContents.addContent({
|
||||
envelopeItemId: selectedEnvelopeItemId,
|
||||
contentMeta: buildContentMeta({
|
||||
item: selectedContent,
|
||||
page: pageNumber,
|
||||
positionX: pageX,
|
||||
positionY: pageY,
|
||||
width: contentPageWidth,
|
||||
height: contentPageHeight,
|
||||
}),
|
||||
});
|
||||
|
||||
setIsContentWithinBounds(false);
|
||||
setSelectedContent(null);
|
||||
},
|
||||
[isWithinPageBounds, selectedContent, selectedEnvelopeItemId, getPage, editorContents],
|
||||
);
|
||||
|
||||
const selectedContentRef = useLatestRef(selectedContent);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new MutationObserver((_mutations) => {
|
||||
const $page = document.querySelector(PDF_VIEWER_PAGE_SELECTOR);
|
||||
|
||||
if (!$page) {
|
||||
return;
|
||||
}
|
||||
|
||||
contentBounds.current = resolveDragBounds(selectedContentRef.current, $page);
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedContent) {
|
||||
const $page = document.querySelector(PDF_VIEWER_PAGE_SELECTOR);
|
||||
|
||||
if ($page) {
|
||||
contentBounds.current = resolveDragBounds(selectedContent, $page);
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onMouseClick);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onMouseClick);
|
||||
};
|
||||
}, [onMouseClick, onMouseMove, selectedContent]);
|
||||
|
||||
if (!canContentBeChanged(envelope)) {
|
||||
return (
|
||||
<Alert variant="neutral" className="rounded-lg border border-border">
|
||||
<AlertDescription className="text-sm">
|
||||
{match(envelope.status)
|
||||
.with(DocumentStatus.COMPLETED, () => (
|
||||
<Trans>This document has been completed, so its content can no longer be changed.</Trans>
|
||||
))
|
||||
.with(DocumentStatus.REJECTED, () => (
|
||||
<Trans>This document has been rejected, so its content can no longer be changed.</Trans>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<Trans>Content cannot be changed because the document has already been sent.</Trans>
|
||||
))}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
// The organisation's plan caps how much content an envelope may hold. Only
|
||||
// adding is blocked: existing contents stay editable and removable so an
|
||||
// envelope which went over its limit can be brought back down.
|
||||
if (contentLimits.isContentLimitReached || contentLimits.isImageLimitReached) {
|
||||
return (
|
||||
<Alert variant="neutral" className="rounded-lg border border-border" data-testid="content-limit-reached-alert">
|
||||
<AlertDescription className="text-sm">
|
||||
{contentLimits.isContentLimitReached ? (
|
||||
<Plural
|
||||
value={contentLimits.contentLimit}
|
||||
one="This envelope cannot have more than # content. Remove some, or contact support if you need more."
|
||||
other="This envelope cannot have more than # contents. Remove some, or contact support if you need more."
|
||||
/>
|
||||
) : (
|
||||
<Plural
|
||||
value={contentLimits.imageLimit}
|
||||
one="This envelope cannot have more than # image content. Remove some, or contact support if you need more."
|
||||
other="This envelope cannot have more than # image contents. Remove some, or contact support if you need more."
|
||||
/>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-x-2 gap-y-2.5">
|
||||
{contentButtonList.map((content) => (
|
||||
<button
|
||||
key={content.key}
|
||||
type="button"
|
||||
onClick={() => setSelectedContent(content)}
|
||||
onMouseDown={() => setSelectedContent(content)}
|
||||
data-selected={selectedContent?.key === content.key ? true : undefined}
|
||||
className="group flex h-12 cursor-pointer items-center justify-center rounded-lg border border-border px-4 transition-colors"
|
||||
>
|
||||
<p className="flex items-center justify-center gap-x-1.5 font-normal font-noto text-muted-foreground text-sm group-data-[selected]:text-foreground">
|
||||
{<content.icon className="h-4 w-4" />}
|
||||
{t(content.name)}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedContent && (
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white font-noto text-muted-foreground ring-2 transition duration-200 [container-type:size] dark:text-muted',
|
||||
// Match the brand green used for the first recipient's fields and
|
||||
// the rest of the content editor's selection affordances.
|
||||
getRecipientColorStyles('green').base,
|
||||
{
|
||||
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isContentWithinBounds,
|
||||
'dark:text-black/60': isContentWithinBounds,
|
||||
},
|
||||
)}
|
||||
style={{
|
||||
top: coords.y,
|
||||
left: coords.x,
|
||||
height: contentBounds.current.height,
|
||||
width: contentBounds.current.width,
|
||||
}}
|
||||
>
|
||||
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">{t(selectedContent.name)}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type BuildContentMetaOptions = {
|
||||
item: ContentDragDropItem;
|
||||
page: number;
|
||||
positionX: number;
|
||||
positionY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the content meta for a newly dropped content, merging the drop
|
||||
* geometry into the default values for the given content type.
|
||||
*
|
||||
* Geometry is handled per content type since it differs, e.g. lines use
|
||||
* start/end coordinates instead of a position and size.
|
||||
*/
|
||||
/**
|
||||
* The screen size of the drag preview for a content type.
|
||||
*
|
||||
* Image contents preview at their page relative default size so the preview
|
||||
* matches the box which gets created, the rest use a fixed screen size.
|
||||
*/
|
||||
const resolveDragBounds = (item: ContentDragDropItem | null, $page: Element) => {
|
||||
if (item !== null && CONTENT_TYPE_DATA_CONTENT_TYPE[item.type] !== undefined) {
|
||||
const { width, height } = getBoundingClientRect($page);
|
||||
|
||||
return {
|
||||
width: (width * CONTENT_IMAGE_DEFAULT_SIZE.width) / 100,
|
||||
height: (height * CONTENT_IMAGE_DEFAULT_SIZE.height) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
width: DEFAULT_WIDTH_PX,
|
||||
height: DEFAULT_HEIGHT_PX,
|
||||
};
|
||||
};
|
||||
|
||||
const buildContentMeta = ({
|
||||
item,
|
||||
page,
|
||||
positionX,
|
||||
positionY,
|
||||
width,
|
||||
height,
|
||||
}: BuildContentMetaOptions): TEnvelopeContentMeta => {
|
||||
const { type } = item;
|
||||
|
||||
return match(type)
|
||||
.with(EnvelopeContentType.LINE, () => ({
|
||||
...structuredClone(CONTENT_LINE_META_DEFAULT_VALUES),
|
||||
page,
|
||||
// Lines are placed horizontally across the drop area, centered vertically.
|
||||
x1: positionX,
|
||||
x2: positionX + width,
|
||||
y1: positionY + height / 2,
|
||||
y2: positionY + height / 2,
|
||||
}))
|
||||
.with(EnvelopeContentType.IMAGE, () => ({
|
||||
...structuredClone(CONTENT_IMAGE_META_DEFAULT_VALUES),
|
||||
page,
|
||||
// Image contents use a page relative default size, so whether the
|
||||
// author has resized the box can be told when an image is attached.
|
||||
// Centered on the drop point like the other contents.
|
||||
positionX: positionX + width / 2 - CONTENT_IMAGE_DEFAULT_SIZE.width / 2,
|
||||
positionY: positionY + height / 2 - CONTENT_IMAGE_DEFAULT_SIZE.height / 2,
|
||||
...CONTENT_IMAGE_DEFAULT_SIZE,
|
||||
}))
|
||||
.with(EnvelopeContentType.SHAPE, () => ({
|
||||
...structuredClone(CONTENT_SHAPE_META_DEFAULT_VALUES_BY_SHAPE[item.shape ?? EnvelopeContentShapeType.RECTANGLE]),
|
||||
page,
|
||||
positionX,
|
||||
positionY,
|
||||
width,
|
||||
height,
|
||||
}))
|
||||
.with(EnvelopeContentType.TEXT, () => ({
|
||||
...structuredClone(CONTENT_TEXT_META_DEFAULT_VALUES),
|
||||
text: '',
|
||||
page,
|
||||
positionX,
|
||||
positionY,
|
||||
width,
|
||||
height,
|
||||
}))
|
||||
.with(EnvelopeContentType.HIGHLIGHT, () => ({
|
||||
...structuredClone(CONTENT_HIGHLIGHT_META_DEFAULT_VALUES),
|
||||
page,
|
||||
positionX,
|
||||
positionY,
|
||||
width,
|
||||
height,
|
||||
}))
|
||||
.exhaustive();
|
||||
};
|
||||
+1
-11
@@ -94,22 +94,12 @@ export const EnvelopeEditorFieldDragDrop = ({
|
||||
selectedRecipientId,
|
||||
selectedEnvelopeItemId,
|
||||
}: EnvelopeEditorFieldDragDropProps) => {
|
||||
const { envelope, editorFields, isTemplate, getRecipientColorKey, setIsPlacingItem } = useCurrentEnvelopeEditor();
|
||||
const { envelope, editorFields, isTemplate, getRecipientColorKey } = useCurrentEnvelopeEditor();
|
||||
|
||||
const { t } = useLingui();
|
||||
|
||||
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
|
||||
|
||||
// Let the canvas know a field is being placed, so it can get its selection
|
||||
// out of the way of the placement click.
|
||||
useEffect(() => {
|
||||
setIsPlacingItem(selectedField !== null);
|
||||
|
||||
return () => {
|
||||
setIsPlacingItem(false);
|
||||
};
|
||||
}, [selectedField, setIsPlacingItem]);
|
||||
|
||||
const { isWithinPageBounds, getPage } = useDocumentElement();
|
||||
|
||||
const isFieldsDisabled = useMemo(() => {
|
||||
|
||||
+878
-231
File diff suppressed because it is too large
Load Diff
+306
-458
@@ -1,10 +1,8 @@
|
||||
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,
|
||||
@@ -27,33 +25,20 @@ 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 { TabsList, TabsTrigger } from '@radix-ui/react-tabs';
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
FileTextIcon,
|
||||
LayoutGridIcon,
|
||||
MousePointerIcon,
|
||||
PencilIcon,
|
||||
SparklesIcon,
|
||||
} from 'lucide-react';
|
||||
import { AlertTriangleIcon, FileTextIcon, 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';
|
||||
@@ -66,9 +51,7 @@ 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';
|
||||
@@ -89,14 +72,6 @@ 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();
|
||||
|
||||
@@ -104,33 +79,12 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
|
||||
const scrollableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const {
|
||||
envelope,
|
||||
editorFields,
|
||||
editorContents,
|
||||
navigateToStep,
|
||||
editorConfig,
|
||||
selectedEditorTab,
|
||||
setSelectedEditorTab,
|
||||
} = useCurrentEnvelopeEditor();
|
||||
const { envelope, editorFields, navigateToStep, editorConfig } = useCurrentEnvelopeEditor();
|
||||
|
||||
const { currentEnvelopeItem, setCurrentEnvelopeItem, viewerControls } = useCurrentEnvelopeRender();
|
||||
const { currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
|
||||
const { _ } = useLingui();
|
||||
|
||||
/**
|
||||
* Switching tabs resets which items are shown to the default for that tab:
|
||||
* both on the fields tab, and only contents on the contents tab so the page
|
||||
* reads as the document itself. This drives the same toggles as the viewer
|
||||
* toolbar, so the user can still override them for the current tab, but any
|
||||
* override is discarded on the next switch.
|
||||
*/
|
||||
const onEditorTabChange = (tab: EnvelopeEditorTab) => {
|
||||
setSelectedEditorTab(tab);
|
||||
viewerControls.setFieldsVisibility(tab === 'contents' ? 'hidden' : 'visible');
|
||||
viewerControls.setContentsVisibility('visible');
|
||||
};
|
||||
|
||||
const [isAiFieldDialogOpen, setIsAiFieldDialogOpen] = useState(false);
|
||||
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
|
||||
const { revalidate } = useRevalidator();
|
||||
@@ -142,11 +96,6 @@ 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
|
||||
@@ -237,21 +186,6 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
editorFields.setSelectedRecipient(firstSelectableRecipient?.id ?? null);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Deselect a field or content which is not on the current envelope item
|
||||
* when the item changes, otherwise its settings stay open in the sidebar for
|
||||
* something which is no longer on screen.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (editorFields.selectedField && editorFields.selectedField.envelopeItemId !== currentEnvelopeItem?.id) {
|
||||
editorFields.setSelectedField(null);
|
||||
}
|
||||
|
||||
if (editorContents.selectedContent && editorContents.selectedContent.envelopeItemId !== currentEnvelopeItem?.id) {
|
||||
editorContents.setSelectedContent(null);
|
||||
}
|
||||
}, [currentEnvelopeItem?.id]);
|
||||
|
||||
const onDetectClick = () => {
|
||||
if (!team.preferences.aiFeaturesEnabled) {
|
||||
setIsAiEnableDialogOpen(true);
|
||||
@@ -268,405 +202,319 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
});
|
||||
};
|
||||
|
||||
if (!editorConfig.general?.allowAddFieldsStep && !editorConfig.general?.allowAddContentsStep) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/*
|
||||
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
|
||||
}
|
||||
/>
|
||||
|
||||
<EnvelopeEditorInvalidDirectTemplateAlert />
|
||||
|
||||
{/* Document View */}
|
||||
<div className="mt-4 flex h-full flex-col items-center justify-center">
|
||||
{envelope.recipients.length === 0 && (
|
||||
<Alert
|
||||
variant="neutral"
|
||||
className="mb-4 flex max-w-[800px] flex-row items-center justify-between space-y-0 rounded-sm border border-border bg-background"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<AlertTitle>
|
||||
<Trans>Missing Recipients</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans>You need at least one recipient to add fields</Trans>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" onClick={() => void navigateToStep('upload')}>
|
||||
<Trans>Add Recipients</Trans>
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{overlappingFieldPairs.length > 0 && (
|
||||
<Alert
|
||||
variant="warning"
|
||||
className="mt-20 mb-4 flex w-full max-w-[800px] flex-row items-center justify-between space-y-0 rounded-sm"
|
||||
>
|
||||
<div className="flex flex-row items-start gap-3">
|
||||
<AlertTriangleIcon className="mt-0.5 h-5 w-5 flex-shrink-0" />
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<AlertTitle>
|
||||
<Trans>Overlapping fields detected</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
Some fields are placed on top of each other. This may complicate the signing process or cause
|
||||
fields to not work as expected.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{currentEnvelopeItem !== null ? (
|
||||
<EnvelopePdfViewer
|
||||
customPageRenderer={EnvelopeEditorFieldsPageRenderer}
|
||||
scrollParentRef={scrollableContainerRef}
|
||||
errorMessage={PDF_VIEWER_ERROR_MESSAGES.editor}
|
||||
toolbar={['zoom', 'fields', 'contents']}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-32">
|
||||
<FileTextIcon className="h-10 w-10 text-muted-foreground" />
|
||||
<p className="mt-1 text-foreground text-sm">
|
||||
<Trans>No documents found</Trans>
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground text-sm">
|
||||
<Trans>Please upload a document to continue</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Section - Form Fields Panel */}
|
||||
{currentEnvelopeItem && envelope.recipients.length > 0 && (
|
||||
<div className="sticky top-0 h-full w-80 flex-shrink-0 overflow-y-auto border-border border-l bg-background py-2">
|
||||
<Tabs value={selectedEditorTab} onValueChange={(value) => onEditorTabChange(value as EnvelopeEditorTab)}>
|
||||
{editorConfig.general?.allowAddFieldsStep && editorConfig.general?.allowAddContentsStep && (
|
||||
<TabsList className="-mt-4 flex w-full flex-row border-b pt-2 text-muted-foreground text-sm">
|
||||
<TabsTrigger
|
||||
className="group flex min-h-12 w-1/2 items-center justify-center px-2 text-center hover:text-muted-foreground/80 data-[state=active]:shadow-[inset_0_-2px_0_0_hsl(var(--primary))]"
|
||||
value="fields"
|
||||
>
|
||||
<MousePointerIcon className="mr-2 -ml-1 h-3.5 w-3.5 group-data-[state=active]:text-primary" />
|
||||
<Trans>Fields</Trans>
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger
|
||||
className="group flex min-h-12 w-1/2 items-center justify-center px-2 text-center hover:text-muted-foreground/80 data-[state=active]:shadow-[inset_0_-2px_0_0_hsl(var(--primary))]"
|
||||
value="contents"
|
||||
>
|
||||
<LayoutGridIcon className="mr-2 -ml-1 h-3.5 w-3.5 group-data-[state=active]:text-primary" />
|
||||
<Trans>Contents</Trans>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
)}
|
||||
|
||||
{/* Fields tab */}
|
||||
<TabsContent value="fields">
|
||||
{/* Recipient selector section. */}
|
||||
<section className="px-4">
|
||||
<h3 className="mb-2 font-semibold text-foreground text-sm">
|
||||
<Trans>Selected Recipient</Trans>
|
||||
</h3>
|
||||
|
||||
<EnvelopeRecipientSelector
|
||||
selectedRecipient={editorFields.selectedRecipient}
|
||||
onSelectedRecipientChange={(recipient) => editorFields.setSelectedRecipient(recipient.id)}
|
||||
recipients={envelope.recipients}
|
||||
fields={envelope.fields}
|
||||
className="w-full"
|
||||
align="end"
|
||||
<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,
|
||||
})}
|
||||
/>
|
||||
|
||||
{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
|
||||
}
|
||||
<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}`}
|
||||
>
|
||||
<SparklesIcon className="mr-2 -ml-1 h-4 w-4" />
|
||||
<Trans>Detect with AI</Trans>
|
||||
</Button>
|
||||
<PencilIcon className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<AiFieldDetectionDialog
|
||||
open={isAiFieldDialogOpen}
|
||||
onOpenChange={setIsAiFieldDialogOpen}
|
||||
onComplete={onFieldDetectionComplete}
|
||||
envelopeId={envelope.id}
|
||||
teamId={envelope.teamId}
|
||||
/>
|
||||
<EnvelopeEditorInvalidDirectTemplateAlert />
|
||||
|
||||
<AiFeaturesEnableDialog
|
||||
open={isAiEnableDialogOpen}
|
||||
onOpenChange={setIsAiEnableDialogOpen}
|
||||
onEnabled={onAiFeaturesEnabled}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
{/* 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>
|
||||
|
||||
{/* Field details section. */}
|
||||
<AnimateGenericFadeInOut key={editorFields.selectedField?.formId}>
|
||||
{selectedField && (
|
||||
<section>
|
||||
<Separator className="my-4" />
|
||||
<Button variant="outline" onClick={() => void navigateToStep('upload')}>
|
||||
<Trans>Add Recipients</Trans>
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{searchParams.get('devmode') && (
|
||||
<>
|
||||
<div className="px-4">
|
||||
<h3 className="mb-3 font-semibold text-foreground text-sm">
|
||||
<Trans>Developer Mode</Trans>
|
||||
</h3>
|
||||
{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="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>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
{currentEnvelopeItem !== null ? (
|
||||
<EnvelopePdfViewer
|
||||
customPageRenderer={EnvelopeEditorFieldsPageRenderer}
|
||||
scrollParentRef={scrollableContainerRef}
|
||||
errorMessage={PDF_VIEWER_ERROR_MESSAGES.editor}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
</ContentSettingsFormProvider>
|
||||
</div>
|
||||
|
||||
<ContentImageUploadDialog.Root />
|
||||
</>
|
||||
{/* 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope';
|
||||
import { getEnvelopeItemPermissions, mapSecondaryIdToTemplateId } 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,6 +222,7 @@ 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,7 +215,6 @@ export const EnvelopeEditorPreviewPage = () => {
|
||||
envelopeItems={envelope.envelopeItems}
|
||||
token={undefined}
|
||||
fields={fieldsWithPlaceholders}
|
||||
contents={envelope.contents}
|
||||
recipients={envelope.recipients.map((recipient) => ({
|
||||
...recipient,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
@@ -226,7 +225,7 @@ export const EnvelopeEditorPreviewPage = () => {
|
||||
}}
|
||||
>
|
||||
<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}>
|
||||
<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} />
|
||||
|
||||
@@ -248,7 +247,6 @@ 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,7 +18,6 @@ export const EnvelopeEditorRenderProviderWrapper = ({
|
||||
envelope={envelope}
|
||||
envelopeItems={envelope.envelopeItems}
|
||||
fields={envelope.fields}
|
||||
contents={envelope.contents}
|
||||
recipients={envelope.recipients}
|
||||
token={token}
|
||||
presignToken={presignedToken}
|
||||
|
||||
@@ -54,7 +54,6 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
envelope,
|
||||
setLocalEnvelope,
|
||||
editorFields,
|
||||
editorContents,
|
||||
editorConfig,
|
||||
isEmbedded,
|
||||
navigateToStep,
|
||||
@@ -140,7 +139,7 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
);
|
||||
|
||||
const { mutateAsync: replaceEnvelopeItemPdf } = trpc.envelope.item.replacePdf.useMutation({
|
||||
onSuccess: ({ data, fields, contents }) => {
|
||||
onSuccess: ({ data, fields }) => {
|
||||
// Update the envelope item with the new documentDataId.
|
||||
setLocalEnvelope({
|
||||
envelopeItems: envelope.envelopeItems.map((item) =>
|
||||
@@ -154,12 +153,6 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
setLocalEnvelope({ fields });
|
||||
editorFields.resetForm(fields);
|
||||
}
|
||||
|
||||
// Same for contents.
|
||||
if (contents) {
|
||||
setLocalEnvelope({ contents });
|
||||
editorContents.resetForm(contents);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -188,18 +181,13 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
// Directly commit the files for embedded documents since those are not uploaded
|
||||
// until the end of the embedded flow.
|
||||
if (isEmbedded) {
|
||||
// New items go after the existing ones, in the order they were added.
|
||||
// Deleting an item does not renumber the rest, so this continues from
|
||||
// the highest order rather than the item count.
|
||||
const highestOrder = envelope.envelopeItems.reduce((highest, item) => Math.max(highest, item.order), 0);
|
||||
|
||||
setLocalEnvelope({
|
||||
envelopeItems: [
|
||||
...envelope.envelopeItems,
|
||||
...newUploadingFiles.map((file, index) => ({
|
||||
...newUploadingFiles.map((file) => ({
|
||||
id: file.envelopeItemId!,
|
||||
title: file.title,
|
||||
order: highestOrder + index + 1,
|
||||
order: envelope.envelopeItems.length + 1,
|
||||
envelopeId: envelope.id,
|
||||
data: file.data!,
|
||||
documentDataId: '',
|
||||
@@ -285,20 +273,12 @@ 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;
|
||||
}
|
||||
@@ -346,17 +326,14 @@ 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: fieldsWithoutDeletedItem,
|
||||
contents: contentsWithoutDeletedItem,
|
||||
fields: envelope.fields.filter((field) => field.envelopeItemId !== envelopeItemId),
|
||||
});
|
||||
|
||||
// Reset editor fields and contents.
|
||||
// Reset editor fields.
|
||||
editorFields.resetForm(fieldsWithoutDeletedItem);
|
||||
editorContents.resetForm(contentsWithoutDeletedItem);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -90,13 +90,7 @@ export const EnvelopeEditor = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const {
|
||||
general: {
|
||||
minimizeLeftSidebar,
|
||||
allowUploadAndRecipientStep,
|
||||
allowAddFieldsStep,
|
||||
allowAddContentsStep,
|
||||
allowPreviewStep,
|
||||
},
|
||||
general: { minimizeLeftSidebar, allowUploadAndRecipientStep, allowAddFieldsStep, allowPreviewStep },
|
||||
actions: {
|
||||
allowDistributing,
|
||||
allowDirectLink,
|
||||
@@ -114,7 +108,7 @@ export const EnvelopeEditor = () => {
|
||||
steps.push(UPLOAD_STEP);
|
||||
}
|
||||
|
||||
if (allowAddFieldsStep || allowAddContentsStep) {
|
||||
if (allowAddFieldsStep) {
|
||||
steps.push(ADD_FIELDS_STEP);
|
||||
}
|
||||
|
||||
@@ -530,16 +524,11 @@ export const EnvelopeEditor = () => {
|
||||
pageToRender,
|
||||
allowUploadAndRecipientStep,
|
||||
allowAddFieldsStep,
|
||||
allowAddContentsStep,
|
||||
allowPreviewStep,
|
||||
})
|
||||
.with({ pageToRender: 'loading' }, () => <SpinnerBox className="py-32" />)
|
||||
.with({ pageToRender: 'upload', allowUploadAndRecipientStep: true }, () => <EnvelopeEditorUploadPage />)
|
||||
.with(
|
||||
{ pageToRender: 'addFields', allowAddFieldsStep: true },
|
||||
{ pageToRender: 'addFields', allowAddContentsStep: true },
|
||||
() => <EnvelopeEditorFieldsPage />,
|
||||
)
|
||||
.with({ pageToRender: 'addFields', allowAddFieldsStep: true }, () => <EnvelopeEditorFieldsPage />)
|
||||
.with({ pageToRender: 'preview', allowPreviewStep: true }, () => <EnvelopeEditorPreviewPage />)
|
||||
.otherwise(() => null)}
|
||||
</div>
|
||||
|
||||
+3
-64
@@ -5,9 +5,7 @@ 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';
|
||||
@@ -24,12 +22,9 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const {
|
||||
version,
|
||||
envelopeStatus,
|
||||
currentEnvelopeItem,
|
||||
fields,
|
||||
contents,
|
||||
contentImages,
|
||||
signatures,
|
||||
recipients,
|
||||
getRecipientColorKey,
|
||||
@@ -37,40 +32,16 @@ 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,
|
||||
fieldsVisibility,
|
||||
applyPageItemsVisibility,
|
||||
hasFailedImage,
|
||||
} = usePageRenderer(({ stage, pageLayer }) => {
|
||||
const { stage, pageLayer, konvaContainer, unscaledViewport } = 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 [];
|
||||
@@ -134,8 +105,7 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
|
||||
translations: fieldTranslations,
|
||||
pageWidth: unscaledViewport.width,
|
||||
pageHeight: unscaledViewport.height,
|
||||
// Muted fields are rendered with the read-only styling.
|
||||
color: fieldsVisibility === 'muted' ? 'readOnly' : getRecipientColorKey(field.recipientId),
|
||||
color: getRecipientColorKey(field.recipientId),
|
||||
editable: false,
|
||||
mode: overrideSettings?.mode ?? 'edit',
|
||||
});
|
||||
@@ -161,12 +131,6 @@ 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);
|
||||
@@ -175,28 +139,6 @@ 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
|
||||
*/
|
||||
@@ -217,10 +159,8 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
|
||||
renderFieldOnLayer(field);
|
||||
});
|
||||
|
||||
applyPageItemsVisibility();
|
||||
|
||||
pageLayer.current.batchDraw();
|
||||
}, [localPageFields, signaturesByFieldId, fieldsVisibility]);
|
||||
}, [localPageFields, signaturesByFieldId]);
|
||||
|
||||
if (!currentEnvelopeItem) {
|
||||
return null;
|
||||
@@ -230,7 +170,6 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
|
||||
<>
|
||||
{overrideSettings?.showRecipientTooltip &&
|
||||
pageData.imageLoadingState === 'loaded' &&
|
||||
fieldsVisibility === 'visible' &&
|
||||
localPageFields.map((field) => (
|
||||
<EnvelopeRecipientFieldTooltip
|
||||
key={field.id}
|
||||
|
||||
+1
-33
@@ -10,7 +10,6 @@ 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,
|
||||
@@ -18,7 +17,6 @@ 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';
|
||||
@@ -51,7 +49,7 @@ type GenericLocalField = TEnvelope['fields'][number] & {
|
||||
|
||||
export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderData }) => {
|
||||
const { t, i18n } = useLingui();
|
||||
const { currentEnvelopeItem, contentImages, setRenderError } = useCurrentEnvelopeRender();
|
||||
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
|
||||
const { sessionData } = useOptionalSession();
|
||||
|
||||
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
|
||||
@@ -537,39 +535,10 @@ 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();
|
||||
};
|
||||
@@ -608,7 +577,6 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
||||
pageLayer.current.destroyChildren();
|
||||
cachedRenderFields.current.clear();
|
||||
|
||||
renderContents();
|
||||
renderFields();
|
||||
|
||||
pageLayer.current.batchDraw();
|
||||
|
||||
@@ -8,40 +8,20 @@ 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,
|
||||
toolbar,
|
||||
toolbarClassName,
|
||||
className,
|
||||
...props
|
||||
}: EnvelopePdfViewerProps) => {
|
||||
export const EnvelopePdfViewer = ({ errorMessage, className, ...props }: EnvelopePdfViewerProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const $el = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { currentEnvelopeItem, renderError, viewerControls } = useCurrentEnvelopeRender();
|
||||
const { currentEnvelopeItem, renderError } = useCurrentEnvelopeRender();
|
||||
|
||||
if (renderError || !currentEnvelopeItem) {
|
||||
return (
|
||||
@@ -65,24 +45,12 @@ export const EnvelopePdfViewer = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
<PDFViewerLazy
|
||||
key={`${currentEnvelopeItem.envelopeId}-${currentEnvelopeItem.id}`}
|
||||
{...props}
|
||||
className={cn('h-full w-full max-w-[800px]', className)}
|
||||
data={currentEnvelopeItem.data}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -5,26 +5,15 @@ 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, isPageReady, imageProps }: PdfViewerPageImageProps) => {
|
||||
const isLoading = !isPageReady && imageLoadingState !== 'error';
|
||||
|
||||
export const PdfViewerPageImage = ({ imageLoadingState, imageProps }: PdfViewerPageImageProps) => {
|
||||
return (
|
||||
<>
|
||||
{/* Loading State */}
|
||||
{isLoading && (
|
||||
<div
|
||||
className="absolute inset-0 z-10 flex items-center justify-center text-muted-foreground opacity-20"
|
||||
data-testid="page-loader"
|
||||
>
|
||||
{imageLoadingState === 'loading' && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center text-muted-foreground opacity-20">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
@@ -39,12 +28,7 @@ export const PdfViewerPageImage = ({ imageLoadingState, isPageReady, imageProps
|
||||
|
||||
{/* The PDF image. */}
|
||||
{imageProps.src && (
|
||||
<img
|
||||
{...imageProps}
|
||||
className={cn(imageProps.className, 'select-none', !isPageReady && 'invisible')}
|
||||
draggable={false}
|
||||
alt=""
|
||||
/>
|
||||
<img {...imageProps} className={cn(imageProps.className, 'select-none')} draggable={false} alt="" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
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/legacy/build/pdf.mjs';
|
||||
import pdfjsWorker from 'pdfjs-dist/legacy/build/pdf.worker.mjs?url';
|
||||
import type React from 'react';
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { ScrollTarget } from '../virtual-list/use-virtual-list';
|
||||
import { useVirtualList } from '../virtual-list/use-virtual-list';
|
||||
@@ -29,16 +29,6 @@ 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;
|
||||
|
||||
@@ -62,23 +52,6 @@ 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.
|
||||
@@ -91,8 +64,6 @@ export default function PDFViewer({
|
||||
data,
|
||||
scrollParentRef,
|
||||
onDocumentLoad,
|
||||
zoom = 1,
|
||||
maxPageWidth,
|
||||
customPageRenderer,
|
||||
...props
|
||||
}: PDFViewerProps) {
|
||||
@@ -242,8 +213,6 @@ export default function PDFViewer({
|
||||
numPages={pages.length}
|
||||
pages={pages}
|
||||
pdf={pdfRef.current}
|
||||
zoom={zoom}
|
||||
maxPageWidth={maxPageWidth}
|
||||
customPageRenderer={customPageRenderer}
|
||||
/>
|
||||
)}
|
||||
@@ -257,8 +226,6 @@ type VirtualizedPageListProps = {
|
||||
pages: PageMeta[];
|
||||
numPages: number;
|
||||
pdf: pdfjsLib.PDFDocumentProxy;
|
||||
zoom: number;
|
||||
maxPageWidth?: number;
|
||||
customPageRenderer?: React.FunctionComponent<{ pageData: PageRenderData }>;
|
||||
};
|
||||
|
||||
@@ -268,8 +235,6 @@ const VirtualizedPageList = ({
|
||||
pages,
|
||||
numPages,
|
||||
pdf,
|
||||
zoom,
|
||||
maxPageWidth,
|
||||
customPageRenderer,
|
||||
}: VirtualizedPageListProps) => {
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
@@ -279,81 +244,22 @@ const VirtualizedPageList = ({
|
||||
constraintRef,
|
||||
contentRef,
|
||||
itemCount: numPages,
|
||||
itemSize: (index, width) => getPageItemSize(pages[index], getDisplayWidth(width, zoom, maxPageWidth)),
|
||||
itemSize: (index, width) => {
|
||||
const pageMeta = pages[index];
|
||||
|
||||
// Calculate height based on aspect ratio and available width
|
||||
const aspectRatio = pageMeta.height / pageMeta.width;
|
||||
const scaledHeight = width * aspectRatio;
|
||||
|
||||
// Add 32px for the page number text and margins (my-2 = 8px * 2 + text height ~16px)
|
||||
// Add additional 2px for the top and bottom borders.
|
||||
return scaledHeight + 32 + 2;
|
||||
},
|
||||
overscan: 5,
|
||||
});
|
||||
|
||||
/**
|
||||
* 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}
|
||||
@@ -362,11 +268,7 @@ const VirtualizedPageList = ({
|
||||
data-page-count={numPages}
|
||||
style={{
|
||||
height: `${totalSize}px`,
|
||||
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',
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
@@ -375,11 +277,11 @@ const VirtualizedPageList = ({
|
||||
const pageMeta = pages[index];
|
||||
const pageNumber = index + 1;
|
||||
|
||||
// Calculate scale based on the rendered page width.
|
||||
const scale = displayWidth / pageMeta.width;
|
||||
// Calculate scale based on constraint width
|
||||
const scale = constraintWidth / pageMeta.width;
|
||||
|
||||
const scaledWidth = displayWidth;
|
||||
const scaledHeight = Math.round(pageMeta.height * scale);
|
||||
const scaledWidth = Math.floor(pageMeta.width * scale);
|
||||
const scaledHeight = Math.floor(pageMeta.height * scale);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -388,7 +290,7 @@ const VirtualizedPageList = ({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: displayWidth,
|
||||
width: constraintWidth,
|
||||
height: `${virtualItem.size}px`,
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
@@ -404,12 +306,7 @@ const VirtualizedPageList = ({
|
||||
customPageRenderer={customPageRenderer}
|
||||
/>
|
||||
|
||||
<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,
|
||||
})}
|
||||
>
|
||||
<p className="my-2 text-center text-[11px] text-muted-foreground/80">
|
||||
<Trans>
|
||||
Page {pageNumber} of {numPages}
|
||||
</Trans>
|
||||
@@ -421,54 +318,6 @@ 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;
|
||||
@@ -500,19 +349,8 @@ 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 (
|
||||
// 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 }}>
|
||||
<div className="relative w-full rounded border border-border" style={{ width: scaledWidth, height: scaledHeight }}>
|
||||
{CustomPageRenderer && imageLoadingState === 'loaded' && (
|
||||
<CustomPageRenderer
|
||||
pageData={{
|
||||
@@ -522,12 +360,11 @@ const PdfViewerPage = ({
|
||||
pageWidth: unscaledWidth,
|
||||
pageHeight: unscaledHeight,
|
||||
imageLoadingState,
|
||||
onReadyChange: setIsCustomRendererReady,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PdfViewerPageImage imageLoadingState={imageLoadingState} isPageReady={isPageReady} imageProps={imageProps} />
|
||||
<PdfViewerPageImage imageLoadingState={imageLoadingState} imageProps={imageProps} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -665,16 +502,8 @@ const usePdfPageImage = ({ pageNumber, pdf, scale, scaledWidth, scaledHeight }:
|
||||
const imageProps = useMemo(
|
||||
(): React.ImgHTMLAttributes<HTMLImageElement> & Record<string, unknown> & { alt: '' } => ({
|
||||
className: PDF_VIEWER_PAGE_CLASSNAME,
|
||||
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',
|
||||
},
|
||||
width: Math.floor(scaledWidth),
|
||||
height: Math.floor(scaledHeight),
|
||||
alt: '',
|
||||
onLoad: () => setImageLoadingState('loaded'),
|
||||
onError: () => setImageLoadingState('error'),
|
||||
|
||||
@@ -209,6 +209,7 @@ 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,8 +595,6 @@ 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<
|
||||
@@ -822,54 +820,6 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="claims.envelopeContentCount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Envelope Content Count</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<Trans>Maximum number of contents per envelope allowed. 0 = Unlimited</Trans>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="claims.envelopeContentImageCount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Envelope Content Image Count</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<Trans>Maximum number of image contents per envelope allowed. 0 = Unlimited</Trans>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -30,13 +30,6 @@ export default function Layout() {
|
||||
(organisation.subscription && organisation.subscription.status === SubscriptionStatus.INACTIVE) ||
|
||||
isOrganisationPendingPayment(organisation);
|
||||
|
||||
// The content allowances come straight from the organisation's claim, as
|
||||
// they are already known on the client.
|
||||
const contentLimits = {
|
||||
maximumEnvelopeContentCount: organisation.organisationClaim.envelopeContentCount,
|
||||
maximumEnvelopeContentImageCount: organisation.organisationClaim.envelopeContentImageCount,
|
||||
};
|
||||
|
||||
if (isRestricted) {
|
||||
return {
|
||||
quota: {
|
||||
@@ -50,7 +43,6 @@ export default function Layout() {
|
||||
directTemplates: 0,
|
||||
},
|
||||
maximumEnvelopeItemCount: 0,
|
||||
...contentLimits,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,7 +50,6 @@ export default function Layout() {
|
||||
quota: PAID_PLAN_LIMITS,
|
||||
remaining: PAID_PLAN_LIMITS,
|
||||
maximumEnvelopeItemCount: DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
|
||||
...contentLimits,
|
||||
};
|
||||
}, [organisation]);
|
||||
|
||||
|
||||
@@ -160,7 +160,6 @@ 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,7 +176,6 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
|
||||
envelopeItems={envelope.envelopeItems}
|
||||
token={undefined}
|
||||
fields={envelope.fields}
|
||||
contents={envelope.contents}
|
||||
recipients={envelope.recipients}
|
||||
overrideSettings={{
|
||||
showRecipientTooltip: true,
|
||||
@@ -256,6 +255,7 @@ 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,10 +15,7 @@ import { redirect } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { Header as AuthenticatedHeader } from '~/components/general/app-header';
|
||||
import {
|
||||
DirectTemplateInvalidPageView,
|
||||
type DirectTemplateInvalidReason,
|
||||
} from '~/components/general/direct-template/direct-template-invalid-page';
|
||||
import { DirectTemplateInvalidPageView } 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';
|
||||
@@ -80,13 +77,13 @@ const handleV1Loader = async ({ params, request }: Route.LoaderArgs) => {
|
||||
if (recipientsWithMissingFields.length > 0) {
|
||||
return {
|
||||
isAccessAuthValid: true,
|
||||
invalidReason: 'MISSING_SIGNATURE_FIELD',
|
||||
isTemplateMissingSignatures: true,
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
isAccessAuthValid: true,
|
||||
invalidReason: null,
|
||||
isTemplateMissingSignatures: false,
|
||||
template: {
|
||||
...template,
|
||||
folder: null,
|
||||
@@ -111,7 +108,7 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
|
||||
.then((envelopeForSigning) => {
|
||||
return {
|
||||
isDocumentAccessValid: true,
|
||||
invalidReason: null,
|
||||
isTemplateMissingSignatures: false,
|
||||
envelopeForSigning,
|
||||
} as const;
|
||||
})
|
||||
@@ -124,18 +121,10 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
|
||||
} as const;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if (error.code === AppErrorCode.MISSING_SIGNATURE_FIELD) {
|
||||
return {
|
||||
isDocumentAccessValid: true,
|
||||
invalidReason,
|
||||
isTemplateMissingSignatures: true,
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -212,8 +201,8 @@ const DirectSigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV
|
||||
return <DirectTemplateAuthPageView />;
|
||||
}
|
||||
|
||||
if (data.invalidReason) {
|
||||
return <DirectTemplateInvalidPageView reason={data.invalidReason} />;
|
||||
if (data.isTemplateMissingSignatures) {
|
||||
return <DirectTemplateInvalidPageView />;
|
||||
}
|
||||
|
||||
const { template, directTemplateRecipient } = data;
|
||||
@@ -270,8 +259,8 @@ const DirectSigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV
|
||||
return <DocumentSigningAuthPageView email={''} emailHasAccount={true} />;
|
||||
}
|
||||
|
||||
if (data.invalidReason) {
|
||||
return <DirectTemplateInvalidPageView reason={data.invalidReason} />;
|
||||
if (data.isTemplateMissingSignatures) {
|
||||
return <DirectTemplateInvalidPageView />;
|
||||
}
|
||||
|
||||
const { envelope, recipient } = data.envelopeForSigning;
|
||||
@@ -294,7 +283,6 @@ const DirectSigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV
|
||||
version="current"
|
||||
envelope={envelope}
|
||||
envelopeItems={envelope.envelopeItems}
|
||||
contents={envelope.contents}
|
||||
token={recipient.token}
|
||||
>
|
||||
<DocumentSigningPageViewV2 />
|
||||
|
||||
@@ -581,7 +581,6 @@ const SigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV2Loade
|
||||
version="current"
|
||||
envelope={envelope}
|
||||
envelopeItems={envelope.envelopeItems}
|
||||
contents={envelope.contents}
|
||||
token={recipient.token}
|
||||
>
|
||||
<DocumentSigningPageViewV2 />
|
||||
|
||||
@@ -358,7 +358,6 @@ const EmbedDirectTemplatePageV2 = ({ data }: { data: Awaited<ReturnType<typeof h
|
||||
version="current"
|
||||
envelope={envelope}
|
||||
envelopeItems={envelope.envelopeItems}
|
||||
contents={envelope.contents}
|
||||
token={recipient.token}
|
||||
>
|
||||
<EmbedSignDocumentV2ClientPage
|
||||
|
||||
@@ -451,7 +451,6 @@ const EmbedSignDocumentPageV2 = ({ data }: { data: Awaited<ReturnType<typeof han
|
||||
version="current"
|
||||
envelope={envelope}
|
||||
envelopeItems={envelope.envelopeItems}
|
||||
contents={envelope.contents}
|
||||
token={token}
|
||||
>
|
||||
<EmbedSignDocumentV2ClientPage
|
||||
|
||||
@@ -50,7 +50,6 @@ export default function EmbedPlaygroundPage() {
|
||||
allowConfigureEnvelopeTitle: true,
|
||||
allowUploadAndRecipientStep: true,
|
||||
allowAddFieldsStep: true,
|
||||
allowAddContentsStep: true,
|
||||
allowPreviewStep: true,
|
||||
minimizeLeftSidebar: true,
|
||||
});
|
||||
|
||||
@@ -149,8 +149,6 @@ export default function AuthoringLayout() {
|
||||
quota: PAID_PLAN_LIMITS,
|
||||
remaining: PAID_PLAN_LIMITS,
|
||||
maximumEnvelopeItemCount: organisationClaim.envelopeItemCount,
|
||||
maximumEnvelopeContentCount: organisationClaim.envelopeContentCount,
|
||||
maximumEnvelopeContentImageCount: organisationClaim.envelopeContentImageCount,
|
||||
}}
|
||||
teamId={team.id}
|
||||
>
|
||||
|
||||
@@ -13,11 +13,7 @@ import {
|
||||
import type { TEnvelopeFieldAndMeta } from '@documenso/lib/types/field-meta';
|
||||
import { fireAndForget } from '@documenso/lib/universal/fire-and-forget';
|
||||
import { extractDerivedDocumentMeta } from '@documenso/lib/utils/document';
|
||||
import {
|
||||
buildEmbeddedEditorOptions,
|
||||
buildEmbeddedFeatures,
|
||||
getPendingEmbedImagesToUpload,
|
||||
} from '@documenso/lib/utils/embed-config';
|
||||
import { buildEmbeddedEditorOptions, buildEmbeddedFeatures } from '@documenso/lib/utils/embed-config';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TCreateEnvelopePayload } from '@documenso/trpc/server/envelope-router/create-envelope.types';
|
||||
@@ -176,7 +172,7 @@ const EnvelopeCreatePage = ({ embedAuthoringOptions }: EnvelopeCreatePageProps)
|
||||
|
||||
const buildCreateEnvelopeRequest = (
|
||||
envelope: Omit<TEditorEnvelope, 'id'>,
|
||||
): { payload: TCreateEnvelopePayload; files: File[]; contentImages: File[] } => {
|
||||
): { payload: TCreateEnvelopePayload; files: File[] } => {
|
||||
const sortedItems = [...envelope.envelopeItems].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
|
||||
const itemIdToIndex = new Map<string, number>();
|
||||
@@ -228,26 +224,6 @@ const EnvelopeCreatePage = ({ embedAuthoringOptions }: EnvelopeCreatePageProps)
|
||||
};
|
||||
});
|
||||
|
||||
const imagesToUpload = getPendingEmbedImagesToUpload(envelope.contents);
|
||||
|
||||
const contents = envelope.contents.map((content) => {
|
||||
const image = imagesToUpload.find((imageToUpload) => imageToUpload.id === content.dataContentId);
|
||||
|
||||
// Nothing is uploaded before the envelope exists, so every image must
|
||||
// go along with the request.
|
||||
if (content.dataContentId && !image) {
|
||||
throw new Error(`Content "${content.id}" has no image data`);
|
||||
}
|
||||
|
||||
return {
|
||||
identifier: itemIdToIndex.get(content.envelopeItemId),
|
||||
contentMeta: content.contentMeta,
|
||||
imageIndex: image?.index,
|
||||
};
|
||||
});
|
||||
|
||||
const contentImages = imagesToUpload.map((image) => image.file);
|
||||
|
||||
const payload: TCreateEnvelopePayload = {
|
||||
title: envelope.title,
|
||||
type: envelope.type,
|
||||
@@ -261,7 +237,6 @@ const EnvelopeCreatePage = ({ embedAuthoringOptions }: EnvelopeCreatePageProps)
|
||||
: undefined,
|
||||
folderId: envelope.folderId ?? undefined,
|
||||
recipients,
|
||||
contents,
|
||||
attachments: envelope.attachments,
|
||||
meta: {
|
||||
...envelope.documentMeta,
|
||||
@@ -280,7 +255,7 @@ const EnvelopeCreatePage = ({ embedAuthoringOptions }: EnvelopeCreatePageProps)
|
||||
},
|
||||
};
|
||||
|
||||
return { payload, files, contentImages };
|
||||
return { payload, files };
|
||||
};
|
||||
|
||||
const createEmbeddedEnvelope = async (envelopeWithoutId: Omit<TEditorEnvelope, 'id'>) => {
|
||||
@@ -291,7 +266,7 @@ const EnvelopeCreatePage = ({ embedAuthoringOptions }: EnvelopeCreatePageProps)
|
||||
setIsCreatingEnvelope(true);
|
||||
|
||||
try {
|
||||
const { payload, files, contentImages } = buildCreateEnvelopeRequest(envelopeWithoutId);
|
||||
const { payload, files } = buildCreateEnvelopeRequest(envelopeWithoutId);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
@@ -300,10 +275,6 @@ const EnvelopeCreatePage = ({ embedAuthoringOptions }: EnvelopeCreatePageProps)
|
||||
formData.append('files', file);
|
||||
}
|
||||
|
||||
for (const contentImage of contentImages) {
|
||||
formData.append('contentImages', contentImage);
|
||||
}
|
||||
|
||||
const { id } = await createEmbeddingEnvelope(formData);
|
||||
|
||||
// Send a message to the parent window with the document details
|
||||
@@ -406,7 +377,6 @@ const EnvelopeCreatePage = ({ embedAuthoringOptions }: EnvelopeCreatePageProps)
|
||||
},
|
||||
recipients,
|
||||
fields: [],
|
||||
contents: [],
|
||||
envelopeItems: [],
|
||||
directLink: null,
|
||||
team: {
|
||||
@@ -427,7 +397,7 @@ const EnvelopeCreatePage = ({ embedAuthoringOptions }: EnvelopeCreatePageProps)
|
||||
return (
|
||||
<div className="relative min-h-screen min-w-screen">
|
||||
{isCreatingEnvelope && (
|
||||
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-background">
|
||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-background">
|
||||
<Spinner />
|
||||
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
@@ -441,7 +411,7 @@ const EnvelopeCreatePage = ({ embedAuthoringOptions }: EnvelopeCreatePageProps)
|
||||
)}
|
||||
|
||||
{createdEnvelope && (
|
||||
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-background">
|
||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-background">
|
||||
<div className="mx-auto w-full max-w-md text-center">
|
||||
<CheckCircle2Icon className="mx-auto h-16 w-16 text-primary" />
|
||||
|
||||
|
||||
@@ -11,11 +11,7 @@ import {
|
||||
ZEmbedEditEnvelopeAuthoringSchema,
|
||||
} from '@documenso/lib/types/envelope-editor';
|
||||
import type { TEnvelopeFieldAndMeta } from '@documenso/lib/types/field-meta';
|
||||
import {
|
||||
buildEmbeddedEditorOptions,
|
||||
getPendingEmbedImagesToUpload,
|
||||
PRESIGNED_ENVELOPE_ITEM_ID_PREFIX,
|
||||
} from '@documenso/lib/utils/embed-config';
|
||||
import { buildEmbeddedEditorOptions, PRESIGNED_ENVELOPE_ITEM_ID_PREFIX } from '@documenso/lib/utils/embed-config';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TUpdateEmbeddingEnvelopePayload } from '@documenso/trpc/server/embedding-router/update-embedding-envelope.types';
|
||||
@@ -177,7 +173,7 @@ const EnvelopeEditPage = ({ embedAuthoringOptions }: EnvelopeEditPageProps) => {
|
||||
|
||||
const buildUpdateEnvelopeRequest = (
|
||||
envelope: TEditorEnvelope,
|
||||
): { payload: TUpdateEmbeddingEnvelopePayload; files: File[]; contentImages: File[] } => {
|
||||
): { payload: TUpdateEmbeddingEnvelopePayload; files: File[] } => {
|
||||
const files: File[] = [];
|
||||
|
||||
const envelopeItems = envelope.envelopeItems.map((item) => {
|
||||
@@ -231,24 +227,6 @@ const EnvelopeEditPage = ({ embedAuthoringOptions }: EnvelopeEditPageProps) => {
|
||||
};
|
||||
});
|
||||
|
||||
// Removes duplicate content images, and gets the files to upload.
|
||||
const imagesToUpload = getPendingEmbedImagesToUpload(envelope.contents);
|
||||
|
||||
const contentImages = imagesToUpload.map((image) => image.file);
|
||||
|
||||
const contents = envelope.contents.map((content) => {
|
||||
const image = imagesToUpload.find((imageToUpload) => imageToUpload.id === content.dataContentId);
|
||||
|
||||
// A new image is referenced by its index, an uploaded one by its ID.
|
||||
return {
|
||||
id: content.id,
|
||||
envelopeItemId: content.envelopeItemId,
|
||||
contentMeta: content.contentMeta,
|
||||
dataContentId: image ? null : content.dataContentId,
|
||||
imageIndex: image?.index,
|
||||
};
|
||||
});
|
||||
|
||||
const payload: TUpdateEmbeddingEnvelopePayload = {
|
||||
envelopeId: envelope.id,
|
||||
data: {
|
||||
@@ -261,7 +239,6 @@ const EnvelopeEditPage = ({ embedAuthoringOptions }: EnvelopeEditPageProps) => {
|
||||
recipients,
|
||||
envelopeItems,
|
||||
attachments: envelope.attachments,
|
||||
contents,
|
||||
},
|
||||
meta: {
|
||||
...envelope.documentMeta,
|
||||
@@ -280,7 +257,7 @@ const EnvelopeEditPage = ({ embedAuthoringOptions }: EnvelopeEditPageProps) => {
|
||||
},
|
||||
};
|
||||
|
||||
return { payload, files, contentImages };
|
||||
return { payload, files };
|
||||
};
|
||||
|
||||
const updateEmbeddedEnvelope = async (envelope: TEditorEnvelope) => {
|
||||
@@ -291,7 +268,7 @@ const EnvelopeEditPage = ({ embedAuthoringOptions }: EnvelopeEditPageProps) => {
|
||||
setIsUpdatingEnvelope(true);
|
||||
|
||||
try {
|
||||
const { payload, files, contentImages } = buildUpdateEnvelopeRequest(envelope);
|
||||
const { payload, files } = buildUpdateEnvelopeRequest(envelope);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
@@ -300,10 +277,6 @@ const EnvelopeEditPage = ({ embedAuthoringOptions }: EnvelopeEditPageProps) => {
|
||||
formData.append('files', file);
|
||||
}
|
||||
|
||||
for (const contentImage of contentImages) {
|
||||
formData.append('contentImages', contentImage);
|
||||
}
|
||||
|
||||
await updateEmbeddingEnvelope(formData);
|
||||
|
||||
// Send a message to the parent window with the document details
|
||||
@@ -359,7 +332,7 @@ const EnvelopeEditPage = ({ embedAuthoringOptions }: EnvelopeEditPageProps) => {
|
||||
return (
|
||||
<div className="relative min-h-screen min-w-screen">
|
||||
{isUpdatingEnvelope && (
|
||||
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-background">
|
||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-background">
|
||||
<Spinner />
|
||||
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
@@ -373,7 +346,7 @@ const EnvelopeEditPage = ({ embedAuthoringOptions }: EnvelopeEditPageProps) => {
|
||||
)}
|
||||
|
||||
{updatedEnvelope && (
|
||||
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-background">
|
||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-background">
|
||||
<div className="mx-auto w-full max-w-md text-center">
|
||||
<CheckCircle2Icon className="mx-auto h-16 w-16 text-primary" />
|
||||
|
||||
|
||||
@@ -18,28 +18,10 @@ 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.`,
|
||||
@@ -54,9 +36,6 @@ 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.`,
|
||||
@@ -151,10 +130,6 @@ 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,16 +234,9 @@ 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,8 +18,6 @@ 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';
|
||||
|
||||
@@ -344,7 +342,3 @@ 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);
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
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;
|
||||
@@ -1,156 +0,0 @@
|
||||
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.
@@ -1,376 +0,0 @@
|
||||
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,8 +48,6 @@ const seedSubscriptionClaim = (name: string) =>
|
||||
memberCount: 1,
|
||||
envelopeItemCount: 10,
|
||||
recipientCount: 10,
|
||||
envelopeContentCount: 0,
|
||||
envelopeContentImageCount: 0,
|
||||
flags: {},
|
||||
documentRateLimits: [],
|
||||
emailRateLimits: [],
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
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 organization')).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, 'Organization 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');
|
||||
});
|
||||
@@ -1,296 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,97 +0,0 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { EnvelopeContentType, type TEnvelopeContentMetaInput } from '@documenso/lib/types/envelope-content-meta';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../../fixtures/authentication';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
});
|
||||
|
||||
/**
|
||||
* The editor sends every content on every save, and the ones which already
|
||||
* exist are identified by their ID. An ID listed twice would update the same
|
||||
* content twice, so it is rejected. New contents have no ID, so any number of
|
||||
* them can be created in one save.
|
||||
*/
|
||||
|
||||
const textMeta = (text: string, positionY = 10): TEnvelopeContentMetaInput => ({
|
||||
type: EnvelopeContentType.TEXT,
|
||||
page: 1,
|
||||
positionX: 10,
|
||||
positionY,
|
||||
width: 20,
|
||||
height: 6,
|
||||
text,
|
||||
});
|
||||
|
||||
type SetContentInput = {
|
||||
id?: string;
|
||||
envelopeItemId: string;
|
||||
contentMeta: TEnvelopeContentMetaInput;
|
||||
};
|
||||
|
||||
const setContents = async (page: Page, teamId: number, envelopeId: string, contents: SetContentInput[]) =>
|
||||
await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/envelope.content.set`, {
|
||||
headers: { 'content-type': 'application/json', 'x-team-id': teamId.toString() },
|
||||
data: JSON.stringify({
|
||||
json: { envelopeId, contents: contents.map((content) => ({ dataContentId: null, ...content })) },
|
||||
}),
|
||||
});
|
||||
|
||||
const setupDocument = async (page: Page) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const document = await seedBlankDocument(user, team.id, { internalVersion: 2 });
|
||||
|
||||
await apiSignin({ page, email: user.email, redirectPath: `/t/${team.url}/documents` });
|
||||
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
include: { envelopeItems: true },
|
||||
});
|
||||
|
||||
return { envelopeId: envelope.id, envelopeItemId: envelope.envelopeItems[0].id, teamId: team.id };
|
||||
};
|
||||
|
||||
test('rejects a content listed twice without changing it', async ({ page }) => {
|
||||
const { envelopeId, envelopeItemId, teamId } = await setupDocument(page);
|
||||
|
||||
const created = await setContents(page, teamId, envelopeId, [{ envelopeItemId, contentMeta: textMeta('Original') }]);
|
||||
|
||||
expect(created.ok(), await created.text()).toBeTruthy();
|
||||
|
||||
const content = await prisma.envelopeContent.findFirstOrThrow({ where: { envelopeId } });
|
||||
|
||||
const res = await setContents(page, teamId, envelopeId, [
|
||||
{ id: content.id, envelopeItemId, contentMeta: textMeta('First edit') },
|
||||
{ id: content.id, envelopeItemId, contentMeta: textMeta('Second edit') },
|
||||
]);
|
||||
|
||||
expect(res.status()).toBe(400);
|
||||
expect(await res.text()).toContain('Content IDs must be unique');
|
||||
|
||||
const contents = await prisma.envelopeContent.findMany({ where: { envelopeId } });
|
||||
|
||||
expect(contents).toHaveLength(1);
|
||||
expect(contents[0].contentMeta).toEqual(content.contentMeta);
|
||||
});
|
||||
|
||||
test('creates several new contents in one save', async ({ page }) => {
|
||||
const { envelopeId, envelopeItemId, teamId } = await setupDocument(page);
|
||||
|
||||
const res = await setContents(page, teamId, envelopeId, [
|
||||
{ envelopeItemId, contentMeta: textMeta('First', 10) },
|
||||
{ envelopeItemId, contentMeta: textMeta('Second', 30) },
|
||||
{ envelopeItemId, contentMeta: textMeta('Third', 50) },
|
||||
]);
|
||||
|
||||
expect(res.ok(), await res.text()).toBeTruthy();
|
||||
|
||||
expect(await prisma.envelopeContent.count({ where: { envelopeId } })).toBe(3);
|
||||
});
|
||||
@@ -1,219 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,482 +0,0 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createDataContentImage } from '@documenso/lib/server-only/data-content/create-data-content-image';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import {
|
||||
EnvelopeContentType,
|
||||
type TEnvelopeContentMeta,
|
||||
ZEnvelopeContentMetaSchema,
|
||||
} from '@documenso/lib/types/envelope-content-meta';
|
||||
import { generateDatabaseId, nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type { TUpdateEmbeddingEnvelopePayload } from '@documenso/trpc/server/embedding-router/update-embedding-envelope.types';
|
||||
import { type APIRequestContext, expect, test } from '@playwright/test';
|
||||
import type { Team } from '@prisma/client';
|
||||
|
||||
import { createGifLabelledAsPng, createImageFile, type TestImageFile } from '../../fixtures/contents';
|
||||
|
||||
/**
|
||||
* The embedded editor saves everything in one request when the envelope is
|
||||
* updated. New contents have temporary IDs, new images are referenced by their
|
||||
* index in `contentImages`, and the contents are checked before anything is
|
||||
* written, so a request which fails changes nothing.
|
||||
*/
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
});
|
||||
|
||||
/**
|
||||
* Seed a draft document and a presign token scoped to it, as the embedded
|
||||
* editor is given.
|
||||
*/
|
||||
const setupEmbeddedDocument = async (request: APIRequestContext) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const document = await seedBlankDocument(user, team.id, { internalVersion: 2 });
|
||||
|
||||
const envelope = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: document.id },
|
||||
include: { envelopeItems: true },
|
||||
});
|
||||
|
||||
const { token: apiToken } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'e2e-embed-update-contents',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const presignRes = await request.post(`${WEBAPP_BASE_URL}/api/v2/embedding/create-presign-token`, {
|
||||
headers: { Authorization: `Bearer ${apiToken}`, 'Content-Type': 'application/json' },
|
||||
data: { scope: `envelopeId:${envelope.id}` },
|
||||
});
|
||||
|
||||
expect(presignRes.ok()).toBeTruthy();
|
||||
|
||||
const { token: presignToken }: { token: string } = await presignRes.json();
|
||||
|
||||
return { team, envelope, envelopeItem: envelope.envelopeItems[0], presignToken };
|
||||
};
|
||||
|
||||
type UpdateEmbeddedEnvelopeOptions = {
|
||||
request: APIRequestContext;
|
||||
presignToken: string;
|
||||
envelope: { id: string; title: string };
|
||||
envelopeItem: { id: string; title: string; order: number };
|
||||
data: Partial<TUpdateEmbeddingEnvelopePayload['data']>;
|
||||
images?: TestImageFile[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Save the envelope through the embedded update route, as the embedded
|
||||
* editor does.
|
||||
*/
|
||||
const updateEmbeddedEnvelope = async ({
|
||||
request,
|
||||
presignToken,
|
||||
envelope,
|
||||
envelopeItem,
|
||||
data,
|
||||
images = [],
|
||||
}: UpdateEmbeddedEnvelopeOptions) => {
|
||||
const payload: TUpdateEmbeddingEnvelopePayload = {
|
||||
envelopeId: envelope.id,
|
||||
data: {
|
||||
title: envelope.title,
|
||||
envelopeItems: [{ id: envelopeItem.id, title: envelopeItem.title, order: envelopeItem.order }],
|
||||
recipients: [],
|
||||
attachments: [],
|
||||
...data,
|
||||
},
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
|
||||
for (const image of images) {
|
||||
formData.append('contentImages', new File([image.buffer], image.name, { type: image.mimeType }));
|
||||
}
|
||||
|
||||
return await request.post(`${WEBAPP_BASE_URL}/api/trpc/embeddingPresign.updateEmbeddingEnvelope`, {
|
||||
headers: { authorization: `Bearer ${presignToken}` },
|
||||
multipart: formData,
|
||||
});
|
||||
};
|
||||
|
||||
const setOrganisationContentLimit = async (team: Team, envelopeContentCount: number) => {
|
||||
const organisationClaim = await prisma.organisationClaim.findFirstOrThrow({
|
||||
where: {
|
||||
organisation: {
|
||||
id: team.organisationId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.organisationClaim.update({
|
||||
where: {
|
||||
id: organisationClaim.id,
|
||||
},
|
||||
data: {
|
||||
envelopeContentCount,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const countDataContentsNamed = async (fileName: string) =>
|
||||
await prisma.dataContent.count({
|
||||
where: {
|
||||
metadata: {
|
||||
path: ['fileName'],
|
||||
equals: fileName,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* A failed update must leave the envelope as it was: the title is only
|
||||
* changed by the steps which run before the contents are set.
|
||||
*/
|
||||
const expectEnvelopeUnchanged = async (envelope: { id: string; title: string }) => {
|
||||
const current = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: envelope.id },
|
||||
include: { contents: true },
|
||||
});
|
||||
|
||||
expect(current.title).toBe(envelope.title);
|
||||
expect(current.contents).toHaveLength(0);
|
||||
};
|
||||
|
||||
const textMeta = (positionY = 10): TEnvelopeContentMeta =>
|
||||
ZEnvelopeContentMetaSchema.parse({
|
||||
type: EnvelopeContentType.TEXT,
|
||||
page: 1,
|
||||
positionX: 10,
|
||||
positionY,
|
||||
width: 40,
|
||||
height: 5,
|
||||
text: 'Text',
|
||||
});
|
||||
|
||||
const imageMeta = (positionY: number): TEnvelopeContentMeta =>
|
||||
ZEnvelopeContentMetaSchema.parse({
|
||||
type: EnvelopeContentType.IMAGE,
|
||||
page: 1,
|
||||
positionX: 10,
|
||||
positionY,
|
||||
width: 30,
|
||||
height: 10,
|
||||
});
|
||||
|
||||
test.describe('Update embedded envelope contents', () => {
|
||||
test('saves new contents and their new images', async ({ request }) => {
|
||||
const { envelope, envelopeItem, presignToken } = await setupEmbeddedDocument(request);
|
||||
const image = await createImageFile(`embed-update-${nanoid()}.png`, 300, 100);
|
||||
|
||||
const res = await updateEmbeddedEnvelope({
|
||||
request,
|
||||
presignToken,
|
||||
envelope,
|
||||
envelopeItem,
|
||||
data: {
|
||||
contents: [
|
||||
{
|
||||
id: 'PRESIGNED_content-a',
|
||||
envelopeItemId: envelopeItem.id,
|
||||
contentMeta: imageMeta(10),
|
||||
dataContentId: null,
|
||||
imageIndex: 0,
|
||||
},
|
||||
// A duplicate showing the same image.
|
||||
{
|
||||
id: 'PRESIGNED_content-b',
|
||||
envelopeItemId: envelopeItem.id,
|
||||
contentMeta: imageMeta(40),
|
||||
dataContentId: null,
|
||||
imageIndex: 0,
|
||||
},
|
||||
{
|
||||
id: 'PRESIGNED_content-c',
|
||||
envelopeItemId: envelopeItem.id,
|
||||
contentMeta: textMeta(),
|
||||
dataContentId: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
images: [image],
|
||||
});
|
||||
|
||||
expect(res.ok(), await res.text()).toBeTruthy();
|
||||
|
||||
const contents = await prisma.envelopeContent.findMany({
|
||||
where: { envelopeId: envelope.id },
|
||||
include: { dataContent: true },
|
||||
});
|
||||
|
||||
expect(contents).toHaveLength(3);
|
||||
expect(contents.some((content) => content.id.startsWith('PRESIGNED_'))).toBe(false);
|
||||
|
||||
const imageContents = contents.filter((content) => content.contentMeta.type === EnvelopeContentType.IMAGE);
|
||||
|
||||
expect(imageContents).toHaveLength(2);
|
||||
|
||||
// The image was stored once and is shared by both contents.
|
||||
expect(imageContents[0].dataContentId).not.toBeNull();
|
||||
expect(imageContents[0].dataContentId).toBe(imageContents[1].dataContentId);
|
||||
|
||||
expect(imageContents[0].dataContent?.metadata).toMatchObject({
|
||||
width: 300,
|
||||
height: 100,
|
||||
fileName: image.name,
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects contents over the limit without changing anything', async ({ request }) => {
|
||||
const { team, envelope, envelopeItem, presignToken } = await setupEmbeddedDocument(request);
|
||||
const image = await createImageFile(`embed-limit-${nanoid()}.png`, 50, 50);
|
||||
|
||||
await setOrganisationContentLimit(team, 1);
|
||||
|
||||
const res = await updateEmbeddedEnvelope({
|
||||
request,
|
||||
presignToken,
|
||||
envelope,
|
||||
envelopeItem,
|
||||
data: {
|
||||
title: 'Changed title',
|
||||
contents: [
|
||||
{
|
||||
id: 'PRESIGNED_content-a',
|
||||
envelopeItemId: envelopeItem.id,
|
||||
contentMeta: imageMeta(10),
|
||||
dataContentId: null,
|
||||
imageIndex: 0,
|
||||
},
|
||||
{
|
||||
id: 'PRESIGNED_content-b',
|
||||
envelopeItemId: envelopeItem.id,
|
||||
contentMeta: textMeta(),
|
||||
dataContentId: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
images: [image],
|
||||
});
|
||||
|
||||
expect(res.status()).toBe(400);
|
||||
expect(await res.text()).toContain('ENVELOPE_CONTENT_LIMIT_EXCEEDED');
|
||||
|
||||
await expectEnvelopeUnchanged(envelope);
|
||||
expect(await countDataContentsNamed(image.name)).toBe(0);
|
||||
});
|
||||
|
||||
test('rejects an unknown content id without changing anything', async ({ request }) => {
|
||||
const { envelope, envelopeItem, presignToken } = await setupEmbeddedDocument(request);
|
||||
|
||||
const res = await updateEmbeddedEnvelope({
|
||||
request,
|
||||
presignToken,
|
||||
envelope,
|
||||
envelopeItem,
|
||||
data: {
|
||||
title: 'Changed title',
|
||||
contents: [
|
||||
{
|
||||
id: 'envelope_content_unknown',
|
||||
envelopeItemId: envelopeItem.id,
|
||||
contentMeta: textMeta(),
|
||||
dataContentId: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status()).toBe(404);
|
||||
expect(await res.text()).toContain('Envelope content not found');
|
||||
|
||||
await expectEnvelopeUnchanged(envelope);
|
||||
});
|
||||
|
||||
test('rejects an image which is not attached to the envelope', async ({ request }) => {
|
||||
const { envelope, envelopeItem, presignToken } = await setupEmbeddedDocument(request);
|
||||
|
||||
// An image stored for something else, referenced by its ID.
|
||||
const png = await createImageFile(`embed-foreign-${nanoid()}.png`, 50, 50);
|
||||
|
||||
const foreignDataContent = await createDataContentImage({
|
||||
file: {
|
||||
name: png.name,
|
||||
arrayBuffer: async () => await new Blob([png.buffer]).arrayBuffer(),
|
||||
},
|
||||
});
|
||||
|
||||
const res = await updateEmbeddedEnvelope({
|
||||
request,
|
||||
presignToken,
|
||||
envelope,
|
||||
envelopeItem,
|
||||
data: {
|
||||
title: 'Changed title',
|
||||
contents: [
|
||||
{
|
||||
id: 'PRESIGNED_content-a',
|
||||
envelopeItemId: envelopeItem.id,
|
||||
contentMeta: imageMeta(10),
|
||||
dataContentId: foreignDataContent.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status()).toBe(404);
|
||||
expect(await res.text()).toContain(`Data content ${foreignDataContent.id} not found`);
|
||||
|
||||
await expectEnvelopeUnchanged(envelope);
|
||||
});
|
||||
|
||||
test('rejects an image index without an uploaded image', async ({ request }) => {
|
||||
const { envelope, envelopeItem, presignToken } = await setupEmbeddedDocument(request);
|
||||
|
||||
const res = await updateEmbeddedEnvelope({
|
||||
request,
|
||||
presignToken,
|
||||
envelope,
|
||||
envelopeItem,
|
||||
data: {
|
||||
title: 'Changed title',
|
||||
contents: [
|
||||
{
|
||||
id: 'PRESIGNED_content-a',
|
||||
envelopeItemId: envelopeItem.id,
|
||||
contentMeta: imageMeta(10),
|
||||
dataContentId: null,
|
||||
imageIndex: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status()).toBe(400);
|
||||
expect(await res.text()).toContain('Invalid content image index');
|
||||
|
||||
await expectEnvelopeUnchanged(envelope);
|
||||
});
|
||||
|
||||
test('rejects a content listed twice without changing anything', async ({ request }) => {
|
||||
const { envelope, envelopeItem, presignToken } = await setupEmbeddedDocument(request);
|
||||
|
||||
const existingContent = await prisma.envelopeContent.create({
|
||||
data: {
|
||||
id: generateDatabaseId('envelope_content'),
|
||||
envelopeId: envelope.id,
|
||||
envelopeItemId: envelopeItem.id,
|
||||
contentMeta: textMeta(),
|
||||
},
|
||||
});
|
||||
|
||||
// Both entries would update the same content.
|
||||
const res = await updateEmbeddedEnvelope({
|
||||
request,
|
||||
presignToken,
|
||||
envelope,
|
||||
envelopeItem,
|
||||
data: {
|
||||
title: 'Changed title',
|
||||
contents: [
|
||||
{
|
||||
id: existingContent.id,
|
||||
envelopeItemId: envelopeItem.id,
|
||||
contentMeta: textMeta(30),
|
||||
dataContentId: null,
|
||||
},
|
||||
{
|
||||
id: existingContent.id,
|
||||
envelopeItemId: envelopeItem.id,
|
||||
contentMeta: textMeta(60),
|
||||
dataContentId: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status()).toBe(400);
|
||||
expect(await res.text()).toContain('Content IDs must be unique');
|
||||
|
||||
const current = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: envelope.id },
|
||||
include: { contents: true },
|
||||
});
|
||||
|
||||
expect(current.title).toBe(envelope.title);
|
||||
expect(current.contents).toHaveLength(1);
|
||||
expect(current.contents[0].contentMeta).toEqual(existingContent.contentMeta);
|
||||
});
|
||||
|
||||
test('rejects a new content listed twice without changing anything', async ({ request }) => {
|
||||
const { envelope, envelopeItem, presignToken } = await setupEmbeddedDocument(request);
|
||||
|
||||
const res = await updateEmbeddedEnvelope({
|
||||
request,
|
||||
presignToken,
|
||||
envelope,
|
||||
envelopeItem,
|
||||
data: {
|
||||
title: 'Changed title',
|
||||
contents: [
|
||||
{
|
||||
id: 'PRESIGNED_content-a',
|
||||
envelopeItemId: envelopeItem.id,
|
||||
contentMeta: textMeta(),
|
||||
dataContentId: null,
|
||||
},
|
||||
{
|
||||
id: 'PRESIGNED_content-a',
|
||||
envelopeItemId: envelopeItem.id,
|
||||
contentMeta: imageMeta(40),
|
||||
dataContentId: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status()).toBe(400);
|
||||
expect(await res.text()).toContain('Content IDs must be unique');
|
||||
|
||||
await expectEnvelopeUnchanged(envelope);
|
||||
});
|
||||
|
||||
test('rejects an image which is not really a PNG, JPEG or WebP', async ({ request }) => {
|
||||
const { envelope, envelopeItem, presignToken } = await setupEmbeddedDocument(request);
|
||||
const image = createGifLabelledAsPng(`embed-format-${nanoid()}.png`);
|
||||
|
||||
const res = await updateEmbeddedEnvelope({
|
||||
request,
|
||||
presignToken,
|
||||
envelope,
|
||||
envelopeItem,
|
||||
data: {
|
||||
title: 'Changed title',
|
||||
contents: [
|
||||
{
|
||||
id: 'PRESIGNED_content-a',
|
||||
envelopeItemId: envelopeItem.id,
|
||||
contentMeta: imageMeta(10),
|
||||
dataContentId: null,
|
||||
imageIndex: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
images: [image],
|
||||
});
|
||||
|
||||
expect(res.status()).toBe(400);
|
||||
expect(await res.text()).toContain('Unsupported image format: gif');
|
||||
|
||||
await expectEnvelopeUnchanged(envelope);
|
||||
expect(await countDataContentsNamed(image.name)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,287 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createDataContentImage } from '@documenso/lib/server-only/data-content/create-data-content-image';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { EnvelopeContentType, ZEnvelopeContentMetaSchema } from '@documenso/lib/types/envelope-content-meta';
|
||||
import { generateDatabaseId } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, EnvelopeType, FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type {
|
||||
TCreateEnvelopePayload,
|
||||
TCreateEnvelopeResponse,
|
||||
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
|
||||
import type { TDistributeEnvelopeRequest } from '@documenso/trpc/server/envelope-router/distribute-envelope.types';
|
||||
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
|
||||
import { type APIRequestContext, type APIResponse, expect, test } from '@playwright/test';
|
||||
import type { Team, User } from '@prisma/client';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
});
|
||||
|
||||
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf'));
|
||||
const logoPngBuffer = fs.readFileSync(path.join(__dirname, '../../../../../packages/assets/logo.png'));
|
||||
|
||||
/**
|
||||
* Set the content allowances on the organisation that owns the seeded team.
|
||||
*
|
||||
* A value of `0` means unlimited.
|
||||
*/
|
||||
const setOrganisationContentLimits = async (
|
||||
team: Team,
|
||||
limits: { envelopeContentCount?: number; envelopeContentImageCount?: number },
|
||||
) => {
|
||||
const organisationClaim = await prisma.organisationClaim.findFirstOrThrow({
|
||||
where: {
|
||||
organisation: {
|
||||
id: team.organisationId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.organisationClaim.update({
|
||||
where: {
|
||||
id: organisationClaim.id,
|
||||
},
|
||||
data: limits,
|
||||
});
|
||||
};
|
||||
|
||||
const createEnvelope = async (request: APIRequestContext, authToken: string) => {
|
||||
const payload: TCreateEnvelopePayload = {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title: 'Envelope Content Limit Test',
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
formData.append('files', new File([examplePdfBuffer], 'example.pdf', { type: 'application/pdf' }));
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${authToken}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
return (await res.json()) as TCreateEnvelopeResponse;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a distributable envelope holding `contentCount` contents, of which
|
||||
* `imageCount` are image contents, then attempt to distribute it.
|
||||
*
|
||||
* Contents are inserted directly rather than created through the API, since
|
||||
* the API refuses to create an envelope over the limits and these tests need
|
||||
* one, as left behind when an organisation's plan is lowered. Image contents
|
||||
* are given a real image, since sending rejects an image content without one
|
||||
* and decodes it to draw it into the PDF.
|
||||
*/
|
||||
const buildAndDistributeEnvelopeWithContents = async ({
|
||||
request,
|
||||
authToken,
|
||||
contentCount,
|
||||
imageCount = 0,
|
||||
}: {
|
||||
request: APIRequestContext;
|
||||
authToken: string;
|
||||
contentCount: number;
|
||||
imageCount?: number;
|
||||
}): Promise<{ envelopeId: string; distributeRes: APIResponse }> => {
|
||||
const envelope = await createEnvelope(request, authToken);
|
||||
|
||||
const envelopeRes = await request.get(`${baseUrl}/envelope/${envelope.id}`, {
|
||||
headers: { Authorization: `Bearer ${authToken}` },
|
||||
});
|
||||
|
||||
expect(envelopeRes.ok()).toBeTruthy();
|
||||
|
||||
const envelopeData = (await envelopeRes.json()) as TGetEnvelopeResponse;
|
||||
const envelopeItemId = envelopeData.envelopeItems[0].id;
|
||||
|
||||
const recipientRes = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
|
||||
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
envelopeId: envelope.id,
|
||||
data: [
|
||||
{
|
||||
email: `recipient-${Date.now()}-${Math.random().toString(36).slice(2)}@test.documenso.com`,
|
||||
name: 'Recipient',
|
||||
role: RecipientRole.SIGNER,
|
||||
accessAuth: [],
|
||||
actionAuth: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(recipientRes.ok()).toBeTruthy();
|
||||
|
||||
const [recipient] = (await recipientRes.json()).data;
|
||||
|
||||
const fieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
|
||||
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
envelopeId: envelope.id,
|
||||
data: [
|
||||
{
|
||||
recipientId: recipient.id,
|
||||
envelopeItemId,
|
||||
type: FieldType.SIGNATURE,
|
||||
page: 1,
|
||||
positionX: 10,
|
||||
positionY: 60,
|
||||
width: 30,
|
||||
height: 8,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(fieldsRes.ok()).toBeTruthy();
|
||||
|
||||
const dataContentIds = await Promise.all(
|
||||
Array.from({ length: imageCount }).map(async () => {
|
||||
const dataContent = await createDataContentImage({
|
||||
file: { name: 'logo.png', arrayBuffer: async () => new Uint8Array(logoPngBuffer).buffer as ArrayBuffer },
|
||||
});
|
||||
|
||||
return dataContent.id;
|
||||
}),
|
||||
);
|
||||
|
||||
await prisma.envelopeContent.createMany({
|
||||
data: Array.from({ length: contentCount }).map((_, index) => {
|
||||
const isImage = index < imageCount;
|
||||
|
||||
return {
|
||||
id: generateDatabaseId('envelope_content'),
|
||||
envelopeId: envelope.id,
|
||||
envelopeItemId,
|
||||
dataContentId: isImage ? dataContentIds[index] : null,
|
||||
contentMeta: ZEnvelopeContentMetaSchema.parse(
|
||||
isImage
|
||||
? {
|
||||
type: EnvelopeContentType.IMAGE,
|
||||
page: 1,
|
||||
rotation: 0,
|
||||
zIndex: 0,
|
||||
positionX: 10,
|
||||
positionY: 10,
|
||||
width: 20,
|
||||
height: 10,
|
||||
}
|
||||
: {
|
||||
type: EnvelopeContentType.TEXT,
|
||||
page: 1,
|
||||
rotation: 0,
|
||||
zIndex: 0,
|
||||
positionX: 10,
|
||||
positionY: 10,
|
||||
width: 20,
|
||||
height: 6,
|
||||
text: `Content ${index}`,
|
||||
},
|
||||
),
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
|
||||
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
envelopeId: envelope.id,
|
||||
} satisfies TDistributeEnvelopeRequest,
|
||||
});
|
||||
|
||||
return { envelopeId: envelope.id, distributeRes };
|
||||
};
|
||||
|
||||
const expectEnvelopeStatus = async (envelopeId: string, status: DocumentStatus) => {
|
||||
const envelope = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: envelopeId },
|
||||
});
|
||||
|
||||
expect(envelope.status).toBe(status);
|
||||
};
|
||||
|
||||
test.describe('Envelope content limits on distribute', () => {
|
||||
let user: User;
|
||||
let team: Team;
|
||||
let token: string;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
({ user, team } = await seedUser());
|
||||
({ token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test-envelope-content-limit',
|
||||
expiresIn: null,
|
||||
}));
|
||||
});
|
||||
|
||||
test('allows distribution when the content count is at the limit', async ({ request }) => {
|
||||
await setOrganisationContentLimits(team, { envelopeContentCount: 3 });
|
||||
|
||||
const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithContents({
|
||||
request,
|
||||
authToken: token,
|
||||
contentCount: 3,
|
||||
});
|
||||
|
||||
expect(distributeRes.status()).toBe(200);
|
||||
|
||||
await expectEnvelopeStatus(envelopeId, DocumentStatus.PENDING);
|
||||
});
|
||||
|
||||
test('denies distribution when the content count is over the limit', async ({ request }) => {
|
||||
await setOrganisationContentLimits(team, { envelopeContentCount: 3 });
|
||||
|
||||
const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithContents({
|
||||
request,
|
||||
authToken: token,
|
||||
contentCount: 4,
|
||||
});
|
||||
|
||||
expect(distributeRes.status()).toBe(400);
|
||||
expect(await distributeRes.text()).toContain('ENVELOPE_CONTENT_LIMIT_EXCEEDED');
|
||||
|
||||
await expectEnvelopeStatus(envelopeId, DocumentStatus.DRAFT);
|
||||
});
|
||||
|
||||
test('denies distribution when the image content count is over the limit', async ({ request }) => {
|
||||
await setOrganisationContentLimits(team, { envelopeContentImageCount: 1 });
|
||||
|
||||
const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithContents({
|
||||
request,
|
||||
authToken: token,
|
||||
contentCount: 3,
|
||||
imageCount: 2,
|
||||
});
|
||||
|
||||
expect(distributeRes.status()).toBe(400);
|
||||
expect(await distributeRes.text()).toContain('ENVELOPE_CONTENT_IMAGE_LIMIT_EXCEEDED');
|
||||
|
||||
await expectEnvelopeStatus(envelopeId, DocumentStatus.DRAFT);
|
||||
});
|
||||
|
||||
test('allows distribution when the limits are unlimited', async ({ request }) => {
|
||||
await setOrganisationContentLimits(team, { envelopeContentCount: 0, envelopeContentImageCount: 0 });
|
||||
|
||||
const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithContents({
|
||||
request,
|
||||
authToken: token,
|
||||
contentCount: 12,
|
||||
imageCount: 6,
|
||||
});
|
||||
|
||||
expect(distributeRes.status()).toBe(200);
|
||||
|
||||
await expectEnvelopeStatus(envelopeId, DocumentStatus.PENDING);
|
||||
});
|
||||
});
|
||||
@@ -1,238 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { EnvelopeContentType, type TEnvelopeContentMetaInput } from '@documenso/lib/types/envelope-content-meta';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type { TCreateEnvelopeResponse } from '@documenso/trpc/server/envelope-router/create-envelope.types';
|
||||
import type { TDuplicateEnvelopeResponse } from '@documenso/trpc/server/envelope-router/duplicate-envelope.types';
|
||||
import type {
|
||||
TUseEnvelopePayload,
|
||||
TUseEnvelopeResponse,
|
||||
} from '@documenso/trpc/server/envelope-router/use-envelope.types';
|
||||
import { type APIRequestContext, expect, test } from '@playwright/test';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { createImageFile } from '../../fixtures/contents';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
});
|
||||
|
||||
/**
|
||||
* Duplicating an envelope, or creating a document from a template, copies the
|
||||
* contents onto the matching items of the new envelope. Images are shared
|
||||
* rather than copied, since a data content never changes.
|
||||
*/
|
||||
|
||||
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf'));
|
||||
|
||||
const textMeta = (text: string): TEnvelopeContentMetaInput => ({
|
||||
type: EnvelopeContentType.TEXT,
|
||||
page: 1,
|
||||
positionX: 10,
|
||||
positionY: 10,
|
||||
width: 40,
|
||||
height: 5,
|
||||
text,
|
||||
});
|
||||
|
||||
const IMAGE_META: TEnvelopeContentMetaInput = {
|
||||
type: EnvelopeContentType.IMAGE,
|
||||
page: 1,
|
||||
positionX: 10,
|
||||
positionY: 40,
|
||||
width: 30,
|
||||
height: 10,
|
||||
};
|
||||
|
||||
/**
|
||||
* Create an envelope with two files through the public create route, with a
|
||||
* text on each file and an image on the second.
|
||||
*/
|
||||
const createSourceEnvelope = async (request: APIRequestContext, token: string, type: EnvelopeType) => {
|
||||
const image = await createImageFile(`copy-${nanoid()}.png`, 120, 60);
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append(
|
||||
'payload',
|
||||
JSON.stringify({
|
||||
type,
|
||||
title: 'Envelope With Contents',
|
||||
contents: [
|
||||
{ identifier: 'first.pdf', contentMeta: textMeta('On the first file') },
|
||||
{ identifier: 'second.pdf', contentMeta: textMeta('On the second file') },
|
||||
{ identifier: 'second.pdf', contentMeta: IMAGE_META, imageIndex: 0 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
for (const pdfName of ['first.pdf', 'second.pdf']) {
|
||||
formData.append('files', new File([examplePdfBuffer], pdfName, { type: 'application/pdf' }));
|
||||
}
|
||||
|
||||
formData.append('contentImages', new File([image.buffer], image.name, { type: image.mimeType }));
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(res.ok(), await res.text()).toBeTruthy();
|
||||
|
||||
const { id } = (await res.json()) as TCreateEnvelopeResponse;
|
||||
|
||||
return id;
|
||||
};
|
||||
|
||||
/**
|
||||
* An envelope's contents, each described by the position of the item it is
|
||||
* on rather than the item's ID, so a copy can be compared with its source.
|
||||
*
|
||||
* Titles would not do, since creating a document from a template drops the
|
||||
* ".pdf" from them.
|
||||
*/
|
||||
const getContentSummaries = async (envelopeId: string) => {
|
||||
const envelope = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: envelopeId },
|
||||
include: { envelopeItems: { orderBy: { order: 'asc' } }, contents: true },
|
||||
});
|
||||
|
||||
return envelope.contents.map((content) => ({
|
||||
envelopeItemIndex: envelope.envelopeItems.findIndex((item) => item.id === content.envelopeItemId),
|
||||
contentMeta: content.contentMeta,
|
||||
dataContentId: content.dataContentId,
|
||||
}));
|
||||
};
|
||||
|
||||
type CopyEnvelopeOptions = {
|
||||
request: APIRequestContext;
|
||||
token: string;
|
||||
sourceId: string;
|
||||
includeContents?: boolean;
|
||||
};
|
||||
|
||||
type CopyRoute = {
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* The type of envelope the route copies.
|
||||
*/
|
||||
sourceType: EnvelopeType;
|
||||
|
||||
/**
|
||||
* Copy the source envelope, returning the ID of the new envelope.
|
||||
*/
|
||||
copy: (options: CopyEnvelopeOptions) => Promise<string>;
|
||||
};
|
||||
|
||||
const COPY_ROUTES: CopyRoute[] = [
|
||||
{
|
||||
name: 'envelope/duplicate',
|
||||
sourceType: EnvelopeType.DOCUMENT,
|
||||
copy: async ({ request, token, sourceId, includeContents }) => {
|
||||
const res = await request.post(`${baseUrl}/envelope/duplicate`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: { envelopeId: sourceId, includeContents },
|
||||
});
|
||||
|
||||
expect(res.ok(), await res.text()).toBeTruthy();
|
||||
|
||||
const { id } = (await res.json()) as TDuplicateEnvelopeResponse;
|
||||
|
||||
return id;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'envelope/use',
|
||||
sourceType: EnvelopeType.TEMPLATE,
|
||||
copy: async ({ request, token, sourceId, includeContents }) => {
|
||||
const payload: TUseEnvelopePayload = { envelopeId: sourceId, includeContents };
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/use`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(res.ok(), await res.text()).toBeTruthy();
|
||||
|
||||
const { id } = (await res.json()) as TUseEnvelopeResponse;
|
||||
|
||||
return id;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'template/use',
|
||||
sourceType: EnvelopeType.TEMPLATE,
|
||||
copy: async ({ request, token, sourceId, includeContents }) => {
|
||||
const template = await prisma.envelope.findUniqueOrThrow({ where: { id: sourceId } });
|
||||
|
||||
const res = await request.post(`${baseUrl}/template/use`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: {
|
||||
templateId: mapSecondaryIdToTemplateId(template.secondaryId),
|
||||
recipients: [],
|
||||
includeContents,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok(), await res.text()).toBeTruthy();
|
||||
|
||||
const { envelopeId }: { envelopeId: string } = await res.json();
|
||||
|
||||
return envelopeId;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
test.describe('Copy contents to new envelopes', () => {
|
||||
let token: string;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
({ token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test-envelope-copy-contents',
|
||||
expiresIn: null,
|
||||
}));
|
||||
});
|
||||
|
||||
for (const route of COPY_ROUTES) {
|
||||
test(`${route.name} copies the contents onto the matching items`, async ({ request }) => {
|
||||
const sourceId = await createSourceEnvelope(request, token, route.sourceType);
|
||||
|
||||
const copyId = await route.copy({ request, token, sourceId });
|
||||
|
||||
const source = await getContentSummaries(sourceId);
|
||||
const copy = await getContentSummaries(copyId);
|
||||
|
||||
expect(source).toHaveLength(3);
|
||||
|
||||
// The same contents on the same files, showing the same image.
|
||||
expect(copy).toHaveLength(source.length);
|
||||
expect(copy).toEqual(expect.arrayContaining(source));
|
||||
});
|
||||
|
||||
test(`${route.name} leaves the contents behind when asked to`, async ({ request }) => {
|
||||
const sourceId = await createSourceEnvelope(request, token, route.sourceType);
|
||||
|
||||
const copyId = await route.copy({ request, token, sourceId, includeContents: false });
|
||||
|
||||
expect(await prisma.envelopeContent.count({ where: { envelopeId: copyId } })).toBe(0);
|
||||
expect(await prisma.envelopeContent.count({ where: { envelopeId: sourceId } })).toBe(3);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,430 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import {
|
||||
EnvelopeContentShapeType,
|
||||
EnvelopeContentType,
|
||||
type TEnvelopeContentMetaInput,
|
||||
} from '@documenso/lib/types/envelope-content-meta';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type {
|
||||
TCreateEnvelopePayload,
|
||||
TCreateEnvelopeResponse,
|
||||
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
|
||||
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
|
||||
import { type APIRequestContext, expect, test } from '@playwright/test';
|
||||
import { EnvelopeType, type Team } from '@prisma/client';
|
||||
|
||||
import { createGifLabelledAsPng, createImageFile, type TestImageFile } from '../../fixtures/contents';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
});
|
||||
|
||||
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf'));
|
||||
|
||||
type TCreateContentInput = {
|
||||
identifier?: string | number;
|
||||
contentMeta: TEnvelopeContentMetaInput;
|
||||
imageIndex?: number;
|
||||
};
|
||||
|
||||
type CreateEnvelopeWithContentsOptions = {
|
||||
request: APIRequestContext;
|
||||
token: string;
|
||||
payload?: Partial<Omit<TCreateEnvelopePayload, 'contents'>>;
|
||||
contents: TCreateContentInput[];
|
||||
pdfNames?: string[];
|
||||
images?: TestImageFile[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Create an envelope with contents through the public create route.
|
||||
*/
|
||||
const createEnvelopeWithContents = async ({
|
||||
request,
|
||||
token,
|
||||
payload = {},
|
||||
contents,
|
||||
pdfNames = ['example.pdf'],
|
||||
images = [],
|
||||
}: CreateEnvelopeWithContentsOptions) => {
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append(
|
||||
'payload',
|
||||
JSON.stringify({
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title: 'Envelope With Contents',
|
||||
...payload,
|
||||
contents,
|
||||
}),
|
||||
);
|
||||
|
||||
for (const pdfName of pdfNames) {
|
||||
formData.append('files', new File([examplePdfBuffer], pdfName, { type: 'application/pdf' }));
|
||||
}
|
||||
|
||||
for (const image of images) {
|
||||
formData.append('contentImages', new File([image.buffer], image.name, { type: image.mimeType }));
|
||||
}
|
||||
|
||||
return await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
multipart: formData,
|
||||
});
|
||||
};
|
||||
|
||||
const getEnvelope = async (request: APIRequestContext, token: string, envelopeId: string) => {
|
||||
const res = await request.get(`${baseUrl}/envelope/${envelopeId}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
return (await res.json()) as TGetEnvelopeResponse;
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the image content allowance on the organisation that owns the team.
|
||||
*/
|
||||
const setOrganisationContentImageLimit = async (team: Team, envelopeContentImageCount: number) => {
|
||||
const organisationClaim = await prisma.organisationClaim.findFirstOrThrow({
|
||||
where: {
|
||||
organisation: {
|
||||
id: team.organisationId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.organisationClaim.update({
|
||||
where: {
|
||||
id: organisationClaim.id,
|
||||
},
|
||||
data: {
|
||||
envelopeContentImageCount,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const countDataContentsNamed = async (fileName: string) =>
|
||||
await prisma.dataContent.count({
|
||||
where: {
|
||||
metadata: {
|
||||
path: ['fileName'],
|
||||
equals: fileName,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const textMeta = (text: string): TEnvelopeContentMetaInput => ({
|
||||
type: EnvelopeContentType.TEXT,
|
||||
page: 1,
|
||||
positionX: 10,
|
||||
positionY: 10,
|
||||
width: 40,
|
||||
height: 5,
|
||||
text,
|
||||
});
|
||||
|
||||
const RECTANGLE_META: TEnvelopeContentMetaInput = {
|
||||
type: EnvelopeContentType.SHAPE,
|
||||
shape: EnvelopeContentShapeType.RECTANGLE,
|
||||
page: 1,
|
||||
positionX: 10,
|
||||
positionY: 20,
|
||||
width: 20,
|
||||
height: 10,
|
||||
};
|
||||
|
||||
const imageMeta = (positionY: number): TEnvelopeContentMetaInput => ({
|
||||
type: EnvelopeContentType.IMAGE,
|
||||
page: 1,
|
||||
positionX: 10,
|
||||
positionY,
|
||||
width: 30,
|
||||
height: 10,
|
||||
});
|
||||
|
||||
test.describe('Create envelope with contents', () => {
|
||||
let team: Team;
|
||||
let token: string;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
const seeded = await seedUser();
|
||||
|
||||
team = seeded.team;
|
||||
|
||||
({ token } = await createApiToken({
|
||||
userId: seeded.user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test-envelope-create-contents',
|
||||
expiresIn: null,
|
||||
}));
|
||||
});
|
||||
|
||||
for (const envelopeType of [EnvelopeType.DOCUMENT, EnvelopeType.TEMPLATE]) {
|
||||
test(`creates a ${envelopeType.toLowerCase()} with contents and images`, async ({ request }) => {
|
||||
const logo = await createImageFile(`logo-${nanoid()}.png`, 300, 100);
|
||||
|
||||
const res = await createEnvelopeWithContents({
|
||||
request,
|
||||
token,
|
||||
payload: { type: envelopeType },
|
||||
contents: [
|
||||
{ contentMeta: textMeta('Hello') },
|
||||
{ contentMeta: RECTANGLE_META },
|
||||
{ contentMeta: imageMeta(40), imageIndex: 0 },
|
||||
// A second content showing the same image.
|
||||
{ contentMeta: imageMeta(60), imageIndex: 0 },
|
||||
// An image content which has no image yet.
|
||||
{ contentMeta: imageMeta(80) },
|
||||
],
|
||||
images: [logo],
|
||||
});
|
||||
|
||||
expect(res.ok(), await res.text()).toBeTruthy();
|
||||
|
||||
const { id } = (await res.json()) as TCreateEnvelopeResponse;
|
||||
|
||||
// Contents are returned by the public get route.
|
||||
const envelope = await getEnvelope(request, token, id);
|
||||
const [envelopeItem] = envelope.envelopeItems;
|
||||
|
||||
expect(envelope.contents).toHaveLength(5);
|
||||
expect(envelope.contents.every((content) => content.envelopeItemId === envelopeItem.id)).toBe(true);
|
||||
|
||||
const text = envelope.contents.find((content) => content.contentMeta.type === EnvelopeContentType.TEXT);
|
||||
const shape = envelope.contents.find((content) => content.contentMeta.type === EnvelopeContentType.SHAPE);
|
||||
|
||||
expect(text?.contentMeta).toMatchObject({ text: 'Hello', page: 1, positionX: 10, positionY: 10 });
|
||||
expect(shape?.contentMeta).toMatchObject({ shape: EnvelopeContentShapeType.RECTANGLE });
|
||||
|
||||
const images = envelope.contents.filter((content) => content.contentMeta.type === EnvelopeContentType.IMAGE);
|
||||
const withImage = images.filter((content) => content.dataContentId !== null);
|
||||
|
||||
expect(images).toHaveLength(3);
|
||||
expect(withImage).toHaveLength(2);
|
||||
|
||||
// The image is stored once and shared by both contents showing it.
|
||||
expect(withImage[0].dataContentId).toBe(withImage[1].dataContentId);
|
||||
|
||||
const dataContent = await prisma.dataContent.findUniqueOrThrow({
|
||||
where: { id: withImage[0].dataContentId ?? '' },
|
||||
});
|
||||
|
||||
expect(dataContent.metadata).toMatchObject({
|
||||
type: 'image',
|
||||
width: 300,
|
||||
height: 100,
|
||||
mimeType: 'image/png',
|
||||
fileName: logo.name,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('places each content on the file it identifies', async ({ request }) => {
|
||||
const res = await createEnvelopeWithContents({
|
||||
request,
|
||||
token,
|
||||
pdfNames: ['first.pdf', 'second.pdf'],
|
||||
contents: [
|
||||
{ contentMeta: textMeta('Default') },
|
||||
{ identifier: 'second.pdf', contentMeta: textMeta('By name') },
|
||||
{ identifier: 1, contentMeta: textMeta('By index') },
|
||||
],
|
||||
});
|
||||
|
||||
expect(res.ok(), await res.text()).toBeTruthy();
|
||||
|
||||
const { id } = (await res.json()) as TCreateEnvelopeResponse;
|
||||
|
||||
const envelope = await getEnvelope(request, token, id);
|
||||
const [first, second] = [...envelope.envelopeItems].sort((a, b) => a.order - b.order);
|
||||
|
||||
const envelopeItemIdByText = Object.fromEntries(
|
||||
envelope.contents.map((content) => [
|
||||
content.contentMeta.type === EnvelopeContentType.TEXT ? content.contentMeta.text : content.id,
|
||||
content.envelopeItemId,
|
||||
]),
|
||||
);
|
||||
|
||||
expect(envelopeItemIdByText).toEqual({
|
||||
Default: first.id,
|
||||
'By name': second.id,
|
||||
'By index': second.id,
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects contents over the limits without storing their images', async ({ request }) => {
|
||||
await setOrganisationContentImageLimit(team, 1);
|
||||
|
||||
const externalId = `e2e-contents-limit-${nanoid()}`;
|
||||
|
||||
const images = await Promise.all([
|
||||
createImageFile(`limit-a-${nanoid()}.png`, 50, 50),
|
||||
createImageFile(`limit-b-${nanoid()}.png`, 50, 50),
|
||||
]);
|
||||
|
||||
const res = await createEnvelopeWithContents({
|
||||
request,
|
||||
token,
|
||||
payload: { externalId },
|
||||
contents: [
|
||||
{ contentMeta: imageMeta(40), imageIndex: 0 },
|
||||
{ contentMeta: imageMeta(60), imageIndex: 1 },
|
||||
],
|
||||
images,
|
||||
});
|
||||
|
||||
expect(res.status()).toBe(400);
|
||||
expect(await res.text()).toContain('ENVELOPE_CONTENT_IMAGE_LIMIT_EXCEEDED');
|
||||
|
||||
expect(await prisma.envelope.count({ where: { externalId } })).toBe(0);
|
||||
|
||||
for (const image of images) {
|
||||
expect(await countDataContentsNamed(image.name)).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects a content on a page its file does not have', async ({ request }) => {
|
||||
const externalId = `e2e-contents-page-${nanoid()}`;
|
||||
const image = await createImageFile(`page-${nanoid()}.png`, 50, 50);
|
||||
|
||||
// The example PDF has a single page.
|
||||
const res = await createEnvelopeWithContents({
|
||||
request,
|
||||
token,
|
||||
payload: { externalId },
|
||||
contents: [{ contentMeta: { ...imageMeta(40), page: 2 }, imageIndex: 0 }],
|
||||
images: [image],
|
||||
});
|
||||
|
||||
expect(res.status()).toBe(400);
|
||||
expect(await res.text()).toContain('only has 1 page(s)');
|
||||
|
||||
expect(await prisma.envelope.count({ where: { externalId } })).toBe(0);
|
||||
|
||||
// Rejected before the image was stored.
|
||||
expect(await countDataContentsNamed(image.name)).toBe(0);
|
||||
});
|
||||
|
||||
test('rejects an image index without an image', async ({ request }) => {
|
||||
const externalId = `e2e-contents-index-${nanoid()}`;
|
||||
|
||||
const res = await createEnvelopeWithContents({
|
||||
request,
|
||||
token,
|
||||
payload: { externalId },
|
||||
contents: [{ contentMeta: imageMeta(40), imageIndex: 1 }],
|
||||
images: [await createImageFile(`index-${nanoid()}.png`, 50, 50)],
|
||||
});
|
||||
|
||||
expect(res.status()).toBe(400);
|
||||
expect(await res.text()).toContain('Invalid content image index');
|
||||
|
||||
expect(await prisma.envelope.count({ where: { externalId } })).toBe(0);
|
||||
});
|
||||
|
||||
test('rejects an image on a content which cannot hold one', async ({ request }) => {
|
||||
const externalId = `e2e-contents-type-${nanoid()}`;
|
||||
const image = await createImageFile(`type-${nanoid()}.png`, 50, 50);
|
||||
|
||||
const res = await createEnvelopeWithContents({
|
||||
request,
|
||||
token,
|
||||
payload: { externalId },
|
||||
contents: [{ contentMeta: textMeta('Not an image'), imageIndex: 0 }],
|
||||
images: [image],
|
||||
});
|
||||
|
||||
expect(res.status()).toBe(400);
|
||||
expect(await res.text()).toContain('A text content cannot hold an image');
|
||||
|
||||
expect(await prisma.envelope.count({ where: { externalId } })).toBe(0);
|
||||
expect(await countDataContentsNamed(image.name)).toBe(0);
|
||||
});
|
||||
|
||||
for (const { identifier, description } of [
|
||||
{ identifier: 'missing.pdf', description: 'a file name' },
|
||||
{ identifier: 1, description: 'a file index' },
|
||||
]) {
|
||||
test(`rejects a content on ${description} which was not uploaded`, async ({ request }) => {
|
||||
const externalId = `e2e-contents-identifier-${nanoid()}`;
|
||||
const image = await createImageFile(`identifier-${nanoid()}.png`, 50, 50);
|
||||
|
||||
// Only "example.pdf" is uploaded, at index 0.
|
||||
const res = await createEnvelopeWithContents({
|
||||
request,
|
||||
token,
|
||||
payload: { externalId },
|
||||
contents: [{ identifier, contentMeta: imageMeta(40), imageIndex: 0 }],
|
||||
images: [image],
|
||||
});
|
||||
|
||||
expect(res.status()).toBe(404);
|
||||
expect(await res.text()).toContain('Document data not found');
|
||||
|
||||
expect(await prisma.envelope.count({ where: { externalId } })).toBe(0);
|
||||
|
||||
// Rejected before the image was stored.
|
||||
expect(await countDataContentsNamed(image.name)).toBe(0);
|
||||
});
|
||||
}
|
||||
|
||||
test('rejects an image which is not really a PNG, JPEG or WebP', async ({ request }) => {
|
||||
const externalId = `e2e-contents-format-${nanoid()}`;
|
||||
const image = createGifLabelledAsPng(`format-${nanoid()}.png`);
|
||||
|
||||
const res = await createEnvelopeWithContents({
|
||||
request,
|
||||
token,
|
||||
payload: { externalId },
|
||||
contents: [{ contentMeta: imageMeta(40), imageIndex: 0 }],
|
||||
images: [image],
|
||||
});
|
||||
|
||||
expect(res.status()).toBe(400);
|
||||
expect(await res.text()).toContain('Unsupported image format: gif');
|
||||
|
||||
expect(await prisma.envelope.count({ where: { externalId } })).toBe(0);
|
||||
expect(await countDataContentsNamed(image.name)).toBe(0);
|
||||
});
|
||||
|
||||
test('documents contents on the public create route', async ({ request }) => {
|
||||
const res = await request.get(`${WEBAPP_BASE_URL}/api/v2/openapi.json`);
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
const openApiDocument: unknown = await res.json();
|
||||
|
||||
const requestSchemaPath = [
|
||||
'paths',
|
||||
'/envelope/create',
|
||||
'post',
|
||||
'requestBody',
|
||||
'content',
|
||||
'multipart/form-data',
|
||||
'schema',
|
||||
'properties',
|
||||
];
|
||||
|
||||
expect(openApiDocument).toHaveProperty([...requestSchemaPath, 'contentImages']);
|
||||
|
||||
expect(openApiDocument).toHaveProperty([
|
||||
...requestSchemaPath,
|
||||
'payload',
|
||||
'properties',
|
||||
'contents',
|
||||
'items',
|
||||
'properties',
|
||||
'imageIndex',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
|
||||
import { getPageCanvas, placeContentOnPdf, selectContentOnCanvas, selectEditorTab } from '../fixtures/contents';
|
||||
import {
|
||||
clickAddMyselfButton,
|
||||
clickEnvelopeEditorStep,
|
||||
getEnvelopeEditorSettingsTrigger,
|
||||
openDocumentEnvelopeEditor,
|
||||
type TEnvelopeEditorSurface,
|
||||
} from '../fixtures/envelope-editor';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
import { getKonvaTransformerNodeCountForPage } from '../fixtures/konva';
|
||||
|
||||
/**
|
||||
* Zooming destroys and rebuilds the page's Konva stage. The selection is held
|
||||
* as node references, so it has to be re-resolved against the rebuilt nodes,
|
||||
* otherwise the handles and the settings panel are lost mid-edit.
|
||||
*/
|
||||
|
||||
const openContentsTab = async (surface: TEnvelopeEditorSurface) => {
|
||||
const root = surface.root;
|
||||
|
||||
await getEnvelopeEditorSettingsTrigger(root).click();
|
||||
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
|
||||
await root.locator('input[name="externalId"]').fill(`e2e-zoom-selection-${nanoid()}`);
|
||||
await root.getByRole('button', { name: 'Update' }).click();
|
||||
await expectToastTextToBeVisible(root, 'Envelope updated');
|
||||
await root.getByTestId('toast-close').click();
|
||||
|
||||
await clickAddMyselfButton(root);
|
||||
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
await expect(getPageCanvas(root)).toBeVisible();
|
||||
|
||||
await selectEditorTab(root, 'Contents');
|
||||
};
|
||||
|
||||
const zoomIn = async (root: Page) => await root.locator('button[title="Zoom in"]').click();
|
||||
|
||||
test('a selected field survives zooming', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const root = surface.root;
|
||||
|
||||
await clickAddMyselfButton(root);
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
await expect(getPageCanvas(root)).toBeVisible();
|
||||
|
||||
await root.getByRole('button', { name: 'Signature', exact: true }).click();
|
||||
await getPageCanvas(root).click({ position: { x: 160, y: 160 } });
|
||||
|
||||
// A freshly placed field is selected, with its action bar showing.
|
||||
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(1);
|
||||
await expect(root.getByTestId('envelope-canvas-action-bar')).toBeVisible();
|
||||
|
||||
await zoomIn(root);
|
||||
|
||||
// The rebuilt stage must end up with the same field selected.
|
||||
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(1);
|
||||
|
||||
// The action bar positions itself from the selected nodes, so it is only
|
||||
// placed correctly while they are the live ones.
|
||||
await expect(root.getByTestId('envelope-canvas-action-bar')).toBeVisible();
|
||||
});
|
||||
|
||||
test('a selected content survives zooming', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const root = surface.root;
|
||||
|
||||
await openContentsTab(surface);
|
||||
|
||||
await placeContentOnPdf(root, 'Text', { x: 160, y: 160 });
|
||||
|
||||
await selectContentOnCanvas(root, { x: 170, y: 170 });
|
||||
|
||||
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(1);
|
||||
await expect(root.getByTestId('envelope-canvas-action-bar')).toBeVisible();
|
||||
|
||||
await zoomIn(root);
|
||||
|
||||
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(1);
|
||||
await expect(root.getByTestId('envelope-canvas-action-bar')).toBeVisible();
|
||||
});
|
||||
@@ -1,268 +0,0 @@
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { createCanvas } from '@napi-rs/canvas';
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
getContentActionButton,
|
||||
getContentGroupsForPage,
|
||||
getPageCanvas,
|
||||
getPageSize,
|
||||
interactWithCanvasPastActionBar,
|
||||
placeContentOnPdf,
|
||||
selectEditorTab,
|
||||
uploadImage,
|
||||
waitForContentsAutosave,
|
||||
waitForUploadToLand,
|
||||
} from '../fixtures/contents';
|
||||
import {
|
||||
clickAddMyselfButton,
|
||||
clickEnvelopeEditorStep,
|
||||
getEnvelopeEditorSettingsTrigger,
|
||||
openDocumentEnvelopeEditor,
|
||||
type TEnvelopeEditorSurface,
|
||||
} from '../fixtures/envelope-editor';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
|
||||
/**
|
||||
* Editing a content while its first save is still on the wire must not make
|
||||
* the server delete and recreate the row, which used to happen on every save
|
||||
* from then on because the client kept the id of the deleted row.
|
||||
*/
|
||||
|
||||
const CONTENT_SET_PROCEDURE = 'envelope.content.set';
|
||||
|
||||
/**
|
||||
* Hold the first `envelope.content.set` open so an edit can be made while it
|
||||
* is in flight. Later saves are forwarded immediately.
|
||||
*/
|
||||
const installContentSetLag = async (page: Page, lagMs: number) => {
|
||||
let markFirstInFlight: () => void = () => {};
|
||||
|
||||
const firstContentSetInFlight = new Promise<void>((resolve) => {
|
||||
markFirstInFlight = resolve;
|
||||
});
|
||||
|
||||
const requestBodies: string[] = [];
|
||||
|
||||
await page.route('**/api/trpc/**', async (route) => {
|
||||
const request = route.request();
|
||||
|
||||
if (request.method() !== 'POST' || !request.url().includes(CONTENT_SET_PROCEDURE)) {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
|
||||
const callIndex = requestBodies.length + 1;
|
||||
requestBodies.push(request.postData() ?? '');
|
||||
|
||||
if (callIndex === 1) {
|
||||
markFirstInFlight();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, lagMs));
|
||||
}
|
||||
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
return { firstContentSetInFlight, requestBodies };
|
||||
};
|
||||
|
||||
const openContentsTab = async (surface: TEnvelopeEditorSurface) => {
|
||||
const root = surface.root;
|
||||
|
||||
await getEnvelopeEditorSettingsTrigger(root).click();
|
||||
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
|
||||
await root.locator('input[name="externalId"]').fill(`e2e-content-race-${nanoid()}`);
|
||||
await root.getByRole('button', { name: 'Update' }).click();
|
||||
await expectToastTextToBeVisible(root, 'Envelope updated');
|
||||
await root.getByTestId('toast-close').click();
|
||||
|
||||
await clickAddMyselfButton(root);
|
||||
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
await expect(getPageCanvas(root)).toBeVisible();
|
||||
|
||||
await selectEditorTab(root, 'Contents');
|
||||
};
|
||||
|
||||
const getContentRows = async (envelopeId: string) =>
|
||||
await prisma.envelopeContent.findMany({
|
||||
where: { envelopeId },
|
||||
select: { id: true, contentMeta: true },
|
||||
});
|
||||
|
||||
/**
|
||||
* Drag the only content on the page by the given offset.
|
||||
*/
|
||||
const dragContent = async (root: Page, offsetX: number, offsetY: number) => {
|
||||
const [content] = await getContentGroupsForPage(root);
|
||||
const { scale } = await getPageSize(root);
|
||||
|
||||
const box = await getPageCanvas(root).boundingBox();
|
||||
|
||||
if (!box) {
|
||||
throw new Error('Canvas bounding box not available');
|
||||
}
|
||||
|
||||
const startX = box.x + (content.rect.x + content.rect.width / 2) * scale;
|
||||
const startY = box.y + (content.rect.y + content.rect.height / 2) * scale;
|
||||
|
||||
await interactWithCanvasPastActionBar(root, async () => {
|
||||
await root.mouse.move(startX, startY);
|
||||
await root.mouse.down();
|
||||
await root.mouse.move(startX + offsetX, startY + offsetY, { steps: 8 });
|
||||
await root.mouse.up();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* The same race on an image content, which used to be the worst case: the
|
||||
* recreated row left the editor pointing at a data content the server had
|
||||
* cleaned up, so every later save failed.
|
||||
*/
|
||||
test('editing a duplicated image content mid-save keeps saving', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const root = surface.root;
|
||||
|
||||
const envelopeId = surface.envelopeId;
|
||||
|
||||
if (!envelopeId) {
|
||||
throw new Error('Expected the document editor surface to have an envelopeId');
|
||||
}
|
||||
|
||||
await openContentsTab(surface);
|
||||
|
||||
await placeContentOnPdf(root, 'Image', { x: 150, y: 150 });
|
||||
|
||||
const png = await createCanvas(60, 30).encode('png');
|
||||
|
||||
await uploadImage(root, { name: 'image.png', mimeType: 'image/png', buffer: png });
|
||||
await waitForUploadToLand(root);
|
||||
|
||||
await waitForContentsAutosave(surface);
|
||||
await expect.poll(async () => (await getContentRows(envelopeId)).length).toBe(1);
|
||||
|
||||
const { firstContentSetInFlight } = await installContentSetLag(page, 4000);
|
||||
|
||||
// The duplicate shares the original's image rather than getting a copy.
|
||||
await getContentActionButton(root, 'Duplicate').click();
|
||||
|
||||
await firstContentSetInFlight;
|
||||
|
||||
await dragContent(root, 50, 30);
|
||||
|
||||
await waitForContentsAutosave(surface);
|
||||
|
||||
// A further edit must still save cleanly.
|
||||
await dragContent(root, -20, 15);
|
||||
await waitForContentsAutosave(surface);
|
||||
|
||||
await expect(root.getByText('Save failed')).toHaveCount(0);
|
||||
|
||||
const rows = await prisma.envelopeContent.findMany({
|
||||
where: { envelopeId },
|
||||
select: { id: true, dataContentId: true },
|
||||
});
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
|
||||
// Both contents keep the image they share, and it has not been deleted.
|
||||
const dataContentIds = rows.flatMap((row) => (row.dataContentId ? [row.dataContentId] : []));
|
||||
|
||||
expect(dataContentIds).toHaveLength(2);
|
||||
expect(new Set(dataContentIds).size).toBe(1);
|
||||
|
||||
const dataContents = await prisma.dataContent.findMany({ where: { id: { in: dataContentIds } } });
|
||||
|
||||
expect(dataContents).toHaveLength(1);
|
||||
});
|
||||
|
||||
/**
|
||||
* Whatever the cause, if the editor ends up holding an ID the envelope no
|
||||
* longer has, the server treats the content as new and the editor has to
|
||||
* adopt the ID it gets back. Keeping the old one would make every later save
|
||||
* create yet another row.
|
||||
*/
|
||||
test('a content whose row no longer exists recovers on the next save', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const root = surface.root;
|
||||
|
||||
const envelopeId = surface.envelopeId;
|
||||
|
||||
if (!envelopeId) {
|
||||
throw new Error('Expected the document editor surface to have an envelopeId');
|
||||
}
|
||||
|
||||
await openContentsTab(surface);
|
||||
|
||||
await placeContentOnPdf(root, 'Text', { x: 150, y: 150 });
|
||||
await waitForContentsAutosave(surface);
|
||||
|
||||
await expect.poll(async () => (await getContentRows(envelopeId)).length).toBe(1);
|
||||
|
||||
// Drop the row behind the editor's back, so the ID it holds is stale.
|
||||
await prisma.envelopeContent.deleteMany({ where: { envelopeId } });
|
||||
|
||||
await dragContent(root, 40, 30);
|
||||
await waitForContentsAutosave(surface);
|
||||
|
||||
const recreated = await getContentRows(envelopeId);
|
||||
|
||||
expect(recreated).toHaveLength(1);
|
||||
|
||||
// The editor must now be pointing at the recreated row, so a further edit
|
||||
// updates it instead of creating a third one.
|
||||
await dragContent(root, -20, 10);
|
||||
await waitForContentsAutosave(surface);
|
||||
|
||||
const rowsAfterEdit = await getContentRows(envelopeId);
|
||||
|
||||
expect(rowsAfterEdit).toHaveLength(1);
|
||||
expect(rowsAfterEdit[0].id).toBe(recreated[0].id);
|
||||
|
||||
await expect(root.getByText('Save failed')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('editing a content while its first save is in flight keeps the same row', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const root = surface.root;
|
||||
|
||||
const envelopeId = surface.envelopeId;
|
||||
|
||||
if (!envelopeId) {
|
||||
throw new Error('Expected the document editor surface to have an envelopeId');
|
||||
}
|
||||
|
||||
await openContentsTab(surface);
|
||||
|
||||
const { firstContentSetInFlight, requestBodies } = await installContentSetLag(page, 4000);
|
||||
|
||||
await placeContentOnPdf(root, 'Text', { x: 150, y: 150 });
|
||||
|
||||
// Wait until the creating save is actually on the wire, then edit.
|
||||
await firstContentSetInFlight;
|
||||
|
||||
await dragContent(root, 60, 40);
|
||||
|
||||
await waitForContentsAutosave(surface);
|
||||
await expect.poll(async () => (await getContentRows(envelopeId)).length).toBe(1);
|
||||
|
||||
const [afterRace] = await getContentRows(envelopeId);
|
||||
|
||||
// A further edit must update that same row rather than replacing it.
|
||||
await dragContent(root, -30, 20);
|
||||
await waitForContentsAutosave(surface);
|
||||
|
||||
const rowsAfterEdit = await getContentRows(envelopeId);
|
||||
|
||||
expect(rowsAfterEdit).toHaveLength(1);
|
||||
expect(rowsAfterEdit[0].id).toBe(afterRace.id);
|
||||
|
||||
await expect(root.getByText('Save failed')).toHaveCount(0);
|
||||
|
||||
// The save queued during the race must reference the row the first save
|
||||
// created, rather than asking for another one to be created.
|
||||
expect(requestBodies).toHaveLength(3);
|
||||
expect(requestBodies[1]).toContain(afterRace.id);
|
||||
});
|
||||
-214
@@ -1,214 +0,0 @@
|
||||
import { APP_CONTENT_IMAGE_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/envelope-content';
|
||||
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { createCanvas } from '@napi-rs/canvas';
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
getContentActionButton,
|
||||
getContentGroupsForPage,
|
||||
getPageCanvas,
|
||||
placeContentOnPdf,
|
||||
selectEditorTab,
|
||||
waitForContentsAutosave,
|
||||
} from '../fixtures/contents';
|
||||
import { clickEnvelopeEditorStep, openDocumentEnvelopeEditor } from '../fixtures/envelope-editor';
|
||||
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
/**
|
||||
* Image uploads go through a dialog which blocks the editor until the upload
|
||||
* settles, so there is only ever one in flight and it always lands on the
|
||||
* content it was opened for.
|
||||
*/
|
||||
|
||||
const createPng = async () => {
|
||||
const canvas = createCanvas(80, 40);
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
context.fillStyle = 'rgb(30, 120, 220)';
|
||||
context.fillRect(0, 0, 80, 40);
|
||||
|
||||
return await canvas.encode('png');
|
||||
};
|
||||
|
||||
const openContentsEditor = async (page: Page) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
|
||||
await clickEnvelopeEditorStep(page, 'addFields');
|
||||
await selectEditorTab(page, 'Contents');
|
||||
|
||||
return surface;
|
||||
};
|
||||
|
||||
/**
|
||||
* Click Upload / Replace on the action bar and hand the picker a file.
|
||||
*/
|
||||
const uploadThroughDialog = async (page: Page, buffer: Buffer) => {
|
||||
const fileChooser = page.waitForEvent('filechooser');
|
||||
|
||||
await getContentActionButton(page, 'Upload image').or(getContentActionButton(page, 'Replace image')).click();
|
||||
|
||||
await (await fileChooser).setFiles({ name: 'logo.png', mimeType: 'image/png', buffer });
|
||||
};
|
||||
|
||||
test('a dropped file which cannot be used shows why', async ({ page }) => {
|
||||
await openContentsEditor(page);
|
||||
|
||||
await placeContentOnPdf(page, 'Image', { x: 200, y: 200 });
|
||||
|
||||
const dropzoneInput = page
|
||||
.locator('div[role="button"]', { hasText: 'Click to upload or drag and drop' })
|
||||
.locator('input[type="file"]');
|
||||
|
||||
// Not a supported image.
|
||||
await dropzoneInput.setInputFiles({
|
||||
name: 'animated.gif',
|
||||
mimeType: 'image/gif',
|
||||
buffer: Buffer.from('R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==', 'base64'),
|
||||
});
|
||||
|
||||
await expect(page.getByText('This image could not be read. Use a PNG, JPEG or WebP file.')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Close' }).click();
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||
|
||||
// Over the size limit.
|
||||
await dropzoneInput.setInputFiles({
|
||||
name: 'huge.png',
|
||||
mimeType: 'image/png',
|
||||
buffer: Buffer.alloc(megabytesToBytes(APP_CONTENT_IMAGE_UPLOAD_SIZE_LIMIT) + 1),
|
||||
});
|
||||
|
||||
await expect(page.getByText(`This image is larger than ${APP_CONTENT_IMAGE_UPLOAD_SIZE_LIMIT} MB.`)).toBeVisible();
|
||||
});
|
||||
|
||||
test('the dialog blocks the editor while an image uploads', async ({ page }) => {
|
||||
const surface = await openContentsEditor(page);
|
||||
|
||||
await placeContentOnPdf(page, 'Image', { x: 200, y: 200 });
|
||||
await waitForContentsAutosave(surface);
|
||||
|
||||
// Hold the upload open so the blocked state is observable.
|
||||
let releaseUpload: () => void = () => {};
|
||||
const uploadHeld = new Promise<void>((resolve) => {
|
||||
releaseUpload = resolve;
|
||||
});
|
||||
|
||||
await page.route('**/api/trpc/envelope.content.uploadImage**', async (route) => {
|
||||
await uploadHeld;
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await uploadThroughDialog(page, await createPng());
|
||||
|
||||
await expect(page.getByTestId('content-image-uploading')).toBeVisible();
|
||||
|
||||
// The dialog's overlay intercepts pointer events, so a normal click on the
|
||||
// canvas cannot be performed.
|
||||
await expect(getPageCanvas(page).click({ position: { x: 50, y: 50 }, timeout: 1500 })).rejects.toThrow();
|
||||
await expect(page.getByTestId('content-image-uploading')).toBeVisible();
|
||||
|
||||
// Escape does not dismiss it either.
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByTestId('content-image-uploading')).toBeVisible();
|
||||
|
||||
releaseUpload();
|
||||
|
||||
await expect(page.getByTestId('content-image-uploading')).toHaveCount(0);
|
||||
await expect(getContentActionButton(page, 'Replace image')).toBeVisible();
|
||||
});
|
||||
|
||||
test('the upload lands on the content it was opened for', async ({ page }) => {
|
||||
const surface = await openContentsEditor(page);
|
||||
|
||||
await placeContentOnPdf(page, 'Image', { x: 150, y: 150 });
|
||||
await placeContentOnPdf(page, 'Image', { x: 350, y: 350 });
|
||||
await waitForContentsAutosave(surface);
|
||||
|
||||
// The second placed content is the selected one.
|
||||
await uploadThroughDialog(page, await createPng());
|
||||
|
||||
await expect(getContentActionButton(page, 'Replace image')).toBeVisible();
|
||||
|
||||
const groups = await getContentGroupsForPage(page);
|
||||
const withImage = groups.filter((group) => group.visibleChildren.includes('content-image'));
|
||||
|
||||
expect(withImage).toHaveLength(1);
|
||||
|
||||
// And it is persisted on that content, not the other.
|
||||
const contents = await prisma.envelopeContent.findMany({
|
||||
where: { envelopeId: surface.envelopeId },
|
||||
});
|
||||
|
||||
const byStackingOrder = [...contents].sort((a, b) => a.contentMeta.zIndex - b.contentMeta.zIndex);
|
||||
|
||||
expect(byStackingOrder.map((content) => content.dataContentId !== null)).toEqual([false, true]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Dismissing the native picker leaves the dialog open on its "Choose image"
|
||||
* button. Pressing that must reopen the picker even though the dialog's
|
||||
* state has not changed.
|
||||
*/
|
||||
test('the picker can be reopened from the dialog after being dismissed', async ({ page }) => {
|
||||
const surface = await openContentsEditor(page);
|
||||
|
||||
await placeContentOnPdf(page, 'Image', { x: 200, y: 200 });
|
||||
await waitForContentsAutosave(surface);
|
||||
|
||||
// Open the picker and leave it unanswered: the dialog stays on "picking".
|
||||
const firstChooser = page.waitForEvent('filechooser');
|
||||
|
||||
await getContentActionButton(page, 'Upload image').click();
|
||||
await firstChooser;
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Choose image' })).toBeVisible();
|
||||
|
||||
// Pressing the dialog's button opens a fresh picker.
|
||||
const secondChooser = page.waitForEvent('filechooser');
|
||||
|
||||
await page.getByRole('button', { name: 'Choose image' }).click();
|
||||
|
||||
await (await secondChooser).setFiles({ name: 'logo.png', mimeType: 'image/png', buffer: await createPng() });
|
||||
|
||||
await expect(getContentActionButton(page, 'Replace image')).toBeVisible();
|
||||
});
|
||||
|
||||
test('cancelling the picker closes the dialog', async ({ page }) => {
|
||||
const surface = await openContentsEditor(page);
|
||||
|
||||
await placeContentOnPdf(page, 'Image', { x: 200, y: 200 });
|
||||
await waitForContentsAutosave(surface);
|
||||
|
||||
const chooser = page.waitForEvent('filechooser');
|
||||
|
||||
await getContentActionButton(page, 'Upload image').click();
|
||||
await chooser;
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Playwright cannot dismiss a native picker, but the browser reports a
|
||||
// dismissal as a `cancel` event on the input, which is what the dialog
|
||||
// listens for.
|
||||
await page.locator('input[id^="content-image-input-"]').dispatchEvent('cancel');
|
||||
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||
await expect(getContentActionButton(page, 'Upload image')).toBeVisible();
|
||||
});
|
||||
|
||||
test('a content placed and uploaded before autosave still receives its image', async ({ page }) => {
|
||||
const surface = await openContentsEditor(page);
|
||||
|
||||
// No waitForContentsAutosave: the content has no server id yet when the
|
||||
// upload is requested. The dialog flushes the save before uploading.
|
||||
await placeContentOnPdf(page, 'Image', { x: 200, y: 200 });
|
||||
|
||||
await uploadThroughDialog(page, await createPng());
|
||||
|
||||
await expect(getContentActionButton(page, 'Replace image')).toBeVisible();
|
||||
|
||||
const content = await prisma.envelopeContent.findFirstOrThrow({ where: { envelopeId: surface.envelopeId } });
|
||||
|
||||
expect(content.dataContentId).not.toBeNull();
|
||||
});
|
||||
@@ -1,396 +0,0 @@
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
createImageFile,
|
||||
getContentActionButton,
|
||||
getContentGroupsForPage,
|
||||
getPageCanvas,
|
||||
getPageSize,
|
||||
placeContentOnPdf,
|
||||
selectEditorTab,
|
||||
type TestImageFile,
|
||||
uploadImage,
|
||||
waitForContentsAutosave,
|
||||
waitForUploadToLand,
|
||||
} from '../fixtures/contents';
|
||||
import {
|
||||
clickAddMyselfButton,
|
||||
clickEnvelopeEditorStep,
|
||||
getEnvelopeEditorSettingsTrigger,
|
||||
openDocumentEnvelopeEditor,
|
||||
openTemplateEnvelopeEditor,
|
||||
type TEnvelopeEditorSurface,
|
||||
} from '../fixtures/envelope-editor';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
|
||||
/**
|
||||
* The default box an image content is dropped at, as a percentage of the page.
|
||||
* Mirrors `CONTENT_IMAGE_DEFAULT_SIZE`.
|
||||
*/
|
||||
const IMAGE_DEFAULT_SIZE = { width: 15, height: 10 };
|
||||
|
||||
/**
|
||||
* A minimal GIF, which is a real image in a format the editor does not accept.
|
||||
*/
|
||||
const createGifFile = (name: string) => ({
|
||||
name,
|
||||
mimeType: 'image/gif',
|
||||
buffer: Buffer.from('R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==', 'base64'),
|
||||
});
|
||||
|
||||
const openSettingsDialog = async (root: Page) => {
|
||||
await getEnvelopeEditorSettingsTrigger(root).click();
|
||||
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
|
||||
};
|
||||
|
||||
const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: string) => {
|
||||
await openSettingsDialog(surface.root);
|
||||
await surface.root.locator('input[name="externalId"]').fill(externalId);
|
||||
await surface.root.getByRole('button', { name: 'Update' }).click();
|
||||
await expectToastTextToBeVisible(surface.root, 'Envelope updated');
|
||||
await surface.root.getByTestId('toast-close').click();
|
||||
};
|
||||
|
||||
const openContentsTab = async (surface: TEnvelopeEditorSurface, externalId: string) => {
|
||||
await updateExternalId(surface, externalId);
|
||||
await clickAddMyselfButton(surface.root);
|
||||
|
||||
await clickEnvelopeEditorStep(surface.root, 'addFields');
|
||||
await expect(getPageCanvas(surface.root)).toBeVisible();
|
||||
|
||||
await selectEditorTab(surface.root, 'Contents');
|
||||
await expect(surface.root.getByRole('heading', { name: 'Add Content' })).toBeVisible();
|
||||
};
|
||||
|
||||
/**
|
||||
* Retry from the dialog's error state, which reopens the picker.
|
||||
*/
|
||||
const retryUpload = async (root: Page, file: TestImageFile) => {
|
||||
const fileChooser = root.waitForEvent('filechooser');
|
||||
|
||||
await root.getByRole('button', { name: 'Try again' }).click();
|
||||
|
||||
await (await fileChooser).setFiles({ name: file.name, mimeType: file.mimeType, buffer: file.buffer });
|
||||
};
|
||||
|
||||
const findEnvelopeWithContents = async (surface: TEnvelopeEditorSurface, externalId: string) =>
|
||||
await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
externalId,
|
||||
userId: surface.userId,
|
||||
teamId: surface.teamId,
|
||||
type: surface.envelopeType,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
contents: {
|
||||
include: {
|
||||
dataContent: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null;
|
||||
|
||||
const getMeta = (metadata: unknown): Record<string, unknown> => {
|
||||
if (!isRecord(metadata)) {
|
||||
throw new Error('Metadata is not an object');
|
||||
}
|
||||
|
||||
return metadata;
|
||||
};
|
||||
|
||||
// --- Upload, replace and remove ---
|
||||
|
||||
const runUploadReplaceRemoveFlow = async (surface: TEnvelopeEditorSurface) => {
|
||||
const externalId = `e2e-content-images-${nanoid()}`;
|
||||
const root = surface.root;
|
||||
|
||||
await openContentsTab(surface, externalId);
|
||||
|
||||
await placeContentOnPdf(root, 'Image', { x: 200, y: 200 });
|
||||
await expect(root.getByRole('heading', { name: 'Image Settings' })).toBeVisible();
|
||||
await expect(root.getByText('Click to upload or drag and drop')).toBeVisible();
|
||||
|
||||
// The image content is a placeholder before upload.
|
||||
const [placeholder] = await getContentGroupsForPage(root);
|
||||
|
||||
expect(placeholder.visibleChildren).toContain('content-placeholder-rect');
|
||||
|
||||
// Upload a wide 2:1 image. The box is still the default size, so it is
|
||||
// sized to the image at one point per pixel.
|
||||
const wide = await createImageFile('wide-logo.png', 400, 200);
|
||||
|
||||
await uploadImage(root, wide);
|
||||
await waitForUploadToLand(root);
|
||||
|
||||
await expect(root.getByText('wide-logo.png')).toBeVisible();
|
||||
await expect(root.getByText('400 * 200')).toBeVisible();
|
||||
|
||||
const [withWide] = await getContentGroupsForPage(root);
|
||||
|
||||
expect(withWide.visibleChildren).toContain('content-image');
|
||||
expect(withWide.visibleChildren).not.toContain('content-placeholder-rect');
|
||||
expect(withWide.rect.width / withWide.rect.height).toBeCloseTo(2, 1);
|
||||
|
||||
await waitForContentsAutosave(surface);
|
||||
|
||||
const afterUpload = await findEnvelopeWithContents(surface, externalId);
|
||||
const firstDataContentId = afterUpload.contents[0].dataContentId;
|
||||
|
||||
expect(firstDataContentId).not.toBeNull();
|
||||
expect(getMeta(afterUpload.contents[0].dataContent?.metadata)).toMatchObject({
|
||||
type: 'image',
|
||||
width: 400,
|
||||
height: 200,
|
||||
mimeType: 'image/png',
|
||||
fileName: 'wide-logo.png',
|
||||
});
|
||||
|
||||
// The box is no longer the default size, so replacing with a tall image
|
||||
// tightens the box around the fitted image rather than resizing to it.
|
||||
const tall = await createImageFile('tall-logo.jpg', 100, 300, 'jpeg');
|
||||
|
||||
await uploadImage(root, tall);
|
||||
await waitForUploadToLand(root);
|
||||
|
||||
await expect(root.getByText('tall-logo.jpg')).toBeVisible();
|
||||
|
||||
const [withTall] = await getContentGroupsForPage(root);
|
||||
|
||||
expect(withTall.rect.width / withTall.rect.height).toBeCloseTo(1 / 3, 1);
|
||||
expect(withTall.rect.width).toBeLessThanOrEqual(withWide.rect.width + 0.5);
|
||||
expect(withTall.rect.height).toBeLessThanOrEqual(withWide.rect.height + 0.5);
|
||||
|
||||
await waitForContentsAutosave(surface);
|
||||
|
||||
const afterReplace = await findEnvelopeWithContents(surface, externalId);
|
||||
const secondDataContentId = afterReplace.contents[0].dataContentId;
|
||||
|
||||
expect(secondDataContentId).not.toBeNull();
|
||||
expect(secondDataContentId).not.toBe(firstDataContentId);
|
||||
expect(getMeta(afterReplace.contents[0].dataContent?.metadata)).toMatchObject({
|
||||
mimeType: 'image/jpeg',
|
||||
fileName: 'tall-logo.jpg',
|
||||
});
|
||||
|
||||
// Data contents are never deleted: the replaced image's row remains, since
|
||||
// another content (e.g. on a document created from this one) may still
|
||||
// reference it.
|
||||
expect(await prisma.dataContent.findUnique({ where: { id: firstDataContentId ?? '' } })).not.toBeNull();
|
||||
|
||||
// Remove returns to the drop zone and detaches the image, leaving the box.
|
||||
await root.getByRole('button', { name: 'Remove image' }).click();
|
||||
await expect(root.getByText('Click to upload or drag and drop')).toBeVisible();
|
||||
|
||||
const [afterRemoveGroup] = await getContentGroupsForPage(root);
|
||||
|
||||
expect(afterRemoveGroup.visibleChildren).toContain('content-placeholder-rect');
|
||||
expect(afterRemoveGroup.rect.width).toBeCloseTo(withTall.rect.width, 0);
|
||||
|
||||
await waitForContentsAutosave(surface);
|
||||
|
||||
const afterRemove = await findEnvelopeWithContents(surface, externalId);
|
||||
|
||||
expect(afterRemove.contents[0].dataContentId).toBeNull();
|
||||
|
||||
// Removing detaches the content from its image; the data content itself is
|
||||
// never deleted.
|
||||
expect(await prisma.dataContent.findUnique({ where: { id: secondDataContentId ?? '' } })).not.toBeNull();
|
||||
|
||||
return { externalId };
|
||||
};
|
||||
|
||||
// --- Upload via the action bar, auto size caps ---
|
||||
|
||||
const runActionBarUploadFlow = async (surface: TEnvelopeEditorSurface) => {
|
||||
const externalId = `e2e-content-images-bar-${nanoid()}`;
|
||||
const root = surface.root;
|
||||
|
||||
await openContentsTab(surface, externalId);
|
||||
|
||||
await placeContentOnPdf(root, 'Image', { x: 200, y: 200 });
|
||||
await expect(getContentActionButton(root, 'Upload image')).toBeVisible();
|
||||
|
||||
// The bar's button opens the upload dialog, which opens the picker at once.
|
||||
const fileChooser = root.waitForEvent('filechooser');
|
||||
|
||||
await getContentActionButton(root, 'Upload image').click();
|
||||
|
||||
const huge = await createImageFile('huge-logo.png', 2000, 2000);
|
||||
|
||||
await (await fileChooser).setFiles({ name: huge.name, mimeType: huge.mimeType, buffer: huge.buffer });
|
||||
|
||||
await expect(root.getByText('huge-logo.png')).toBeVisible();
|
||||
await expect(getContentActionButton(root, 'Replace image')).toBeVisible();
|
||||
|
||||
const pageSize = await getPageSize(root);
|
||||
|
||||
await waitForContentsAutosave(surface);
|
||||
|
||||
return { externalId, pageSize };
|
||||
};
|
||||
|
||||
const assertActionBarUploadPersisted = async (
|
||||
surface: TEnvelopeEditorSurface,
|
||||
externalId: string,
|
||||
pageSize: { width: number; height: number },
|
||||
) => {
|
||||
const envelope = await findEnvelopeWithContents(surface, externalId);
|
||||
const [imageContent] = envelope.contents;
|
||||
const meta = getMeta(imageContent.contentMeta);
|
||||
|
||||
expect(imageContent.dataContent).not.toBeNull();
|
||||
|
||||
// A 2000px square exceeds the page, so its width is capped at 80% of the
|
||||
// page width and the height follows the square ratio in page points, i.e.
|
||||
// 80% of the page width expressed as a percentage of the page height.
|
||||
expect(Number(meta.width)).toBeCloseTo(80, 0);
|
||||
expect(Number(meta.height)).toBeCloseTo((80 * pageSize.width) / pageSize.height, 0);
|
||||
expect(Number(meta.positionX) + Number(meta.width)).toBeLessThanOrEqual(100);
|
||||
expect(Number(meta.positionY) + Number(meta.height)).toBeLessThanOrEqual(100);
|
||||
};
|
||||
|
||||
// --- Rejected uploads ---
|
||||
|
||||
const runRejectedUploadsFlow = async (surface: TEnvelopeEditorSurface) => {
|
||||
const externalId = `e2e-content-images-reject-${nanoid()}`;
|
||||
const root = surface.root;
|
||||
|
||||
await openContentsTab(surface, externalId);
|
||||
|
||||
await placeContentOnPdf(root, 'Image', { x: 200, y: 200 });
|
||||
|
||||
// An unsupported format is rejected by the server's MIME allowlist. The
|
||||
// dialog stays open showing why.
|
||||
await uploadImage(root, createGifFile('animated.gif'));
|
||||
await expect(root.getByText('This image could not be read. Use a PNG, JPEG or WebP file.')).toBeVisible();
|
||||
|
||||
// Bytes which are not an image at all pass the type check and are rejected
|
||||
// while the server decodes them.
|
||||
await retryUpload(root, {
|
||||
name: 'not-an-image.png',
|
||||
mimeType: 'image/png',
|
||||
buffer: Buffer.from('definitely not a png'),
|
||||
});
|
||||
await expect(root.getByText('This image could not be read. Use a PNG, JPEG or WebP file.')).toBeVisible();
|
||||
|
||||
// Give up, releasing the editor.
|
||||
await root.getByRole('button', { name: 'Close' }).click();
|
||||
await expect(root.getByRole('button', { name: 'Try again' })).toHaveCount(0);
|
||||
|
||||
// The placeholder is unaffected.
|
||||
const [group] = await getContentGroupsForPage(root);
|
||||
|
||||
expect(group.visibleChildren).toContain('content-placeholder-rect');
|
||||
expect(IMAGE_DEFAULT_SIZE.width).toBeGreaterThan(0);
|
||||
|
||||
await waitForContentsAutosave(surface);
|
||||
|
||||
return { externalId };
|
||||
};
|
||||
|
||||
const assertNoImageAttached = async (surface: TEnvelopeEditorSurface, externalId: string) => {
|
||||
const envelope = await findEnvelopeWithContents(surface, externalId);
|
||||
|
||||
expect(envelope.contents).toHaveLength(1);
|
||||
expect(envelope.contents[0].dataContentId).toBeNull();
|
||||
};
|
||||
|
||||
// --- Send guard ---
|
||||
|
||||
const runSendGuardFlow = async (surface: TEnvelopeEditorSurface) => {
|
||||
const externalId = `e2e-content-images-guard-${nanoid()}`;
|
||||
const root = surface.root;
|
||||
|
||||
await openContentsTab(surface, externalId);
|
||||
|
||||
await placeContentOnPdf(root, 'Image', { x: 200, y: 200 });
|
||||
|
||||
// A signature field so the only thing blocking sending is the empty image.
|
||||
await selectEditorTab(root, 'Fields');
|
||||
await root.getByRole('button', { name: 'Signature', exact: true }).click();
|
||||
await getPageCanvas(root).click({ position: { x: 200, y: 500 } });
|
||||
|
||||
await waitForContentsAutosave(surface);
|
||||
|
||||
await root.getByRole('navigation').getByRole('button', { name: 'Send Document' }).click();
|
||||
await expect(root.getByRole('heading', { name: 'Send Document' })).toBeVisible();
|
||||
await expect(root.getByText(/image content(s)? (has|have) no image/)).toBeVisible();
|
||||
await expect(root.getByRole('button', { name: 'Send', exact: true })).toHaveCount(0);
|
||||
|
||||
await root.keyboard.press('Escape');
|
||||
|
||||
// Attach an image and the guard lifts.
|
||||
await selectEditorTab(root, 'Contents');
|
||||
await getPageCanvas(root).click({ position: { x: 200, y: 200 }, force: true });
|
||||
await expect(root.getByRole('heading', { name: 'Image Settings' })).toBeVisible();
|
||||
|
||||
const logo = await createImageFile('logo.png', 120, 60);
|
||||
|
||||
await uploadImage(root, logo);
|
||||
await waitForUploadToLand(root);
|
||||
await expect(root.getByText('logo.png')).toBeVisible();
|
||||
|
||||
await waitForContentsAutosave(surface);
|
||||
|
||||
await root.getByRole('navigation').getByRole('button', { name: 'Send Document' }).click();
|
||||
await expect(root.getByRole('heading', { name: 'Send Document' })).toBeVisible();
|
||||
await expect(root.getByText(/image content(s)? (has|have) no image/)).toHaveCount(0);
|
||||
await expect(root.getByRole('button', { name: 'Send', exact: true })).toBeVisible();
|
||||
};
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
test.describe('document editor', () => {
|
||||
test('upload, replace and remove a content image', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
|
||||
await runUploadReplaceRemoveFlow(surface);
|
||||
});
|
||||
|
||||
test('upload a content image from the canvas action bar', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const { externalId, pageSize } = await runActionBarUploadFlow(surface);
|
||||
|
||||
await assertActionBarUploadPersisted(surface, externalId, pageSize);
|
||||
});
|
||||
|
||||
test('rejected uploads leave the placeholder untouched', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const { externalId } = await runRejectedUploadsFlow(surface);
|
||||
|
||||
await assertNoImageAttached(surface, externalId);
|
||||
});
|
||||
|
||||
test('a document cannot be sent with an image content which has no image', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
|
||||
await runSendGuardFlow(surface);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('template editor', () => {
|
||||
test('upload, replace and remove a content image', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
|
||||
await runUploadReplaceRemoveFlow(surface);
|
||||
});
|
||||
|
||||
test('upload a content image from the canvas action bar', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
const { externalId, pageSize } = await runActionBarUploadFlow(surface);
|
||||
|
||||
await assertActionBarUploadPersisted(surface, externalId, pageSize);
|
||||
});
|
||||
|
||||
test('rejected uploads leave the placeholder untouched', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
const { externalId } = await runRejectedUploadsFlow(surface);
|
||||
|
||||
await assertNoImageAttached(surface, externalId);
|
||||
});
|
||||
});
|
||||
@@ -1,157 +0,0 @@
|
||||
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');
|
||||
});
|
||||
@@ -1,110 +0,0 @@
|
||||
import { EnvelopeContentType, ZEnvelopeContentMetaSchema } from '@documenso/lib/types/envelope-content-meta';
|
||||
import { generateDatabaseId } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
|
||||
import { getContentGroupsForPage, getPageCanvas, selectEditorTab } from '../fixtures/contents';
|
||||
import { clickEnvelopeEditorStep, openDocumentEnvelopeEditor } from '../fixtures/envelope-editor';
|
||||
|
||||
/**
|
||||
* Contents sharing a `zIndex` are stacked by their database ID everywhere the
|
||||
* document is rendered. The editor has to agree, otherwise what is authored
|
||||
* is not what is sealed.
|
||||
*
|
||||
* Ties cannot be produced through the editor, which always places a content
|
||||
* above the rest of its page, so they are seeded directly.
|
||||
*/
|
||||
|
||||
const POSITIONS = [10, 25, 40, 55];
|
||||
|
||||
const seedTiedContents = async (envelopeId: string) => {
|
||||
const envelope = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: envelopeId },
|
||||
include: { envelopeItems: true },
|
||||
});
|
||||
|
||||
await prisma.envelopeContent.createMany({
|
||||
data: POSITIONS.map((positionX) => ({
|
||||
id: generateDatabaseId('envelope_content'),
|
||||
envelopeId,
|
||||
envelopeItemId: envelope.envelopeItems[0].id,
|
||||
contentMeta: ZEnvelopeContentMetaSchema.parse({
|
||||
type: EnvelopeContentType.TEXT,
|
||||
page: 1,
|
||||
rotation: 0,
|
||||
positionX,
|
||||
positionY: 20,
|
||||
width: 10,
|
||||
height: 5,
|
||||
text: `x${positionX}`,
|
||||
}),
|
||||
})),
|
||||
});
|
||||
|
||||
const contents = await prisma.envelopeContent.findMany({
|
||||
where: { envelopeId },
|
||||
select: { id: true, contentMeta: true },
|
||||
});
|
||||
|
||||
// The order every other renderer uses: zIndex, then database ID.
|
||||
return [...contents]
|
||||
.sort((a, b) => (a.id < b.id ? -1 : 1))
|
||||
.map((content) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const contentMeta = content.contentMeta as { positionX?: number };
|
||||
|
||||
return contentMeta.positionX;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* The horizontal positions of the page's contents, in the order they are
|
||||
* stacked on the canvas.
|
||||
*/
|
||||
const getCanvasStackingOrder = async (root: Page) => {
|
||||
const groups = await getContentGroupsForPage(root);
|
||||
const { width } = await root.evaluate(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const konva = (window as unknown as { Konva: typeof import('konva').default }).Konva;
|
||||
const stage = konva.stages.find((currentStage) => currentStage.attrs.id === 'page-1');
|
||||
|
||||
return { width: (stage?.width() ?? 1) / (stage?.scaleX() ?? 1) };
|
||||
});
|
||||
|
||||
return groups.map((group) =>
|
||||
POSITIONS.reduce((closest, position) =>
|
||||
Math.abs((position / 100) * width - group.rect.x) < Math.abs((closest / 100) * width - group.rect.x)
|
||||
? position
|
||||
: closest,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const openContentsTab = async (root: Page) => {
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
await expect(getPageCanvas(root)).toBeVisible();
|
||||
await selectEditorTab(root, 'Contents');
|
||||
};
|
||||
|
||||
test('contents sharing a zIndex stack by database ID in the editor', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
|
||||
const envelopeId = surface.envelopeId;
|
||||
|
||||
if (!envelopeId) {
|
||||
throw new Error('Expected the document editor surface to have an envelopeId');
|
||||
}
|
||||
|
||||
const expectedOrder = await seedTiedContents(envelopeId);
|
||||
|
||||
// Reloaded repeatedly since the editor's own IDs are regenerated on each
|
||||
// load, so a stacking order derived from them would vary between loads.
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
await page.reload();
|
||||
await openContentsTab(page);
|
||||
|
||||
await expect.poll(async () => (await getContentGroupsForPage(page)).length).toBe(POSITIONS.length);
|
||||
|
||||
expect(await getCanvasStackingOrder(page)).toEqual(expectedOrder);
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -310,32 +310,6 @@ test.describe('embedded edit', () => {
|
||||
expect(items[0].title).toBe('Envelope Item A');
|
||||
expect(items[0].order).toBe(2); // Expect order 2 because deleting items does not drop the order of sequential items.
|
||||
});
|
||||
|
||||
test('items added together keep the order they were added in', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'DOCUMENT',
|
||||
mode: 'edit',
|
||||
tokenNamePrefix: 'e2e-embed-items-order',
|
||||
});
|
||||
|
||||
const externalId = `e2e-items-order-${nanoid()}`;
|
||||
|
||||
await updateExternalId(surface, externalId);
|
||||
await expect(getEnvelopeItemTitleInputs(surface.root)).toHaveCount(1);
|
||||
|
||||
// Both files are added in one go and not reordered afterwards, which
|
||||
// would renumber every item.
|
||||
await uploadFiles(surface.root, [createPdfPayload('first-added.pdf'), createPdfPayload('second-added.pdf')]);
|
||||
await expect(getEnvelopeItemTitleInputs(surface.root)).toHaveCount(3);
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
const items = await getEnvelopeItemsFromDatabase(surface, externalId);
|
||||
|
||||
expect(items.map((item) => item.order)).toEqual([1, 2, 3]);
|
||||
expect(items[1].title).toContain('first-added');
|
||||
expect(items[2].title).toContain('second-added');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('pending envelope title editing', () => {
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,236 +0,0 @@
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -1,78 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,183 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,162 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,164 +0,0 @@
|
||||
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 });
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,112 +0,0 @@
|
||||
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([]);
|
||||
});
|
||||
@@ -1,270 +0,0 @@
|
||||
import { createCanvas } from '@napi-rs/canvas';
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
import type Konva from 'konva';
|
||||
|
||||
import type { TEnvelopeEditorSurface } from './envelope-editor';
|
||||
import { getKonvaElementCountForPage } from './konva';
|
||||
|
||||
export type ContentButtonName = 'Text' | 'Line' | 'Rectangle' | 'Highlight' | 'Image';
|
||||
|
||||
export type TestImageFile = { name: string; mimeType: string; buffer: Buffer };
|
||||
|
||||
/**
|
||||
* Generate a solid color image of the given size in memory.
|
||||
*/
|
||||
export const createImageFile = async (
|
||||
name: string,
|
||||
width: number,
|
||||
height: number,
|
||||
format: 'png' | 'jpeg' | 'webp' = 'png',
|
||||
): Promise<TestImageFile> => {
|
||||
const canvas = createCanvas(width, height);
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
context.fillStyle = 'rgb(30, 120, 220)';
|
||||
context.fillRect(0, 0, width, height);
|
||||
|
||||
const buffer = format === 'png' ? await canvas.encode('png') : await canvas.encode(format);
|
||||
|
||||
return { name, mimeType: `image/${format}`, buffer };
|
||||
};
|
||||
|
||||
/**
|
||||
* A real GIF labelled as a PNG, as when the file is renamed. The declared type
|
||||
* passes every check made before the bytes are read, so only decoding the
|
||||
* image can reject it.
|
||||
*/
|
||||
export const createGifLabelledAsPng = (name: string): TestImageFile => ({
|
||||
name,
|
||||
mimeType: 'image/png',
|
||||
buffer: Buffer.from('R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==', 'base64'),
|
||||
});
|
||||
|
||||
/**
|
||||
* Switch the fields step sidebar to the given tab.
|
||||
*/
|
||||
export const selectEditorTab = async (root: Page, tab: 'Fields' | 'Contents') => {
|
||||
await root.getByRole('tab', { name: tab }).click();
|
||||
await expect(root.getByRole('tab', { name: tab })).toHaveAttribute('data-state', 'active');
|
||||
};
|
||||
|
||||
export const getPageCanvas = (root: Page) => root.locator('.konva-container canvas').first();
|
||||
|
||||
/**
|
||||
* Run a mouse interaction on the canvas with the floating action bar of the
|
||||
* current selection unable to intercept it.
|
||||
*
|
||||
* The bar hangs just below the selected content and sits above the canvas,
|
||||
* so a click there would press one of its buttons (e.g. Remove) rather than
|
||||
* reach the canvas.
|
||||
*/
|
||||
export const interactWithCanvasPastActionBar = async (root: Page, interaction: () => Promise<void>) => {
|
||||
const setActionBarPointerEvents = async (value: string) => {
|
||||
await root.evaluate((pointerEvents) => {
|
||||
for (const bar of document.querySelectorAll<HTMLElement>('[data-testid="envelope-canvas-action-bar"]')) {
|
||||
bar.style.pointerEvents = pointerEvents;
|
||||
}
|
||||
}, value);
|
||||
};
|
||||
|
||||
await setActionBarPointerEvents('none');
|
||||
|
||||
try {
|
||||
await interaction();
|
||||
} finally {
|
||||
await setActionBarPointerEvents('');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Pick a content from the palette and click on the canvas to place it.
|
||||
*/
|
||||
export const placeContentOnPdf = async (
|
||||
root: Page,
|
||||
contentName: ContentButtonName,
|
||||
position: { x: number; y: number },
|
||||
) => {
|
||||
await root.getByRole('button', { name: contentName, exact: true }).click();
|
||||
|
||||
const canvas = getPageCanvas(root);
|
||||
await expect(canvas).toBeVisible();
|
||||
|
||||
await interactWithCanvasPastActionBar(root, async () => {
|
||||
await canvas.click({ position });
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Click a content on the canvas to select it.
|
||||
*/
|
||||
export const selectContentOnCanvas = async (
|
||||
root: Page,
|
||||
position: { x: number; y: number },
|
||||
options: { shift?: boolean } = {},
|
||||
) => {
|
||||
const canvas = getPageCanvas(root);
|
||||
await expect(canvas).toBeVisible();
|
||||
await root.waitForTimeout(300);
|
||||
|
||||
await interactWithCanvasPastActionBar(root, async () => {
|
||||
await canvas.click({ position, modifiers: options.shift ? ['Shift'] : [], force: true });
|
||||
});
|
||||
};
|
||||
|
||||
export const getContentCountForPage = async (root: Page, pageNumber = 1) =>
|
||||
await getKonvaElementCountForPage(root, pageNumber, '.content-group');
|
||||
|
||||
export const getFieldCountForPage = async (root: Page, pageNumber = 1) =>
|
||||
await getKonvaElementCountForPage(root, pageNumber, '.field-group');
|
||||
|
||||
type ContentGroupSnapshot = {
|
||||
id: string;
|
||||
contentType: string | undefined;
|
||||
visible: boolean;
|
||||
/**
|
||||
* The group's client rect in unscaled page coordinates.
|
||||
*/
|
||||
rect: { x: number; y: number; width: number; height: number };
|
||||
/**
|
||||
* The names of the group's visible child nodes.
|
||||
*/
|
||||
visibleChildren: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Snapshot every content group on a page, straight from Konva.
|
||||
*/
|
||||
export const getContentGroupsForPage = async (root: Page, pageNumber = 1): Promise<ContentGroupSnapshot[]> => {
|
||||
await getPageCanvas(root).waitFor({ state: 'visible' });
|
||||
|
||||
return await root.evaluate(
|
||||
({ pageNumber }) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const konva: typeof Konva = (window as unknown as { Konva: typeof Konva }).Konva;
|
||||
|
||||
const stage = konva.stages.find((currentStage) => currentStage.attrs.id === `page-${pageNumber}`);
|
||||
|
||||
if (!stage) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const scale = stage.scaleX();
|
||||
|
||||
return stage.find('.content-group').map((node) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const group = node as Konva.Group;
|
||||
const rect = group.getClientRect({ skipStroke: true, skipShadow: true });
|
||||
|
||||
return {
|
||||
id: group.id(),
|
||||
contentType: group.getAttr('contentType'),
|
||||
visible: group.isVisible(),
|
||||
rect: { x: rect.x / scale, y: rect.y / scale, width: rect.width / scale, height: rect.height / scale },
|
||||
visibleChildren: group
|
||||
.getChildren()
|
||||
.filter((child) => child.isVisible())
|
||||
.map((child) => child.name()),
|
||||
};
|
||||
});
|
||||
},
|
||||
{ pageNumber },
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The unscaled page size of a page's Konva stage.
|
||||
*/
|
||||
export const getPageSize = async (root: Page, pageNumber = 1) => {
|
||||
await getPageCanvas(root).waitFor({ state: 'visible' });
|
||||
|
||||
return await root.evaluate(
|
||||
({ pageNumber }) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const konva: typeof Konva = (window as unknown as { Konva: typeof Konva }).Konva;
|
||||
|
||||
const stage = konva.stages.find((currentStage) => currentStage.attrs.id === `page-${pageNumber}`);
|
||||
|
||||
if (!stage) {
|
||||
throw new Error(`Stage for page ${pageNumber} not found`);
|
||||
}
|
||||
|
||||
return { width: stage.width() / stage.scaleX(), height: stage.height() / stage.scaleY(), scale: stage.scaleX() };
|
||||
},
|
||||
{ pageNumber },
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Wait until exactly the given contents are selected, i.e. the transformer is
|
||||
* attached to them and their anchors can be grabbed.
|
||||
*/
|
||||
export const waitForContentSelection = async (root: Page, contentIds: string[], pageNumber = 1) => {
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
await root.evaluate(
|
||||
({ pageNumber }) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const konva: typeof Konva = (window as unknown as { Konva: typeof Konva }).Konva;
|
||||
|
||||
const stage = konva.stages.find((currentStage) => currentStage.attrs.id === `page-${pageNumber}`);
|
||||
|
||||
if (!stage) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return stage
|
||||
.find('Transformer')
|
||||
.flatMap((node) =>
|
||||
node instanceof konva.Transformer ? node.nodes().map((selected) => selected.id()) : [],
|
||||
)
|
||||
.sort();
|
||||
},
|
||||
{ pageNumber },
|
||||
),
|
||||
)
|
||||
.toEqual([...contentIds].sort());
|
||||
};
|
||||
|
||||
/**
|
||||
* Open the settings dialog and set the external ID used to find the envelope
|
||||
* in the database.
|
||||
*/
|
||||
export const getContentActionButton = (root: Page, title: string): Locator =>
|
||||
root.getByTestId('envelope-canvas-action-bar').locator(`button[title="${title}"]`);
|
||||
|
||||
/**
|
||||
* Upload through the action bar. Clicking Upload / Replace opens the upload
|
||||
* dialog, which immediately opens the native picker; the file is set on that.
|
||||
*
|
||||
* Returns as soon as the file is handed over. Follow with
|
||||
* `waitForUploadToLand` for a successful upload, or check the dialog's error
|
||||
* text for a rejected one.
|
||||
*/
|
||||
export const uploadImage = async (root: Page, file: TestImageFile) => {
|
||||
const fileChooser = root.waitForEvent('filechooser');
|
||||
|
||||
await getContentActionButton(root, 'Upload image').or(getContentActionButton(root, 'Replace image')).click();
|
||||
|
||||
await (await fileChooser).setFiles({ name: file.name, mimeType: file.mimeType, buffer: file.buffer });
|
||||
};
|
||||
|
||||
/**
|
||||
* Wait for a successful upload's dialog to close.
|
||||
*/
|
||||
export const waitForUploadToLand = async (root: Page) => {
|
||||
await expect(root.getByTestId('content-image-uploading')).toHaveCount(0);
|
||||
await expect(root.getByRole('dialog')).toHaveCount(0);
|
||||
};
|
||||
|
||||
/**
|
||||
* Wait for the editor's debounced autosave to flush.
|
||||
*/
|
||||
export const waitForContentsAutosave = async (surface: TEnvelopeEditorSurface) => {
|
||||
if (surface.isEmbedded) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Autosave debounces for 2 seconds.
|
||||
await surface.root.waitForTimeout(2500);
|
||||
};
|
||||
@@ -1,151 +0,0 @@
|
||||
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 }]);
|
||||
});
|
||||
@@ -1,144 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,11 +1,6 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
|
||||
import {
|
||||
DEFAULT_ENVELOPE_CONTENT_COUNT,
|
||||
DEFAULT_ENVELOPE_CONTENT_IMAGE_COUNT,
|
||||
DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
|
||||
FREE_PLAN_LIMITS,
|
||||
} from './constants';
|
||||
import { DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT, FREE_PLAN_LIMITS } from './constants';
|
||||
import type { TLimitsResponseSchema } from './schema';
|
||||
import { ZLimitsResponseSchema } from './schema';
|
||||
|
||||
@@ -38,8 +33,6 @@ export const getLimits = async ({ headers, teamId }: GetLimitsOptions) => {
|
||||
quota: FREE_PLAN_LIMITS,
|
||||
remaining: FREE_PLAN_LIMITS,
|
||||
maximumEnvelopeItemCount: DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
|
||||
maximumEnvelopeContentCount: DEFAULT_ENVELOPE_CONTENT_COUNT,
|
||||
maximumEnvelopeContentImageCount: DEFAULT_ENVELOPE_CONTENT_IMAGE_COUNT,
|
||||
} satisfies TLimitsResponseSchema;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -35,18 +35,3 @@ export const DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT = 5;
|
||||
* 0 = Unlimited recipients.
|
||||
*/
|
||||
export const DEFAULT_RECIPIENT_COUNT = 20;
|
||||
|
||||
/**
|
||||
* Used as an initial value for the frontend before values are loaded from the server.
|
||||
*
|
||||
* 0 = Unlimited contents, matching the value existing organisation claims
|
||||
* were backfilled with.
|
||||
*/
|
||||
export const DEFAULT_ENVELOPE_CONTENT_COUNT = 0;
|
||||
|
||||
/**
|
||||
* Used as an initial value for the frontend before values are loaded from the server.
|
||||
*
|
||||
* 0 = Unlimited image contents.
|
||||
*/
|
||||
export const DEFAULT_ENVELOPE_CONTENT_IMAGE_COUNT = 0;
|
||||
|
||||
@@ -3,12 +3,7 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'rea
|
||||
import { isDeepEqual } from 'remeda';
|
||||
|
||||
import { getLimits } from '../client';
|
||||
import {
|
||||
DEFAULT_ENVELOPE_CONTENT_COUNT,
|
||||
DEFAULT_ENVELOPE_CONTENT_IMAGE_COUNT,
|
||||
DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
|
||||
FREE_PLAN_LIMITS,
|
||||
} from '../constants';
|
||||
import { DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT, FREE_PLAN_LIMITS } from '../constants';
|
||||
import type { TLimitsResponseSchema } from '../schema';
|
||||
|
||||
export type LimitsContextValue = TLimitsResponseSchema & { refreshLimits: () => Promise<void> };
|
||||
@@ -42,8 +37,6 @@ export const LimitsProvider = ({
|
||||
quota: FREE_PLAN_LIMITS,
|
||||
remaining: FREE_PLAN_LIMITS,
|
||||
maximumEnvelopeItemCount: DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
|
||||
maximumEnvelopeContentCount: DEFAULT_ENVELOPE_CONTENT_COUNT,
|
||||
maximumEnvelopeContentImageCount: DEFAULT_ENVELOPE_CONTENT_IMAGE_COUNT,
|
||||
},
|
||||
disableLimitsFetch,
|
||||
teamId,
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
DEFAULT_ENVELOPE_CONTENT_COUNT,
|
||||
DEFAULT_ENVELOPE_CONTENT_IMAGE_COUNT,
|
||||
DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
|
||||
} from './constants';
|
||||
import { DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT } from './constants';
|
||||
|
||||
// Not proud of the below but it's a way to deal with Infinity when returning JSON.
|
||||
export const ZLimitsSchema = z.object({
|
||||
@@ -28,8 +24,6 @@ export const ZLimitsResponseSchema = z.object({
|
||||
quota: ZLimitsSchema,
|
||||
remaining: ZLimitsSchema,
|
||||
maximumEnvelopeItemCount: z.number().optional().default(DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT),
|
||||
maximumEnvelopeContentCount: z.number().optional().default(DEFAULT_ENVELOPE_CONTENT_COUNT),
|
||||
maximumEnvelopeContentImageCount: z.number().optional().default(DEFAULT_ENVELOPE_CONTENT_IMAGE_COUNT),
|
||||
});
|
||||
|
||||
export type TLimitsResponseSchema = z.infer<typeof ZLimitsResponseSchema>;
|
||||
|
||||
@@ -42,18 +42,13 @@ export const getServerLimits = async ({ userId, teamId }: GetServerLimitsOptions
|
||||
const remaining = structuredClone(FREE_PLAN_LIMITS);
|
||||
|
||||
const subscription = organisation.subscription;
|
||||
|
||||
const baseValues = {
|
||||
maximumEnvelopeItemCount: organisation.organisationClaim.envelopeItemCount,
|
||||
maximumEnvelopeContentCount: organisation.organisationClaim.envelopeContentCount,
|
||||
maximumEnvelopeContentImageCount: organisation.organisationClaim.envelopeContentImageCount,
|
||||
};
|
||||
const maximumEnvelopeItemCount = organisation.organisationClaim.envelopeItemCount;
|
||||
|
||||
if (!IS_BILLING_ENABLED()) {
|
||||
return {
|
||||
quota: SELFHOSTED_PLAN_LIMITS,
|
||||
remaining: SELFHOSTED_PLAN_LIMITS,
|
||||
...baseValues,
|
||||
maximumEnvelopeItemCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,7 +57,7 @@ export const getServerLimits = async ({ userId, teamId }: GetServerLimitsOptions
|
||||
return {
|
||||
quota: PAID_PLAN_LIMITS,
|
||||
remaining: PAID_PLAN_LIMITS,
|
||||
...baseValues,
|
||||
maximumEnvelopeItemCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -71,7 +66,7 @@ export const getServerLimits = async ({ userId, teamId }: GetServerLimitsOptions
|
||||
return {
|
||||
quota: INACTIVE_PLAN_LIMITS,
|
||||
remaining: INACTIVE_PLAN_LIMITS,
|
||||
...baseValues,
|
||||
maximumEnvelopeItemCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -80,7 +75,7 @@ export const getServerLimits = async ({ userId, teamId }: GetServerLimitsOptions
|
||||
return {
|
||||
quota: INACTIVE_PLAN_LIMITS,
|
||||
remaining: INACTIVE_PLAN_LIMITS,
|
||||
...baseValues,
|
||||
maximumEnvelopeItemCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -90,7 +85,7 @@ export const getServerLimits = async ({ userId, teamId }: GetServerLimitsOptions
|
||||
return {
|
||||
quota: PAID_PLAN_LIMITS,
|
||||
remaining: PAID_PLAN_LIMITS,
|
||||
...baseValues,
|
||||
maximumEnvelopeItemCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -128,6 +123,6 @@ export const getServerLimits = async ({ userId, teamId }: GetServerLimitsOptions
|
||||
return {
|
||||
quota,
|
||||
remaining,
|
||||
...baseValues,
|
||||
maximumEnvelopeItemCount,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
import type { EnvelopeContent } from '@prisma/client';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { ContentImageStatus, ContentImageStatusMap } from '../../universal/content-renderer/content-image-status';
|
||||
import type { ContentImageMap, ContentImageSource } from '../../universal/content-renderer/content-renderer';
|
||||
import { getDataContentIds } from '../../utils/envelope-content';
|
||||
import { getDataContentImageUrl } from '../../utils/envelope-download';
|
||||
import { type ContentImageDetails, decodeLocalContentImage, loadContentImage } from '../load-content-image';
|
||||
import { useLatestRef } from './use-latest-ref';
|
||||
|
||||
export type ContentImages = {
|
||||
/**
|
||||
* The loaded images by data content ID.
|
||||
*/
|
||||
images: ContentImageMap;
|
||||
|
||||
/**
|
||||
* The file details of the loaded images by data content ID, for display.
|
||||
*/
|
||||
details: ReadonlyMap<string, ContentImageDetails>;
|
||||
|
||||
/**
|
||||
* The loading status by data content ID. IDs which have not started
|
||||
* loading are absent.
|
||||
*/
|
||||
loadingStatuses: ContentImageStatusMap;
|
||||
|
||||
/**
|
||||
* The images being loaded.
|
||||
*
|
||||
* An image belonging to a content is not always requested, e.g. nothing is
|
||||
* loaded for a sealed envelope since its contents are imprinted on the PDF.
|
||||
* Consumers need this to tell an image which is still on its way from one
|
||||
* which is never coming.
|
||||
*/
|
||||
requestedDataContentIds: ReadonlySet<string>;
|
||||
|
||||
/**
|
||||
* Insert an already decoded image, e.g. an upload decoded locally, so it is
|
||||
* available immediately without a request.
|
||||
*/
|
||||
setImage: (dataContentId: string, image: ContentImageSource, details: ContentImageDetails) => void;
|
||||
};
|
||||
|
||||
type UseContentImagesOptions = {
|
||||
envelopeId: string;
|
||||
|
||||
/**
|
||||
* The envelope item currently being rendered. Only its images are loaded.
|
||||
*/
|
||||
envelopeItemId: string | null;
|
||||
|
||||
/**
|
||||
* Data is only used for embeds, where the image has yet to be uploaded since
|
||||
* embeds are all built locally before being sent at the end of the flow.
|
||||
*/
|
||||
contents: (Pick<EnvelopeContent, 'envelopeItemId' | 'dataContentId'> & { data?: Blob })[];
|
||||
token: string | undefined;
|
||||
presignToken?: string | undefined;
|
||||
};
|
||||
|
||||
type ContentImagesState = {
|
||||
images: Map<string, ContentImageSource>;
|
||||
details: Map<string, ContentImageDetails>;
|
||||
loadingStatuses: Map<string, ContentImageStatus>;
|
||||
};
|
||||
|
||||
const createEmptyState = (): ContentImagesState => ({
|
||||
images: new Map(),
|
||||
details: new Map(),
|
||||
loadingStatuses: new Map(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Load and cache the images of the image contents of the envelope item being
|
||||
* rendered, starting as soon as the contents are known so they are ready by
|
||||
* the time the pages render.
|
||||
*
|
||||
* Images are kept for the lifetime of the provider, including across item
|
||||
* switches, so navigating back never refetches or re-decodes. They are only
|
||||
* closed on unmount, since a Konva node may still draw an image until its
|
||||
* stage is destroyed and drawing a closed bitmap throws.
|
||||
*/
|
||||
export const useContentImages = ({
|
||||
envelopeId,
|
||||
envelopeItemId,
|
||||
contents,
|
||||
token,
|
||||
presignToken,
|
||||
}: UseContentImagesOptions): ContentImages => {
|
||||
const [state, setState] = useState<ContentImagesState>(createEmptyState);
|
||||
|
||||
// Read when a load starts, so the effect does not re-run on every content
|
||||
// change. A content always holds its file before its ID is requested.
|
||||
const contentsRef = useLatestRef(contents);
|
||||
|
||||
// Mirrors of the state for the effects, so starting loads does not depend
|
||||
// on the state itself (which would re-run the effect on every update) and
|
||||
// unmounting can close whatever was loaded.
|
||||
const loadingStatusesRef = useLatestRef(state.loadingStatuses);
|
||||
const imagesRef = useLatestRef(state.images);
|
||||
|
||||
// Identifies the current mount. Bumped on unmount so a load started under a
|
||||
// previous mount discards its result rather than writing into state which
|
||||
// has since been cleared. A strict mode remount counts as a new mount.
|
||||
const mountIdRef = useRef(0);
|
||||
|
||||
const requestedDataContentIds = useMemo(() => {
|
||||
const itemContents = contents.filter((content) => content.envelopeItemId === envelopeItemId);
|
||||
|
||||
return getDataContentIds(itemContents).sort();
|
||||
}, [contents, envelopeItemId]);
|
||||
|
||||
// A stable key so the effect only re-runs when the set of IDs changes.
|
||||
const requestedDataContentIdsKey = requestedDataContentIds.join(',');
|
||||
|
||||
useEffect(() => {
|
||||
const unrequestedDataContentIds = requestedDataContentIds.filter(
|
||||
(dataContentId) => !loadingStatusesRef.current.has(dataContentId),
|
||||
);
|
||||
|
||||
if (unrequestedDataContentIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mountId = mountIdRef.current;
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
loadingStatuses: setDataContentStatuses(prev.loadingStatuses, unrequestedDataContentIds, 'loading'),
|
||||
}));
|
||||
|
||||
for (const dataContentId of unrequestedDataContentIds) {
|
||||
const localImage = contentsRef.current.find(
|
||||
(content) => content.dataContentId === dataContentId && content.data,
|
||||
)?.data;
|
||||
|
||||
const loadImage = localImage
|
||||
? decodeLocalContentImage(localImage)
|
||||
: loadContentImage(getDataContentImageUrl({ envelopeId, dataContentId, token, presignToken }));
|
||||
|
||||
void loadImage
|
||||
.then(({ image, details }) => {
|
||||
if (mountIdRef.current !== mountId) {
|
||||
closeImage(image);
|
||||
return;
|
||||
}
|
||||
|
||||
setState((prev) => ({
|
||||
images: new Map(prev.images).set(dataContentId, image),
|
||||
details: new Map(prev.details).set(dataContentId, details),
|
||||
loadingStatuses: setDataContentStatuses(prev.loadingStatuses, [dataContentId], 'loaded'),
|
||||
}));
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (mountIdRef.current !== mountId) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`Failed to load content image ${dataContentId}`, error);
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
loadingStatuses: setDataContentStatuses(prev.loadingStatuses, [dataContentId], 'failed'),
|
||||
}));
|
||||
});
|
||||
}
|
||||
// The IDs are tracked via their key so an equal set does not re-run.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [envelopeId, requestedDataContentIdsKey, token, presignToken]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
mountIdRef.current += 1;
|
||||
|
||||
for (const image of imagesRef.current.values()) {
|
||||
closeImage(image);
|
||||
}
|
||||
|
||||
// Also reset the mirrors so a strict mode remount reloads everything
|
||||
// rather than reusing closed images.
|
||||
imagesRef.current = new Map();
|
||||
loadingStatusesRef.current = new Map();
|
||||
|
||||
setState(createEmptyState());
|
||||
};
|
||||
}, []);
|
||||
|
||||
const setImage = useCallback((dataContentId: string, image: ContentImageSource, details: ContentImageDetails) => {
|
||||
setState((prev) => ({
|
||||
images: new Map(prev.images).set(dataContentId, image),
|
||||
details: new Map(prev.details).set(dataContentId, details),
|
||||
loadingStatuses: setDataContentStatuses(prev.loadingStatuses, [dataContentId], 'loaded'),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
images: state.images,
|
||||
details: state.details,
|
||||
loadingStatuses: state.loadingStatuses,
|
||||
|
||||
/**
|
||||
* The images being loaded, so consumers can tell an image which is
|
||||
* still on its way from one which is never coming.
|
||||
*/
|
||||
requestedDataContentIds: new Set(requestedDataContentIds),
|
||||
setImage,
|
||||
}),
|
||||
[state, setImage, requestedDataContentIdsKey],
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* A copy of the statuses with the given data contents set to the given
|
||||
* status. The original is left untouched so it can be used as React state.
|
||||
*/
|
||||
const setDataContentStatuses = (
|
||||
loadingStatuses: Map<string, ContentImageStatus>,
|
||||
dataContentIds: string[],
|
||||
status: ContentImageStatus,
|
||||
) => {
|
||||
const remappedLoadingStatuses = new Map(loadingStatuses);
|
||||
|
||||
for (const dataContentId of dataContentIds) {
|
||||
remappedLoadingStatuses.set(dataContentId, status);
|
||||
}
|
||||
|
||||
return remappedLoadingStatuses;
|
||||
};
|
||||
|
||||
/**
|
||||
* Release the memory of a decoded bitmap. Image elements have nothing to
|
||||
* release.
|
||||
*/
|
||||
const closeImage = (image: ContentImageSource) => {
|
||||
if ('close' in image) {
|
||||
image.close();
|
||||
}
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user