From 94793c7cce82d1ab2ff075f35573681fa15b3412 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 24 Sep 2026 20:03:22 +1000 Subject: [PATCH] feat: add content fields --- .../dialogs/envelope-distribute-dialog.tsx | 69 +- .../dialogs/envelope-duplicate-dialog.tsx | 21 +- .../dialogs/envelope-item-edit-dialog.tsx | 14 +- .../dialogs/template-use-dialog.tsx | 85 +- .../authoring/configure-document-upload.tsx | 11 +- .../editor-content-generic-field-forms.tsx | 175 +++ .../editor/editor-content-highlight-form.tsx | 31 + .../editor/editor-content-image-settings.tsx | 126 ++ .../forms/editor/editor-content-line-form.tsx | 43 + .../editor/editor-content-shape-form.tsx | 105 ++ .../forms/editor/editor-content-text-form.tsx | 78 ++ .../editor-field-generic-field-forms.tsx | 2 +- .../forms/subscription-claim-form.tsx | 50 + .../direct-template-invalid-page.tsx | 31 +- .../document-signing-page-view-v2.tsx | 8 +- .../canvas/envelope-canvas-action-bar.tsx | 131 ++ .../envelope-canvas-content-actions.tsx | 423 +++++++ .../envelope-canvas-field-action-buttons.tsx | 169 +++ .../envelope-canvas-pending-content-menu.tsx | 40 + .../envelope-canvas-pending-field-menu.tsx | 34 + .../canvas/envelope-canvas-types.ts | 65 + .../reconcile-envelope-canvas-groups.ts | 118 ++ .../use-envelope-canvas-contents-layer.ts | 454 +++++++ .../use-envelope-canvas-fields-layer.ts | 475 ++++++++ .../use-envelope-canvas-line-anchors.ts | 195 +++ .../canvas/use-envelope-canvas-marquee.ts | 185 +++ .../use-envelope-canvas-pending-creation.ts | 88 ++ .../canvas/use-envelope-canvas-selection.ts | 254 ++++ .../content-image-upload-dialog.tsx | 270 +++++ .../content-settings-form-provider.tsx | 246 ++++ .../envelope-editor-content-drag-drop.tsx | 401 ++++++ .../envelope-editor-fields-page-renderer.tsx | 1080 ++++------------- .../envelope-editor-fields-page.tsx | 772 +++++++----- .../envelope-editor-header.tsx | 3 +- .../envelope-editor-preview-page.tsx | 4 +- ...elope-editor-renderer-provider-wrapper.tsx | 1 + .../envelope-editor-upload-page.tsx | 24 +- .../envelope-generic-page-renderer.tsx | 67 +- .../envelope-signer-page-renderer.tsx | 34 +- .../pdf-viewer/envelope-pdf-viewer.tsx | 48 +- .../pdf-viewer/pdf-viewer-page-image.tsx | 24 +- .../general/pdf-viewer/pdf-viewer-toolbar.tsx | 222 ++++ .../general/pdf-viewer/pdf-viewer.tsx | 217 +++- .../app/components/tables/templates-table.tsx | 1 - .../admin+/organisations.$id.tsx | 50 + .../t.$teamUrl+/documents.$id._index.tsx | 1 + .../t.$teamUrl+/templates.$id._index.tsx | 2 +- .../routes/_recipient+/d.$token+/_index.tsx | 32 +- .../_recipient+/sign.$token+/_index.tsx | 1 + .../app/routes/embed+/_v0+/direct.$token.tsx | 1 + .../app/routes/embed+/_v0+/sign.$token.tsx | 1 + .../v2+/authoring+/envelope.create._index.tsx | 1 + apps/remix/app/utils/toast-error-messages.ts | 25 + apps/remix/server/api/files/files.helpers.ts | 7 + apps/remix/server/api/files/files.ts | 6 + .../routes/get-data-content-image-by-token.ts | 74 ++ .../files/routes/get-data-content-image.ts | 156 +++ assets/content-alignment.pdf | Bin 0 -> 36275 bytes .../constants/content-alignment-pdf.ts | 376 ++++++ .../email-transport-claims.spec.ts | 2 + ...update-organisation-content-claims.spec.ts | 54 + .../set-envelope-contents-audit-logs.spec.ts | 296 +++++ .../trpc/set-envelope-contents-limits.spec.ts | 219 ++++ .../e2e/api/v2/envelope-content-limit.spec.ts | 296 +++++ .../envelope-canvas-zoom-selection.spec.ts | 86 ++ .../envelope-content-autosave-race.spec.ts | 267 ++++ ...velope-content-image-upload-dialog.spec.ts | 181 +++ .../envelope-content-images.spec.ts | 439 +++++++ .../envelope-content-limits.spec.ts | 157 +++ .../envelope-content-zindex-ties.spec.ts | 110 ++ .../envelope-contents.spec.ts | 695 +++++++++++ .../envelope-replace-pdf-contents.spec.ts | 119 ++ .../envelope-content-alignment.spec.ts | 236 ++++ .../envelope-content-signing.spec.ts | 78 ++ ...envelope-contents-inserted-on-send.spec.ts | 183 +++ .../envelope-page-canvas-loading.spec.ts | 98 ++ ...velope-replace-pdf-content-pruning.spec.ts | 162 +++ ...nvelope-seal-inserts-contents-once.spec.ts | 164 +++ .../envelope-sealed-content-rendering.spec.ts | 92 ++ .../envelopes/envelope-viewer-toolbar.spec.ts | 112 ++ packages/app-tests/e2e/fixtures/contents.ts | 212 ++++ ...ect-template-missing-content-image.spec.ts | 151 +++ .../template-shares-data-contents.spec.ts | 144 +++ .../client-only/hooks/use-content-images.ts | 226 ++++ .../client-only/hooks/use-editor-contents.ts | 316 +++++ .../client-only/hooks/use-editor-fields.ts | 277 ++--- .../hooks/use-editor-form-array.ts | 245 ++++ .../hooks/use-envelope-autosave.ts | 13 +- .../lib/client-only/hooks/use-latest-ref.ts | 15 + .../client-only/hooks/use-page-renderer.ts | 141 ++- .../lib/client-only/load-content-image.ts | 88 ++ .../providers/envelope-editor-provider.tsx | 131 +- .../providers/envelope-render-provider.tsx | 143 ++- packages/lib/constants/app.ts | 2 + packages/lib/constants/envelope-content.ts | 28 + packages/lib/constants/envelope-editor.ts | 4 + .../internal/seal-document.handler.ts | 88 +- .../server-only/admin/admin-global-search.ts | 4 +- .../data-content/create-data-content-image.ts | 59 + .../data-content/load-content-images.ts | 49 + .../data-content/normalize-image.test.ts | 187 +++ .../data-content/normalize-image.ts | 177 +++ .../lib/server-only/document/send-document.ts | 35 + .../insert-contents-into-envelope-item.ts | 88 ++ .../envelope-content/set-envelope-contents.ts | 362 ++++++ .../upload-envelope-content-image.ts | 122 ++ .../replace-envelope-item-pdf.ts | 54 +- .../envelope/duplicate-envelope.ts | 27 +- .../envelope/get-editor-envelope-by-id.ts | 1 + .../envelope/get-envelope-by-id.ts | 1 + ...et-envelope-for-direct-template-signing.ts | 11 + .../get-envelope-for-recipient-signing.ts | 4 + .../envelope/get-envelopes-by-ids.ts | 1 + .../organisation/create-organisation.ts | 2 + .../pdf/add-rejection-stamp-to-pdf.ts | 19 +- .../pdf/generate-partial-signed-pdf.test.ts | 61 + .../pdf/generate-partial-signed-pdf.ts | 62 +- .../server-only/pdf/insert-field-in-pdf-v2.ts | 48 +- .../pdf/insert-page-overlay.test.ts | 395 ++++++ .../server-only/pdf/insert-page-overlay.ts | 82 ++ .../lib/server-only/rate-limit/rate-limits.ts | 10 + .../create-document-from-direct-template.ts | 76 +- .../template/create-document-from-template.ts | 50 +- .../get-organisation-template-by-id.ts | 1 + packages/lib/types/data-content-meta.ts | 51 + packages/lib/types/document-audit-logs.ts | 65 + packages/lib/types/envelope-content-meta.ts | 455 +++++++ packages/lib/types/envelope-content.ts | 12 + packages/lib/types/envelope-editor.ts | 2 + .../types/envelope-page-items-visibility.ts | 40 + packages/lib/types/envelope.ts | 3 +- packages/lib/types/field-meta.ts | 10 +- packages/lib/types/organisation.ts | 2 + .../content-renderer/content-generic-items.ts | 323 +++++ .../content-renderer/content-geometry.test.ts | 253 ++++ .../content-renderer/content-geometry.ts | 219 ++++ .../content-image-box.test.ts | 97 ++ .../content-renderer/content-image-box.ts | 118 ++ .../content-image-status.test.ts | 173 +++ .../content-renderer/content-image-status.ts | 84 ++ .../content-renderer/content-renderer.test.ts | 127 ++ .../content-renderer/content-renderer.ts | 277 +++++ .../content-renderer/render-content.ts | 35 + .../render-highlight-content.ts | 29 + .../content-renderer/render-image-content.ts | 116 ++ .../content-renderer/render-line-content.ts | 55 + .../content-renderer/render-shape-content.ts | 75 ++ .../render-static-contents.ts | 67 + .../content-renderer/render-text-content.ts | 182 +++ .../field-renderer/field-canvas-style.test.ts | 27 +- .../field-renderer/field-canvas-style.ts | 15 +- .../field-renderer/field-renderer.ts | 28 +- .../universal/field-renderer/render-field.ts | 3 - packages/lib/universal/id.ts | 26 + packages/lib/universal/konva/constants.ts | 34 + .../universal/konva/page-items-visibility.ts | 49 + .../lib/universal/konva/transformer.test.ts | 61 + packages/lib/universal/konva/transformer.ts | 98 ++ packages/lib/universal/unit-convertions.ts | 19 + .../lib/universal/upload/put-file.server.ts | 26 +- .../lib/utils/document-audit-logs.test.ts | 71 ++ packages/lib/utils/document-audit-logs.ts | 92 +- packages/lib/utils/envelope-content.test.ts | 377 ++++++ packages/lib/utils/envelope-content.ts | 277 +++++ packages/lib/utils/envelope-download.ts | 35 + packages/lib/utils/envelope.ts | 23 + packages/lib/utils/geometry.ts | 74 ++ packages/lib/utils/organisations-claims.ts | 3 + .../migration.sql | 59 + packages/prisma/schema.prisma | 59 +- packages/prisma/seed/initial-seed.ts | 169 +++ packages/prisma/types/types.d.ts | 5 + .../admin-router/create-subscription-claim.ts | 4 + .../create-subscription-claim.types.ts | 2 + .../find-subscription-claims.types.ts | 2 + .../swap-organisation-subscription.ts | 2 + .../update-admin-organisation.types.ts | 2 + .../envelope-router/duplicate-envelope.ts | 3 +- .../duplicate-envelope.types.ts | 7 + .../replace-envelope-item-pdf.ts | 3 +- .../replace-envelope-item-pdf.types.ts | 11 + .../trpc/server/envelope-router/router.ts | 6 + .../envelope-router/set-envelope-contents.ts | 34 + .../set-envelope-contents.types.ts | 32 + .../upload-envelope-content-image.ts | 53 + .../upload-envelope-content-image.types.ts | 27 + .../server/envelope-router/use-envelope.ts | 8 + .../envelope-router/use-envelope.types.ts | 12 +- .../trpc/server/template-router/router.ts | 2 + .../trpc/server/template-router/schema.ts | 6 + packages/trpc/utils/zod-form-data.ts | 21 + packages/ui/primitives/color-picker.tsx | 46 +- .../generate-content-alignment-test-pdf.mjs | 244 ++++ 193 files changed, 19188 insertions(+), 1724 deletions(-) create mode 100644 apps/remix/app/components/forms/editor/editor-content-generic-field-forms.tsx create mode 100644 apps/remix/app/components/forms/editor/editor-content-highlight-form.tsx create mode 100644 apps/remix/app/components/forms/editor/editor-content-image-settings.tsx create mode 100644 apps/remix/app/components/forms/editor/editor-content-line-form.tsx create mode 100644 apps/remix/app/components/forms/editor/editor-content-shape-form.tsx create mode 100644 apps/remix/app/components/forms/editor/editor-content-text-form.tsx create mode 100644 apps/remix/app/components/general/envelope-editor/canvas/envelope-canvas-action-bar.tsx create mode 100644 apps/remix/app/components/general/envelope-editor/canvas/envelope-canvas-content-actions.tsx create mode 100644 apps/remix/app/components/general/envelope-editor/canvas/envelope-canvas-field-action-buttons.tsx create mode 100644 apps/remix/app/components/general/envelope-editor/canvas/envelope-canvas-pending-content-menu.tsx create mode 100644 apps/remix/app/components/general/envelope-editor/canvas/envelope-canvas-pending-field-menu.tsx create mode 100644 apps/remix/app/components/general/envelope-editor/canvas/envelope-canvas-types.ts create mode 100644 apps/remix/app/components/general/envelope-editor/canvas/reconcile-envelope-canvas-groups.ts create mode 100644 apps/remix/app/components/general/envelope-editor/canvas/use-envelope-canvas-contents-layer.ts create mode 100644 apps/remix/app/components/general/envelope-editor/canvas/use-envelope-canvas-fields-layer.ts create mode 100644 apps/remix/app/components/general/envelope-editor/canvas/use-envelope-canvas-line-anchors.ts create mode 100644 apps/remix/app/components/general/envelope-editor/canvas/use-envelope-canvas-marquee.ts create mode 100644 apps/remix/app/components/general/envelope-editor/canvas/use-envelope-canvas-pending-creation.ts create mode 100644 apps/remix/app/components/general/envelope-editor/canvas/use-envelope-canvas-selection.ts create mode 100644 apps/remix/app/components/general/envelope-editor/content-image-upload-dialog.tsx create mode 100644 apps/remix/app/components/general/envelope-editor/content-settings-form-provider.tsx create mode 100644 apps/remix/app/components/general/envelope-editor/envelope-editor-content-drag-drop.tsx create mode 100644 apps/remix/app/components/general/pdf-viewer/pdf-viewer-toolbar.tsx create mode 100644 apps/remix/server/api/files/routes/get-data-content-image-by-token.ts create mode 100644 apps/remix/server/api/files/routes/get-data-content-image.ts create mode 100644 assets/content-alignment.pdf create mode 100644 packages/app-tests/constants/content-alignment-pdf.ts create mode 100644 packages/app-tests/e2e/admin/organisations/update-organisation-content-claims.spec.ts create mode 100644 packages/app-tests/e2e/api/trpc/set-envelope-contents-audit-logs.spec.ts create mode 100644 packages/app-tests/e2e/api/trpc/set-envelope-contents-limits.spec.ts create mode 100644 packages/app-tests/e2e/api/v2/envelope-content-limit.spec.ts create mode 100644 packages/app-tests/e2e/envelope-editor-v2/envelope-canvas-zoom-selection.spec.ts create mode 100644 packages/app-tests/e2e/envelope-editor-v2/envelope-content-autosave-race.spec.ts create mode 100644 packages/app-tests/e2e/envelope-editor-v2/envelope-content-image-upload-dialog.spec.ts create mode 100644 packages/app-tests/e2e/envelope-editor-v2/envelope-content-images.spec.ts create mode 100644 packages/app-tests/e2e/envelope-editor-v2/envelope-content-limits.spec.ts create mode 100644 packages/app-tests/e2e/envelope-editor-v2/envelope-content-zindex-ties.spec.ts create mode 100644 packages/app-tests/e2e/envelope-editor-v2/envelope-contents.spec.ts create mode 100644 packages/app-tests/e2e/envelope-editor-v2/envelope-replace-pdf-contents.spec.ts create mode 100644 packages/app-tests/e2e/envelopes/envelope-content-alignment.spec.ts create mode 100644 packages/app-tests/e2e/envelopes/envelope-content-signing.spec.ts create mode 100644 packages/app-tests/e2e/envelopes/envelope-contents-inserted-on-send.spec.ts create mode 100644 packages/app-tests/e2e/envelopes/envelope-page-canvas-loading.spec.ts create mode 100644 packages/app-tests/e2e/envelopes/envelope-replace-pdf-content-pruning.spec.ts create mode 100644 packages/app-tests/e2e/envelopes/envelope-seal-inserts-contents-once.spec.ts create mode 100644 packages/app-tests/e2e/envelopes/envelope-sealed-content-rendering.spec.ts create mode 100644 packages/app-tests/e2e/envelopes/envelope-viewer-toolbar.spec.ts create mode 100644 packages/app-tests/e2e/fixtures/contents.ts create mode 100644 packages/app-tests/e2e/templates/direct-template-missing-content-image.spec.ts create mode 100644 packages/app-tests/e2e/templates/template-shares-data-contents.spec.ts create mode 100644 packages/lib/client-only/hooks/use-content-images.ts create mode 100644 packages/lib/client-only/hooks/use-editor-contents.ts create mode 100644 packages/lib/client-only/hooks/use-editor-form-array.ts create mode 100644 packages/lib/client-only/hooks/use-latest-ref.ts create mode 100644 packages/lib/client-only/load-content-image.ts create mode 100644 packages/lib/constants/envelope-content.ts create mode 100644 packages/lib/constants/envelope-editor.ts create mode 100644 packages/lib/server-only/data-content/create-data-content-image.ts create mode 100644 packages/lib/server-only/data-content/load-content-images.ts create mode 100644 packages/lib/server-only/data-content/normalize-image.test.ts create mode 100644 packages/lib/server-only/data-content/normalize-image.ts create mode 100644 packages/lib/server-only/envelope-content/insert-contents-into-envelope-item.ts create mode 100644 packages/lib/server-only/envelope-content/set-envelope-contents.ts create mode 100644 packages/lib/server-only/envelope-content/upload-envelope-content-image.ts create mode 100644 packages/lib/server-only/pdf/generate-partial-signed-pdf.test.ts create mode 100644 packages/lib/server-only/pdf/insert-page-overlay.test.ts create mode 100644 packages/lib/server-only/pdf/insert-page-overlay.ts create mode 100644 packages/lib/types/data-content-meta.ts create mode 100644 packages/lib/types/envelope-content-meta.ts create mode 100644 packages/lib/types/envelope-content.ts create mode 100644 packages/lib/types/envelope-page-items-visibility.ts create mode 100644 packages/lib/universal/content-renderer/content-generic-items.ts create mode 100644 packages/lib/universal/content-renderer/content-geometry.test.ts create mode 100644 packages/lib/universal/content-renderer/content-geometry.ts create mode 100644 packages/lib/universal/content-renderer/content-image-box.test.ts create mode 100644 packages/lib/universal/content-renderer/content-image-box.ts create mode 100644 packages/lib/universal/content-renderer/content-image-status.test.ts create mode 100644 packages/lib/universal/content-renderer/content-image-status.ts create mode 100644 packages/lib/universal/content-renderer/content-renderer.test.ts create mode 100644 packages/lib/universal/content-renderer/content-renderer.ts create mode 100644 packages/lib/universal/content-renderer/render-content.ts create mode 100644 packages/lib/universal/content-renderer/render-highlight-content.ts create mode 100644 packages/lib/universal/content-renderer/render-image-content.ts create mode 100644 packages/lib/universal/content-renderer/render-line-content.ts create mode 100644 packages/lib/universal/content-renderer/render-shape-content.ts create mode 100644 packages/lib/universal/content-renderer/render-static-contents.ts create mode 100644 packages/lib/universal/content-renderer/render-text-content.ts create mode 100644 packages/lib/universal/konva/constants.ts create mode 100644 packages/lib/universal/konva/page-items-visibility.ts create mode 100644 packages/lib/universal/konva/transformer.test.ts create mode 100644 packages/lib/universal/konva/transformer.ts create mode 100644 packages/lib/utils/document-audit-logs.test.ts create mode 100644 packages/lib/utils/envelope-content.test.ts create mode 100644 packages/lib/utils/envelope-content.ts create mode 100644 packages/lib/utils/geometry.ts create mode 100644 packages/prisma/migrations/20260914054525_add_envelope_contents/migration.sql create mode 100644 packages/trpc/server/envelope-router/set-envelope-contents.ts create mode 100644 packages/trpc/server/envelope-router/set-envelope-contents.types.ts create mode 100644 packages/trpc/server/envelope-router/upload-envelope-content-image.ts create mode 100644 packages/trpc/server/envelope-router/upload-envelope-content-image.types.ts create mode 100644 scripts/generate-content-alignment-test-pdf.mjs 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 + + +