diff --git a/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx b/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx index 0cd5e3c4c..5b7fa08c6 100644 --- a/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx +++ b/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx @@ -4,6 +4,7 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { AppError } from '@documenso/lib/errors/app-error'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; +import { getContentsMissingImages, resolveEnvelopeContentLimits } from '@documenso/lib/utils/envelope-content'; import { hasOverlappingFields } from '@documenso/lib/utils/fields-overlap'; import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients'; import { zEmail } from '@documenso/lib/utils/zod'; @@ -31,7 +32,7 @@ import { Textarea } from '@documenso/ui/primitives/textarea'; import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { zodResolver } from '@hookform/resolvers/zod'; -import { Trans, useLingui } from '@lingui/react/macro'; +import { Plural, Trans, useLingui } from '@lingui/react/macro'; import { DocumentDistributionMethod, DocumentStatus, EnvelopeType } from '@prisma/client'; import { AnimatePresence, motion } from 'framer-motion'; import { AlertTriangleIcon, InfoIcon } from 'lucide-react'; @@ -162,6 +163,25 @@ export const EnvelopeDistributeDialog = ({ [envelope.fields], ); + /** + * Image related contents without an image render nothing once sent. + */ + const contentsMissingImages = useMemo(() => getContentsMissingImages(envelope.contents), [envelope.contents]); + + /** + * An envelope can hold more contents than the organisation's plan allows, + * e.g. after the plan was lowered or a template was copied, so this is + * reported here rather than letting the send fail. + */ + const contentLimits = useMemo( + () => + resolveEnvelopeContentLimits( + envelope.contents.map((content) => content.contentMeta.type), + organisation.organisationClaim, + ), + [envelope.contents, organisation.organisationClaim], + ); + const invalidEnvelopeCode = useMemo(() => { if (recipientsMissingSignatureFields.length > 0) { return 'MISSING_SIGNATURES'; @@ -175,8 +195,26 @@ export const EnvelopeDistributeDialog = ({ return 'MISSING_REQUIRED_EMAIL'; } + if (contentsMissingImages.length > 0) { + return 'MISSING_CONTENT_IMAGES'; + } + + if (contentLimits.isContentLimitExceeded) { + return 'ENVELOPE_CONTENT_LIMIT_EXCEEDED'; + } + + if (contentLimits.isImageLimitExceeded) { + return 'ENVELOPE_CONTENT_IMAGE_LIMIT_EXCEEDED'; + } + return null; - }, [envelope.recipients, recipientsMissingRequiredEmail, recipientsMissingSignatureFields]); + }, [ + envelope.recipients, + recipientsMissingRequiredEmail, + recipientsMissingSignatureFields, + contentsMissingImages, + contentLimits, + ]); const onFormSubmit = async ({ meta }: TEnvelopeDistributeFormSchema) => { try { @@ -520,6 +558,33 @@ export const EnvelopeDistributeDialog = ({ )) + .with('MISSING_CONTENT_IMAGES', () => ( + + + + )) + .with('ENVELOPE_CONTENT_LIMIT_EXCEEDED', () => ( + + + + )) + .with('ENVELOPE_CONTENT_IMAGE_LIMIT_EXCEEDED', () => ( + + + + )) .exhaustive()} diff --git a/apps/remix/app/components/dialogs/envelope-duplicate-dialog.tsx b/apps/remix/app/components/dialogs/envelope-duplicate-dialog.tsx index 893fbf6e9..580b2ee17 100644 --- a/apps/remix/app/components/dialogs/envelope-duplicate-dialog.tsx +++ b/apps/remix/app/components/dialogs/envelope-duplicate-dialog.tsx @@ -44,6 +44,7 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E defaultValues: { includeRecipients: true, includeFields: true, + includeContents: true, }, }); @@ -67,13 +68,14 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E }); const onDuplicate = async () => { - const { includeRecipients, includeFields } = form.getValues(); + const { includeRecipients, includeFields, includeContents } = form.getValues(); try { await duplicateEnvelope({ envelopeId, includeRecipients, includeFields: includeRecipients && includeFields, + includeContents, }); } catch { toast({ @@ -159,6 +161,23 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E )} /> + + ( +
+ field.onChange(checked === true)} + /> + +
+ )} + /> diff --git a/apps/remix/app/components/dialogs/envelope-item-edit-dialog.tsx b/apps/remix/app/components/dialogs/envelope-item-edit-dialog.tsx index a7bb62eb2..a5a61fe63 100644 --- a/apps/remix/app/components/dialogs/envelope-item-edit-dialog.tsx +++ b/apps/remix/app/components/dialogs/envelope-item-edit-dialog.tsx @@ -1,6 +1,6 @@ import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; -import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions'; +import { formatFileSize, megabytesToBytes } from '@documenso/lib/universal/unit-convertions'; import { trpc } from '@documenso/trpc/react'; import { ZDocumentTitleSchema } from '@documenso/trpc/server/document-router/schema'; import type { TReplaceEnvelopeItemPdfPayload } from '@documenso/trpc/server/envelope-router/replace-envelope-item-pdf.types'; @@ -195,18 +195,6 @@ export const EnvelopeItemEditDialog = ({ } }, [isOpen, form, envelopeItem.title]); - const formatFileSize = (bytes: number) => { - if (bytes < 1024) { - return `${bytes} B`; - } - - if (bytes < 1024 * 1024) { - return `${(bytes / 1024).toFixed(1)} KB`; - } - - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; - }; - return ( !form.formState.isSubmitting && setIsOpen(value)}> e.stopPropagation()} asChild> diff --git a/apps/remix/app/components/dialogs/template-use-dialog.tsx b/apps/remix/app/components/dialogs/template-use-dialog.tsx index a78f4179a..9b6db5a08 100644 --- a/apps/remix/app/components/dialogs/template-use-dialog.tsx +++ b/apps/remix/app/components/dialogs/template-use-dialog.tsx @@ -6,9 +6,9 @@ import { import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc'; import { AppError } from '@documenso/lib/errors/app-error'; import { type TRecipientLite, ZRecipientEmailSchema } from '@documenso/lib/types/recipient'; -import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import { trpc } from '@documenso/trpc/react'; import { DOCUMENT_TITLE_MAX_LENGTH } from '@documenso/trpc/server/document-router/schema'; +import type { TUseEnvelopePayload } from '@documenso/trpc/server/envelope-router/use-envelope.types'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { Checkbox } from '@documenso/ui/primitives/checkbox'; @@ -66,6 +66,7 @@ const isUploadedFileNameUsable = (file?: File): file is File => { const ZAddRecipientsForNewDocumentSchema = z .object({ distributeDocument: z.boolean(), + includeContents: z.boolean().default(true), useCustomDocument: z.boolean().default(false), documentNameSource: z.enum([ DOCUMENT_NAME_SOURCE.TEMPLATE, @@ -103,7 +104,6 @@ type TAddRecipientsForNewDocumentSchema = z.infer { return { distributeDocument: false, + includeContents: true, useCustomDocument: false, documentNameSource: DOCUMENT_NAME_SOURCE.TEMPLATE, customDocumentName: '', @@ -180,7 +180,7 @@ export function TemplateUseDialog({ name: 'customDocumentData', }); - const { mutateAsync: createDocumentFromTemplate } = trpc.template.createDocumentFromTemplate.useMutation(); + const { mutateAsync: createDocumentFromTemplate } = trpc.envelope.use.useMutation(); /** * Track the most recently uploaded file so its name can be used as the document name. @@ -217,24 +217,29 @@ export function TemplateUseDialog({ (item): item is typeof item & { data: File } => item.data !== undefined, ); - const customDocumentData = await Promise.all( - customFilesToUpload.map(async (item) => { - const customDocumentData = await putPdfFile(item.data); - - return { - documentDataId: customDocumentData.id, - envelopeItemId: item.envelopeItemId, - }; - }), - ); - - const { envelopeId } = await createDocumentFromTemplate({ - templateId, + // The files are sent alongside the payload and mapped to their envelope + // items by index, which is robust against duplicate file names. + const payload: TUseEnvelopePayload = { + envelopeId, recipients: data.recipients, distributeDocument: data.distributeDocument, - customDocumentData, + includeContents: data.includeContents, + customDocumentData: customFilesToUpload.map((item, index) => ({ + identifier: index, + envelopeItemId: item.envelopeItemId, + })), ...(documentTitle ? { override: { title: documentTitle } } : {}), - }); + }; + + const formData = new FormData(); + + formData.append('payload', JSON.stringify(payload)); + + for (const item of customFilesToUpload) { + formData.append('files', item.data); + } + + const { id: createdEnvelopeId } = await createDocumentFromTemplate(formData); toast({ title: _(msg`Document created`), @@ -242,7 +247,7 @@ export function TemplateUseDialog({ duration: 5000, }); - let documentPath = `${documentRootPath}/${envelopeId}`; + let documentPath = `${documentRootPath}/${createdEnvelopeId}`; if (data.distributeDocument && documentDistributionMethod === DocumentDistributionMethod.NONE) { documentPath += '?action=view-signing-links'; @@ -464,6 +469,46 @@ export function TemplateUseDialog({ )} + ( + +
+ field.onChange(checked === true)} + /> + +
+
+ )} + /> + { - 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'], diff --git a/apps/remix/app/components/forms/editor/editor-content-generic-field-forms.tsx b/apps/remix/app/components/forms/editor/editor-content-generic-field-forms.tsx new file mode 100644 index 000000000..fecd61e36 --- /dev/null +++ b/apps/remix/app/components/forms/editor/editor-content-generic-field-forms.tsx @@ -0,0 +1,175 @@ +import { + CONTENT_MAX_STROKE_WIDTH, + CONTENT_MIN_STROKE_WIDTH, + DEFAULT_CONTENT_TEXT_COLOR, +} from '@documenso/lib/types/envelope-content-meta'; +import { ColorPicker } from '@documenso/ui/primitives/color-picker'; +import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select'; +import { Slider } from '@documenso/ui/primitives/slider'; +import { Trans, useLingui } from '@lingui/react/macro'; +import type { Control } from 'react-hook-form'; + +// Can't seem to get the non-any type to work with correct types. +// Eg Control<{ fontSize?: number } doesn't seem to work when there are required items. +// biome-ignore lint/suspicious/noExplicitAny: See above +type FormControlType = Control; + +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 ( + ( + + {label ?? Color} + +
+ field.onChange(color)} + /> + + {field.value ?? defaultColor} +
+
+ +
+ )} + /> + ); +}; + +export const EditorContentStrokeWidthField = ({ formControl, className }: GenericContentFieldProps) => { + const { t } = useLingui(); + + return ( + ( + + + Thickness + + + { + field.onChange(Number(e.target.value)); + }} + /> + + + + )} + /> + ); +}; + +export const EditorContentStrokeStyleField = ({ formControl, className }: GenericContentFieldProps) => { + const { t } = useLingui(); + + return ( + ( + + + Style + + + + + + + )} + /> + ); +}; + +/** + * 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 ( + { + const percentage = Math.round((field.value ?? 1) * 100); + + return ( + + + {label ?? Opacity} + {percentage}% + + + field.onChange(value / 100)} + /> + + + + ); + }} + /> + ); +}; diff --git a/apps/remix/app/components/forms/editor/editor-content-highlight-form.tsx b/apps/remix/app/components/forms/editor/editor-content-highlight-form.tsx new file mode 100644 index 000000000..c4350f8f0 --- /dev/null +++ b/apps/remix/app/components/forms/editor/editor-content-highlight-form.tsx @@ -0,0 +1,31 @@ +import { DEFAULT_CONTENT_HIGHLIGHT_COLOR } from '@documenso/lib/types/envelope-content-meta'; +import { + type TContentHighlightFormSchema, + useContentSettingsForm, +} from '~/components/general/envelope-editor/content-settings-form-provider'; + +import { EditorContentColorField, EditorContentOpacityField } from './editor-content-generic-field-forms'; + +/** + * Settings form for highlight contents, bound to the selected content's + * settings form. Only the presentational settings are editable here; + * geometry (page, position and size) is managed on the canvas. + */ +export const EditorContentHighlightForm = () => { + const { form, isReady } = useContentSettingsForm(); + + // Mount the inputs only once the form holds this content's values. + if (!isReady) { + return null; + } + + return ( +
+
+ + + +
+
+ ); +}; diff --git a/apps/remix/app/components/forms/editor/editor-content-image-settings.tsx b/apps/remix/app/components/forms/editor/editor-content-image-settings.tsx new file mode 100644 index 000000000..f6268a714 --- /dev/null +++ b/apps/remix/app/components/forms/editor/editor-content-image-settings.tsx @@ -0,0 +1,126 @@ +import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; +import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider'; +import { + APP_CONTENT_IMAGE_MIME_TYPES, + APP_CONTENT_IMAGE_UPLOAD_SIZE_LIMIT, +} from '@documenso/lib/constants/envelope-content'; +import { formatFileSize, megabytesToBytes } from '@documenso/lib/universal/unit-convertions'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { FileImageIcon, ImageUpIcon, RefreshCwIcon, TrashIcon } from 'lucide-react'; +import { useDropzone } from 'react-dropzone'; +import { ContentImageUploadDialog } from '~/components/general/envelope-editor/content-image-upload-dialog'; + +type EditorContentImageSettingsProps = { + formId: string; + dataContentId?: string | null; +}; + +/** + * Settings for image contents: the image itself. + * + * Uploads go through the shared upload dialog. This panel only offers the + * ways to start one (drop, click, replace) and to remove the current image. + * Geometry (page, position, size and rotation) is managed on the canvas. + */ +export const EditorContentImageSettings = ({ formId, dataContentId }: EditorContentImageSettingsProps) => { + const { t } = useLingui(); + const { editorContents } = useCurrentEnvelopeEditor(); + const { contentImages } = useCurrentEnvelopeRender(); + + const image = dataContentId ? contentImages.images.get(dataContentId) : undefined; + const details = dataContentId ? contentImages.details.get(dataContentId) : undefined; + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + accept: Object.fromEntries(APP_CONTENT_IMAGE_MIME_TYPES.map((mimeType) => [mimeType, []])), + multiple: false, + maxSize: megabytesToBytes(APP_CONTENT_IMAGE_UPLOAD_SIZE_LIMIT), + // A dropped file goes straight to the dialog; a click opens the picker + // through it instead of the dropzone's own input. + noClick: true, + onDrop: (acceptedFiles) => { + const [file] = acceptedFiles; + + if (file) { + void ContentImageUploadDialog.call({ formId, file }); + } + }, + }); + + if (dataContentId) { + return ( +
+
+ +
+ +
+

+ Image +

+ +

+ {[details ? formatFileSize(details.fileSize) : null, image ? `${image.width} * ${image.height}` : null] + .filter((part) => part !== null) + .join(' ยท ')} +

+
+ +
+ + + +
+
+ ); + } + + return ( +
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', + )} + > + + +

+ Click to upload or drag and drop +

+ +

+ PNG, JPEG or WebP up to {APP_CONTENT_IMAGE_UPLOAD_SIZE_LIMIT} MB +

+
+ ); +}; diff --git a/apps/remix/app/components/forms/editor/editor-content-line-form.tsx b/apps/remix/app/components/forms/editor/editor-content-line-form.tsx new file mode 100644 index 000000000..8b5e407f3 --- /dev/null +++ b/apps/remix/app/components/forms/editor/editor-content-line-form.tsx @@ -0,0 +1,43 @@ +import { DEFAULT_CONTENT_STROKE_COLOR } from '@documenso/lib/types/envelope-content-meta'; +import { + type TContentLineFormSchema, + useContentSettingsForm, +} from '~/components/general/envelope-editor/content-settings-form-provider'; + +import { + EditorContentColorField, + EditorContentStrokeStyleField, + EditorContentStrokeWidthField, +} from './editor-content-generic-field-forms'; + +/** + * Settings form for line contents, bound to the selected content's settings + * form. Only the presentational settings are editable here; geometry (page + * and endpoints) is managed on the canvas. + */ +export const EditorContentLineForm = () => { + const { form, isReady } = useContentSettingsForm(); + + // Mount the inputs only once the form holds this content's values. + if (!isReady) { + return null; + } + + return ( +
+
+
+ + + +
+ + +
+
+ ); +}; diff --git a/apps/remix/app/components/forms/editor/editor-content-shape-form.tsx b/apps/remix/app/components/forms/editor/editor-content-shape-form.tsx new file mode 100644 index 000000000..fb6a5ae9a --- /dev/null +++ b/apps/remix/app/components/forms/editor/editor-content-shape-form.tsx @@ -0,0 +1,105 @@ +import { + DEFAULT_CONTENT_STROKE_COLOR, + EnvelopeContentShapeType, + EnvelopeContentType, +} from '@documenso/lib/types/envelope-content-meta'; +import { FormControl, FormItem, FormLabel } from '@documenso/ui/primitives/form/form'; +import { Switch } from '@documenso/ui/primitives/switch'; +import { Trans } from '@lingui/react/macro'; +import { useWatch } from 'react-hook-form'; +import { match } from 'ts-pattern'; +import { + type TContentShapeFormSchema, + useContentSettingsForm, +} from '~/components/general/envelope-editor/content-settings-form-provider'; + +import { + EditorContentColorField, + EditorContentOpacityField, + EditorContentStrokeStyleField, + EditorContentStrokeWidthField, +} from './editor-content-generic-field-forms'; + +/** + * The fill color used when the fill is first enabled. + * + * Todo: Contents + */ +export const DEFAULT_ENABLED_FILL_COLOR = '#e5e7eb'; + +/** + * Settings form for shape contents, bound to the selected content's settings + * form, with the fields for the content's shape. Only the presentational + * settings are editable here; geometry (page, position and size) is managed + * on the canvas. + */ +export const EditorContentShapeForm = () => { + const { form, content, isReady } = useContentSettingsForm(); + + 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 ( +
+ {match(content.contentMeta.shape) + .with(EnvelopeContentShapeType.RECTANGLE, () => ( +
+
+ + + +
+ + Border Color} + defaultColor={DEFAULT_CONTENT_STROKE_COLOR} + /> + + + + Fill + + + + + + + {hasFill && ( + <> + Fill Color} + defaultColor={DEFAULT_ENABLED_FILL_COLOR} + /> + + Fill Opacity} + /> + + )} +
+ )) + .exhaustive()} +
+ ); +}; diff --git a/apps/remix/app/components/forms/editor/editor-content-text-form.tsx b/apps/remix/app/components/forms/editor/editor-content-text-form.tsx new file mode 100644 index 000000000..2747df240 --- /dev/null +++ b/apps/remix/app/components/forms/editor/editor-content-text-form.tsx @@ -0,0 +1,78 @@ +import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form'; +import { Textarea } from '@documenso/ui/primitives/textarea'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { + type TContentTextFormSchema, + useContentSettingsForm, +} from '~/components/general/envelope-editor/content-settings-form-provider'; + +import { EditorContentColorField } from './editor-content-generic-field-forms'; +import { + EditorGenericFontSizeField, + EditorGenericLetterSpacingField, + EditorGenericLineHeightField, + EditorGenericTextAlignField, + EditorGenericVerticalAlignField, +} from './editor-field-generic-field-forms'; + +/** + * Settings form for text contents, bound to the selected content's settings + * form. Only the presentational settings are editable here; geometry (page, + * position and size) is managed on the canvas. + */ +export const EditorContentTextForm = () => { + const { t } = useLingui(); + + const { form, isReady } = useContentSettingsForm(); + + // Mount the inputs only once the form holds this content's values. + if (!isReady) { + return null; + } + + return ( +
+
+ ( + + + Text + + +