diff --git a/apps/remix/app/components/forms/editor/editor-field-date-form.tsx b/apps/remix/app/components/forms/editor/editor-field-date-form.tsx index 8abe1aec8..670718918 100644 --- a/apps/remix/app/components/forms/editor/editor-field-date-form.tsx +++ b/apps/remix/app/components/forms/editor/editor-field-date-form.tsx @@ -7,6 +7,7 @@ import type { z } from 'zod'; import { DEFAULT_FIELD_FONT_SIZE, type TDateFieldMeta as DateFieldMeta, + FIELD_DATE_META_DEFAULT_VALUES, FIELD_DEFAULT_GENERIC_ALIGN, ZDateFieldMeta, } from '@documenso/lib/types/field-meta'; @@ -20,12 +21,13 @@ import { const ZDateFieldFormSchema = ZDateFieldMeta.pick({ fontSize: true, textAlign: true, + overflow: true, }); type TDateFieldFormSchema = z.infer; type EditorFieldDateFormProps = { - value: DateFieldMeta | undefined; + value: z.input | undefined; onValueChange: (value: DateFieldMeta) => void; }; @@ -41,6 +43,7 @@ export const EditorFieldDateForm = ({ defaultValues: { fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE, textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN, + overflow: value.overflow || FIELD_DATE_META_DEFAULT_VALUES.overflow, }, }); diff --git a/apps/remix/app/components/forms/editor/editor-field-email-form.tsx b/apps/remix/app/components/forms/editor/editor-field-email-form.tsx index c51f3c74f..1b54c38cf 100644 --- a/apps/remix/app/components/forms/editor/editor-field-email-form.tsx +++ b/apps/remix/app/components/forms/editor/editor-field-email-form.tsx @@ -8,6 +8,7 @@ import { DEFAULT_FIELD_FONT_SIZE, type TEmailFieldMeta as EmailFieldMeta, FIELD_DEFAULT_GENERIC_ALIGN, + FIELD_EMAIL_META_DEFAULT_VALUES, ZEmailFieldMeta, } from '@documenso/lib/types/field-meta'; import { Form } from '@documenso/ui/primitives/form/form'; @@ -20,12 +21,13 @@ import { const ZEmailFieldFormSchema = ZEmailFieldMeta.pick({ fontSize: true, textAlign: true, + overflow: true, }); type TEmailFieldFormSchema = z.infer; type EditorFieldEmailFormProps = { - value: EmailFieldMeta | undefined; + value: z.input | undefined; onValueChange: (value: EmailFieldMeta) => void; }; @@ -41,6 +43,7 @@ export const EditorFieldEmailForm = ({ defaultValues: { fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE, textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN, + overflow: value.overflow || FIELD_EMAIL_META_DEFAULT_VALUES.overflow, }, }); diff --git a/apps/remix/app/components/forms/editor/editor-field-signature-form.tsx b/apps/remix/app/components/forms/editor/editor-field-signature-form.tsx index 2a1064751..6aebeccc3 100644 --- a/apps/remix/app/components/forms/editor/editor-field-signature-form.tsx +++ b/apps/remix/app/components/forms/editor/editor-field-signature-form.tsx @@ -6,19 +6,24 @@ import { useForm, useWatch } from 'react-hook-form'; import type { z } from 'zod'; import { DEFAULT_SIGNATURE_TEXT_FONT_SIZE } from '@documenso/lib/constants/pdf'; -import { type TSignatureFieldMeta, ZSignatureFieldMeta } from '@documenso/lib/types/field-meta'; +import { + FIELD_SIGNATURE_META_DEFAULT_VALUES, + type TSignatureFieldMeta, + ZSignatureFieldMeta, +} from '@documenso/lib/types/field-meta'; import { Form } from '@documenso/ui/primitives/form/form'; import { EditorGenericFontSizeField } from './editor-field-generic-field-forms'; const ZSignatureFieldFormSchema = ZSignatureFieldMeta.pick({ fontSize: true, + overflow: true, }); type TSignatureFieldFormSchema = z.infer; type EditorFieldSignatureFormProps = { - value: TSignatureFieldMeta | undefined; + value: z.input | undefined; onValueChange: (value: TSignatureFieldMeta) => void; }; @@ -32,6 +37,7 @@ export const EditorFieldSignatureForm = ({ resolver: zodResolver(ZSignatureFieldFormSchema), mode: 'onChange', defaultValues: { + overflow: value.overflow || FIELD_SIGNATURE_META_DEFAULT_VALUES.overflow, fontSize: value.fontSize || DEFAULT_SIGNATURE_TEXT_FONT_SIZE, }, }); @@ -60,7 +66,7 @@ export const EditorFieldSignatureForm = ({
-

+

The typed signature font size

diff --git a/apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx b/apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx index 34ff0ee5d..c608f306f 100644 --- a/apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx +++ b/apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx @@ -167,7 +167,9 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD const currentTarget = e.currentTarget as Konva.Group; const target = e.target as Konva.Shape; - const { width: fieldWidth, height: fieldHeight } = fieldGroup.getClientRect(); + const fieldRect = fieldGroup.findOne('.field-rect'); + const fieldWidth = fieldRect ? fieldRect.width() : fieldGroup.width(); + const fieldHeight = fieldRect ? fieldRect.height() : fieldGroup.height(); const foundField = localPageFields.find((f) => f.id === unparsedField.id); const foundLoadingGroup = currentTarget.findOne('.loading-spinner-group'); @@ -195,8 +197,8 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD } const loadingSpinnerGroup = createSpinner({ - fieldWidth: fieldWidth / scale, - fieldHeight: fieldHeight / scale, + fieldWidth, + fieldHeight, }); const parsedFoundField = ZFullFieldSchema.parse(foundField); diff --git a/assets/field-overflow.pdf b/assets/field-overflow.pdf new file mode 100644 index 000000000..987f39d78 Binary files /dev/null and b/assets/field-overflow.pdf differ diff --git a/packages/app-tests/constants/field-alignment-pdf.ts b/packages/app-tests/constants/field-alignment-pdf.ts index a5539007f..61e305748 100644 --- a/packages/app-tests/constants/field-alignment-pdf.ts +++ b/packages/app-tests/constants/field-alignment-pdf.ts @@ -86,6 +86,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [ fontSize: 10, textAlign: 'left', type: 'email', + overflow: 'auto', }, page: 1, ...calculatePositionPageOne(0, 0), @@ -96,6 +97,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [ fieldMeta: { textAlign: 'center', type: 'email', + overflow: 'auto', }, page: 1, ...calculatePositionPageOne(0, 1), @@ -107,6 +109,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [ fontSize: 20, textAlign: 'right', type: 'email', + overflow: 'auto', }, page: 1, ...calculatePositionPageOne(0, 2), @@ -156,6 +159,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [ fontSize: 10, textAlign: 'left', type: 'date', + overflow: 'auto', }, page: 1, ...calculatePositionPageOne(2, 0), @@ -166,6 +170,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [ fieldMeta: { textAlign: 'center', type: 'date', + overflow: 'auto', }, page: 1, ...calculatePositionPageOne(2, 1), @@ -177,6 +182,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [ fontSize: 20, textAlign: 'right', type: 'date', + overflow: 'auto', }, page: 1, ...calculatePositionPageOne(2, 2), @@ -424,6 +430,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [ fieldMeta: { fontSize: 10, type: 'signature', + overflow: 'auto', }, page: 1, ...calculatePositionPageOne(9, 0), @@ -434,6 +441,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [ type: FieldType.SIGNATURE, fieldMeta: { type: 'signature', + overflow: 'auto', }, page: 1, ...calculatePositionPageOne(9, 1), @@ -445,6 +453,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [ fieldMeta: { fontSize: 20, type: 'signature', + overflow: 'auto', }, page: 1, ...calculatePositionPageOne(9, 2), diff --git a/packages/app-tests/constants/field-overflow-pdf.ts b/packages/app-tests/constants/field-overflow-pdf.ts new file mode 100644 index 000000000..82e5782fa --- /dev/null +++ b/packages/app-tests/constants/field-overflow-pdf.ts @@ -0,0 +1,790 @@ +import { FieldType } from '@prisma/client'; + +import type { FieldTestData } from './field-alignment-pdf'; + +/** + * Overflow test data extends FieldTestData with a `seedFieldMeta` property. + * + * - `fieldMeta`: Minimal field meta sent via the API. Omit properties that the API + * auto-applies via ZEnvelopeFieldAndMetaSchema defaults (e.g. `overflow: 'auto'` for + * date/email/signature fields). This tests that the API correctly sets defaults. + * + * - `seedFieldMeta`: Full field meta written directly to the DB by the seed function. + * Must include ALL properties explicitly since the seed bypasses API validation/defaults. + * Used by `seedOverflowTestDocument` in initial-seed.ts. + */ +export type OverflowFieldTestData = Omit & { + fieldMeta?: FieldTestData['fieldMeta']; + seedFieldMeta: FieldTestData['fieldMeta']; +}; + +const SINGLE_LINE_HEIGHT = 1.75; +const MULTI_LINE_HEIGHT = 12; +const DEFAULT_BOX_WIDTH = 25; +const SINGLE_TYPE_BOX_WIDTH = 35; +const DEFAULT_START_X = 10; + +/** + * Pages 1-3: Date, Email, Signature + * Single-line section (rows 0-2): single column, full width + * Pages 1-2 multi-line: 3×3 grid (rows = TA_LEFT/CENTER/RIGHT, columns = short/medium/long text) + * Page 3 multi-line: stacked single column (signature has no text align control) + */ +const SINGLE_TYPE_ML_COLUMN_X = [2.5, 35, 67.5]; +const SINGLE_TYPE_ML_BOX_WIDTH = 30; +const SINGLE_TYPE_ML_ROW_Y = [45, 63, 83]; + +const calculateSingleLinePosition = (row: number) => { + const singleLineYPositions = [15, 23, 31]; + + return { + positionX: DEFAULT_START_X, + positionY: singleLineYPositions[row], + width: SINGLE_TYPE_BOX_WIDTH, + height: SINGLE_LINE_HEIGHT, + }; +}; + +/** Pages 1-2: multi-line 3×3 grid */ +const calculateMultiLinePosition = (row: number, column: number) => { + return { + positionX: SINGLE_TYPE_ML_COLUMN_X[column], + positionY: SINGLE_TYPE_ML_ROW_Y[row], + width: SINGLE_TYPE_ML_BOX_WIDTH, + height: MULTI_LINE_HEIGHT, + }; +}; + +/** Page 3: multi-line stacked single column */ +const calculateStackedMultiLinePosition = (row: number) => { + const yPositions = [45, 63, 81]; + + return { + positionX: DEFAULT_START_X, + positionY: yPositions[row], + width: SINGLE_TYPE_BOX_WIDTH, + height: MULTI_LINE_HEIGHT, + }; +}; + +/** + * Pages 4-5: Text Auto Mode (3x3 grid) + */ +const TEXT_AUTO_COLUMN_X = [5, 35.5, 66]; +const TEXT_AUTO_BOX_WIDTH = 28; + +const calculateTextAutoPosition = (row: number, column: number, isSingleLine: boolean) => { + if (isSingleLine) { + // Single-line: all 9 items evenly spaced down the page. + // Order: row0-col0, row0-col1, row0-col2, row1-col0, ... + const startY = 10; + const endY = 92; + const spacing = (endY - startY) / 8; // 9 items, 8 gaps = 10.25% + const itemIndex = row * 3 + column; + + return { + positionX: TEXT_AUTO_COLUMN_X[column], + positionY: startY + itemIndex * spacing, + width: TEXT_AUTO_BOX_WIDTH, + height: SINGLE_LINE_HEIGHT, + }; + } + + // Multi-line: 3 rows evenly spaced, bottom row near page bottom. + // Box is 12% tall. Top of last box at 80% so bottom edge is at 92%. + const multiLineYPositions = [10, 45, 80]; + + return { + positionX: TEXT_AUTO_COLUMN_X[column], + positionY: multiLineYPositions[row], + width: TEXT_AUTO_BOX_WIDTH, + height: MULTI_LINE_HEIGHT, + }; +}; + +/** + * Page 6: Explicit Modes + */ +const HORIZONTAL_CENTERED_X = (100 - DEFAULT_BOX_WIDTH) / 2; // 37.5% + +const calculateExplicitHorizontalPosition = (row: number) => { + const yPositions = [15, 21, 27]; + + return { + positionX: HORIZONTAL_CENTERED_X, + positionY: yPositions[row], + width: DEFAULT_BOX_WIDTH, + height: SINGLE_LINE_HEIGHT, + }; +}; + +const calculateExplicitVerticalPosition = (column: number) => { + const xPositions = [5, 37.5, 70]; + + return { + positionX: xPositions[column], + positionY: 43, + width: DEFAULT_BOX_WIDTH, + height: MULTI_LINE_HEIGHT, + }; +}; + +export const OVERFLOW_TEST_FIELDS: OverflowFieldTestData[] = [ + /** + * @@@@@@@@@@@@@@@@@@@@@@@ + * + * PAGE 1: DATE OVERFLOW + * + * @@@@@@@@@@@@@@@@@@@@@@@ + */ + // Single-line: Row 0-2 (default date meta — API auto-adds overflow: 'auto') + { + type: FieldType.DATE, + fieldMeta: undefined, + seedFieldMeta: { type: 'date', overflow: 'auto' }, + page: 1, + ...calculateSingleLinePosition(0), + customText: 'Apr 16 2026', + }, + { + type: FieldType.DATE, + fieldMeta: undefined, + seedFieldMeta: { type: 'date', overflow: 'auto' }, + page: 1, + ...calculateSingleLinePosition(1), + customText: 'Wednesday, April 16, 2026 at 14:30:45 UTC', + }, + { + type: FieldType.DATE, + fieldMeta: undefined, + seedFieldMeta: { type: 'date', overflow: 'auto' }, + page: 1, + ...calculateSingleLinePosition(2), + customText: + 'Wednesday, April 16, 2026 at 14:30:45.123 Coordinated Universal Time signed in Melbourne, Australia', + }, + // Multi-line 3×3: Row 0 = TA_LEFT (short / medium / long) + { + type: FieldType.DATE, + fieldMeta: { type: 'date', textAlign: 'left' }, + seedFieldMeta: { type: 'date', overflow: 'auto', textAlign: 'left' }, + page: 1, + ...calculateMultiLinePosition(0, 0), + customText: 'Apr 16 2026', + }, + { + type: FieldType.DATE, + fieldMeta: { type: 'date', textAlign: 'left' }, + seedFieldMeta: { type: 'date', overflow: 'auto', textAlign: 'left' }, + page: 1, + ...calculateMultiLinePosition(0, 1), + customText: 'Wednesday, April 16, 2026 at 14:30:45 Coordinated Universal Time', + }, + { + type: FieldType.DATE, + fieldMeta: { type: 'date', textAlign: 'left' }, + seedFieldMeta: { type: 'date', overflow: 'auto', textAlign: 'left' }, + page: 1, + ...calculateMultiLinePosition(0, 2), + customText: + 'Wednesday, April 16, 2026 at 14:30:45.123 Coordinated Universal Time signed in Melbourne, Australia. Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty', + }, + // Multi-line 3×3: Row 1 = TA_CENTER (short / medium / long) + { + type: FieldType.DATE, + fieldMeta: { type: 'date', textAlign: 'center' }, + seedFieldMeta: { type: 'date', overflow: 'auto', textAlign: 'center' }, + page: 1, + ...calculateMultiLinePosition(1, 0), + customText: 'Apr 16 2026', + }, + { + type: FieldType.DATE, + fieldMeta: { type: 'date', textAlign: 'center' }, + seedFieldMeta: { type: 'date', overflow: 'auto', textAlign: 'center' }, + page: 1, + ...calculateMultiLinePosition(1, 1), + customText: 'Wednesday, April 16, 2026 at 14:30:45 Coordinated Universal Time', + }, + { + type: FieldType.DATE, + fieldMeta: { type: 'date', textAlign: 'center' }, + seedFieldMeta: { type: 'date', overflow: 'auto', textAlign: 'center' }, + page: 1, + ...calculateMultiLinePosition(1, 2), + customText: + 'Wednesday, April 16, 2026 at 14:30:45.123 Coordinated Universal Time signed in Melbourne, Australia. Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty', + }, + // Multi-line 3×3: Row 2 = TA_RIGHT (short / medium / long) + { + type: FieldType.DATE, + fieldMeta: { type: 'date', textAlign: 'right' }, + seedFieldMeta: { type: 'date', overflow: 'auto', textAlign: 'right' }, + page: 1, + ...calculateMultiLinePosition(2, 0), + customText: 'Apr 16 2026', + }, + { + type: FieldType.DATE, + fieldMeta: { type: 'date', textAlign: 'right' }, + seedFieldMeta: { type: 'date', overflow: 'auto', textAlign: 'right' }, + page: 1, + ...calculateMultiLinePosition(2, 1), + customText: 'Wednesday, April 16, 2026 at 14:30:45 Coordinated Universal Time', + }, + { + type: FieldType.DATE, + fieldMeta: { type: 'date', textAlign: 'right' }, + seedFieldMeta: { type: 'date', overflow: 'auto', textAlign: 'right' }, + page: 1, + ...calculateMultiLinePosition(2, 2), + customText: + 'Wednesday, April 16, 2026 at 14:30:45.123 Coordinated Universal Time signed in Melbourne, Australia. Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty', + }, + + /** + * @@@@@@@@@@@@@@@@@@@@@@@ + * + * PAGE 2: EMAIL OVERFLOW + * + * @@@@@@@@@@@@@@@@@@@@@@@ + */ + // Single-line: Row 0-2 (default email meta — API auto-adds overflow: 'auto') + { + type: FieldType.EMAIL, + fieldMeta: undefined, + seedFieldMeta: { type: 'email', overflow: 'auto' }, + page: 2, + ...calculateSingleLinePosition(0), + customText: 'example@documenso.com', + }, + { + type: FieldType.EMAIL, + fieldMeta: undefined, + seedFieldMeta: { type: 'email', overflow: 'auto' }, + page: 2, + ...calculateSingleLinePosition(1), + customText: 'example+medium-overflow-test@documenso.com', + }, + { + type: FieldType.EMAIL, + fieldMeta: undefined, + seedFieldMeta: { type: 'email', overflow: 'auto' }, + page: 2, + ...calculateSingleLinePosition(2), + customText: + 'example+maximum-overflow-testing-across-the-page-width-to-verify-text-extends-beyond-field@documenso.com', + }, + // Multi-line 3×3: Row 0 = TA_LEFT (short / medium / long) + { + type: FieldType.EMAIL, + fieldMeta: { type: 'email', textAlign: 'left' }, + seedFieldMeta: { type: 'email', overflow: 'auto', textAlign: 'left' }, + page: 2, + ...calculateMultiLinePosition(0, 0), + customText: 'example@documenso.com', + }, + { + type: FieldType.EMAIL, + fieldMeta: { type: 'email', textAlign: 'left' }, + seedFieldMeta: { type: 'email', overflow: 'auto', textAlign: 'left' }, + page: 2, + ...calculateMultiLinePosition(0, 1), + customText: 'example+medium-wrapped-text@documenso.com', + }, + { + type: FieldType.EMAIL, + fieldMeta: { type: 'email', textAlign: 'left' }, + seedFieldMeta: { type: 'email', overflow: 'auto', textAlign: 'left' }, + page: 2, + ...calculateMultiLinePosition(0, 2), + customText: + 'example+this-is-an-extremely-long-email-address-that-is-designed-to-overflow-vertically-out-of-the-field-box-and-extend-well-beyond-the-bottom-of-the-page-to-verify-that-the-vertical-overflow-logic-correctly-handles-text-that-wraps@documenso.com', + }, + // Multi-line 3×3: Row 1 = TA_CENTER (short / medium / long) + { + type: FieldType.EMAIL, + fieldMeta: { type: 'email', textAlign: 'center' }, + seedFieldMeta: { type: 'email', overflow: 'auto', textAlign: 'center' }, + page: 2, + ...calculateMultiLinePosition(1, 0), + customText: 'example@documenso.com', + }, + { + type: FieldType.EMAIL, + fieldMeta: { type: 'email', textAlign: 'center' }, + seedFieldMeta: { type: 'email', overflow: 'auto', textAlign: 'center' }, + page: 2, + ...calculateMultiLinePosition(1, 1), + customText: 'example+medium-wrapped-text@documenso.com', + }, + { + type: FieldType.EMAIL, + fieldMeta: { type: 'email', textAlign: 'center' }, + seedFieldMeta: { type: 'email', overflow: 'auto', textAlign: 'center' }, + page: 2, + ...calculateMultiLinePosition(1, 2), + customText: + 'example+this-is-an-extremely-long-email-address-that-is-designed-to-overflow-vertically-out-of-the-field-box-and-extend-well-beyond-the-bottom-of-the-page-to-verify-that-the-vertical-overflow-logic-correctly-handles-text-that-wraps@documenso.com', + }, + // Multi-line 3×3: Row 2 = TA_RIGHT (short / medium / long) + { + type: FieldType.EMAIL, + fieldMeta: { type: 'email', textAlign: 'right' }, + seedFieldMeta: { type: 'email', overflow: 'auto', textAlign: 'right' }, + page: 2, + ...calculateMultiLinePosition(2, 0), + customText: 'example@documenso.com', + }, + { + type: FieldType.EMAIL, + fieldMeta: { type: 'email', textAlign: 'right' }, + seedFieldMeta: { type: 'email', overflow: 'auto', textAlign: 'right' }, + page: 2, + ...calculateMultiLinePosition(2, 1), + customText: 'example+medium-wrapped-text@documenso.com', + }, + { + type: FieldType.EMAIL, + fieldMeta: { type: 'email', textAlign: 'right' }, + seedFieldMeta: { type: 'email', overflow: 'auto', textAlign: 'right' }, + page: 2, + ...calculateMultiLinePosition(2, 2), + customText: + 'example+this-is-an-extremely-long-email-address-that-is-designed-to-overflow-vertically-out-of-the-field-box-and-extend-well-beyond-the-bottom-of-the-page-to-verify-that-the-vertical-overflow-logic-correctly-handles-text-that-wraps@documenso.com', + }, + + /** + * @@@@@@@@@@@@@@@@@@@@@@@ + * + * PAGE 3: SIGNATURE OVERFLOW + * + * @@@@@@@@@@@@@@@@@@@@@@@ + */ + // Single-line: Row 0-2 (default signature meta — API auto-adds overflow: 'auto') + { + type: FieldType.SIGNATURE, + fieldMeta: undefined, + seedFieldMeta: { type: 'signature', overflow: 'auto' }, + page: 3, + ...calculateSingleLinePosition(0), + customText: '', + signature: 'John Doe', + }, + { + type: FieldType.SIGNATURE, + fieldMeta: undefined, + seedFieldMeta: { type: 'signature', overflow: 'auto' }, + page: 3, + ...calculateSingleLinePosition(1), + customText: '', + signature: 'My Signature should overflow the field width', + }, + { + type: FieldType.SIGNATURE, + fieldMeta: undefined, + seedFieldMeta: { type: 'signature', overflow: 'auto' }, + page: 3, + ...calculateSingleLinePosition(2), + customText: '', + signature: + 'My Signature should overflow the full signature field width and continue across the page to verify text is no longer clipped by the box boundary', + }, + // Multi-line stacked: short / medium / long + { + type: FieldType.SIGNATURE, + fieldMeta: undefined, + seedFieldMeta: { type: 'signature', overflow: 'auto' }, + page: 3, + ...calculateStackedMultiLinePosition(0), + customText: '', + signature: 'John Doe', + }, + { + type: FieldType.SIGNATURE, + fieldMeta: undefined, + seedFieldMeta: { type: 'signature', overflow: 'auto' }, + page: 3, + ...calculateStackedMultiLinePosition(1), + customText: '', + signature: 'My Signature wraps within the tall field', + }, + { + type: FieldType.SIGNATURE, + fieldMeta: undefined, + seedFieldMeta: { type: 'signature', overflow: 'auto' }, + page: 3, + ...calculateStackedMultiLinePosition(2), + customText: '', + signature: + 'Count to 40, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty thirty one thirty two thirty three thirty four thirty five thirty six thirty seven thirty eight thirty nine forty', + }, + + /** + * @@@@@@@@@@@@@@@@@@@@@@@ + * + * PAGE 4: TEXT AUTO - SINGLE-LINE (3x3 grid) + * + * @@@@@@@@@@@@@@@@@@@@@@@ + */ + // Row 0 (top) + { + type: FieldType.TEXT, + fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'top' }, + seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'top' }, + page: 4, + ...calculateTextAutoPosition(0, 0, true), + customText: 'This text should overflow horizontally', + }, + { + type: FieldType.TEXT, + fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'top' }, + seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'top' }, + page: 4, + ...calculateTextAutoPosition(0, 1, true), + customText: 'This text should overflow horizontally', + }, + { + type: FieldType.TEXT, + fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'top' }, + seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'top' }, + page: 4, + ...calculateTextAutoPosition(0, 2, true), + customText: 'This text should overflow horizontally', + }, + // Row 1 (middle) + { + type: FieldType.TEXT, + fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'middle' }, + seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'middle' }, + page: 4, + ...calculateTextAutoPosition(1, 0, true), + customText: 'This text should overflow horizontally', + }, + { + type: FieldType.TEXT, + fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'middle' }, + seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'middle' }, + page: 4, + ...calculateTextAutoPosition(1, 1, true), + customText: 'This text should overflow horizontally', + }, + { + type: FieldType.TEXT, + fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'middle' }, + seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'middle' }, + page: 4, + ...calculateTextAutoPosition(1, 2, true), + customText: 'This text should overflow horizontally', + }, + // Row 2 (bottom) + { + type: FieldType.TEXT, + fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'bottom' }, + seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'bottom' }, + page: 4, + ...calculateTextAutoPosition(2, 0, true), + customText: 'This text should overflow horizontally', + }, + { + type: FieldType.TEXT, + fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'bottom' }, + seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'bottom' }, + page: 4, + ...calculateTextAutoPosition(2, 1, true), + customText: 'This text should overflow horizontally', + }, + { + type: FieldType.TEXT, + fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'bottom' }, + seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'bottom' }, + page: 4, + ...calculateTextAutoPosition(2, 2, true), + customText: 'This text should overflow horizontally', + }, + + /** + * @@@@@@@@@@@@@@@@@@@@@@@ + * + * PAGE 5: TEXT AUTO - MULTI-LINE (3x3 grid) + * + * @@@@@@@@@@@@@@@@@@@@@@@ + */ + // Row 0 (top) + { + type: FieldType.TEXT, + fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'top' }, + seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'top' }, + page: 5, + ...calculateTextAutoPosition(0, 0, false), + customText: + 'Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty', + }, + { + type: FieldType.TEXT, + fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'top' }, + seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'top' }, + page: 5, + ...calculateTextAutoPosition(0, 1, false), + customText: + 'Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty', + }, + { + type: FieldType.TEXT, + fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'top' }, + seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'top' }, + page: 5, + ...calculateTextAutoPosition(0, 2, false), + customText: + 'Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty', + }, + // Row 1 (middle) + { + type: FieldType.TEXT, + fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'middle' }, + seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'middle' }, + page: 5, + ...calculateTextAutoPosition(1, 0, false), + customText: + 'Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty', + }, + { + type: FieldType.TEXT, + fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'middle' }, + seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'middle' }, + page: 5, + ...calculateTextAutoPosition(1, 1, false), + customText: + 'Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty', + }, + { + type: FieldType.TEXT, + fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'middle' }, + seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'middle' }, + page: 5, + ...calculateTextAutoPosition(1, 2, false), + customText: + 'Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty', + }, + // Row 2 (bottom) + { + type: FieldType.TEXT, + fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'bottom' }, + seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'bottom' }, + page: 5, + ...calculateTextAutoPosition(2, 0, false), + customText: + 'Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty', + }, + { + type: FieldType.TEXT, + fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'bottom' }, + seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'bottom' }, + page: 5, + ...calculateTextAutoPosition(2, 1, false), + customText: + 'Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty', + }, + { + type: FieldType.TEXT, + fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'bottom' }, + seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'bottom' }, + page: 5, + ...calculateTextAutoPosition(2, 2, false), + customText: + 'Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty', + }, + + /** + * @@@@@@@@@@@@@@@@@@@@@@@ + * + * PAGE 6: TEXT AUTO - MULTI-LINE HEIGHT OVERFLOW + * + * @@@@@@@@@@@@@@@@@@@@@@@ + */ + // Same 3×3 grid as page 5 but with longer text that overflows vertically. + // left / top + { + type: FieldType.TEXT, + fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'top' }, + seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'top' }, + page: 6, + ...calculateTextAutoPosition(0, 0, false), + customText: + 'Count to 40, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty thirty one thirty two thirty three thirty four thirty five thirty six thirty seven thirty eight thirty nine forty', + }, + // center / top + { + type: FieldType.TEXT, + fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'top' }, + seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'top' }, + page: 6, + ...calculateTextAutoPosition(0, 1, false), + customText: + 'Count to 40, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty thirty one thirty two thirty three thirty four thirty five thirty six thirty seven thirty eight thirty nine forty', + }, + // right / top + { + type: FieldType.TEXT, + fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'top' }, + seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'top' }, + page: 6, + ...calculateTextAutoPosition(0, 2, false), + customText: + 'Count to 40, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty thirty one thirty two thirty three thirty four thirty five thirty six thirty seven thirty eight thirty nine forty', + }, + // left / middle + { + type: FieldType.TEXT, + fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'middle' }, + seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'middle' }, + page: 6, + ...calculateTextAutoPosition(1, 0, false), + customText: + 'Count to 40, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty thirty one thirty two thirty three thirty four thirty five thirty six thirty seven thirty eight thirty nine forty', + }, + // center / middle + { + type: FieldType.TEXT, + fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'middle' }, + seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'middle' }, + page: 6, + ...calculateTextAutoPosition(1, 1, false), + customText: + 'Count to 40, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty thirty one thirty two thirty three thirty four thirty five thirty six thirty seven thirty eight thirty nine forty', + }, + // right / middle + { + type: FieldType.TEXT, + fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'middle' }, + seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'middle' }, + page: 6, + ...calculateTextAutoPosition(1, 2, false), + customText: + 'Count to 40, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty thirty one thirty two thirty three thirty four thirty five thirty six thirty seven thirty eight thirty nine forty', + }, + // left / bottom + { + type: FieldType.TEXT, + fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'bottom' }, + seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'bottom' }, + page: 6, + ...calculateTextAutoPosition(2, 0, false), + customText: + 'Count to 40, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty thirty one thirty two thirty three thirty four thirty five thirty six thirty seven thirty eight thirty nine forty', + }, + // center / bottom + { + type: FieldType.TEXT, + fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'bottom' }, + seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'bottom' }, + page: 6, + ...calculateTextAutoPosition(2, 1, false), + customText: + 'Count to 40, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty thirty one thirty two thirty three thirty four thirty five thirty six thirty seven thirty eight thirty nine forty', + }, + // right / bottom + { + type: FieldType.TEXT, + fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'bottom' }, + seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'bottom' }, + page: 6, + ...calculateTextAutoPosition(2, 2, false), + customText: + 'Count to 40, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty thirty one thirty two thirty three thirty four thirty five thirty six thirty seven thirty eight thirty nine forty', + }, + + /** + * @@@@@@@@@@@@@@@@@@@@@@@ + * + * PAGE 7: EXPLICIT MODES + * + * @@@@@@@@@@@@@@@@@@@@@@@ + */ + // Section A: Horizontal mode (3 boxes in a row) + { + type: FieldType.TEXT, + fieldMeta: { type: 'text', overflow: 'horizontal', textAlign: 'left' }, + seedFieldMeta: { type: 'text', overflow: 'horizontal', textAlign: 'left' }, + page: 7, + ...calculateExplicitHorizontalPosition(0), + customText: 'Explicit horizontal overflow text that should extend beyond the field', + }, + { + type: FieldType.TEXT, + fieldMeta: { type: 'text', overflow: 'horizontal', textAlign: 'center' }, + seedFieldMeta: { type: 'text', overflow: 'horizontal', textAlign: 'center' }, + page: 7, + ...calculateExplicitHorizontalPosition(1), + customText: 'Explicit horizontal overflow text that should extend beyond the field', + }, + { + type: FieldType.TEXT, + fieldMeta: { type: 'text', overflow: 'horizontal', textAlign: 'right' }, + seedFieldMeta: { type: 'text', overflow: 'horizontal', textAlign: 'right' }, + page: 7, + ...calculateExplicitHorizontalPosition(2), + customText: 'Explicit horizontal overflow text that should extend beyond the field', + }, + // Section B: Vertical mode (3 boxes in a column) + { + type: FieldType.TEXT, + fieldMeta: { type: 'text', overflow: 'vertical', verticalAlign: 'top' }, + seedFieldMeta: { type: 'text', overflow: 'vertical', verticalAlign: 'top' }, + page: 7, + ...calculateExplicitVerticalPosition(0), + customText: + 'Count to 30, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty', + }, + { + type: FieldType.TEXT, + fieldMeta: { type: 'text', overflow: 'vertical', verticalAlign: 'middle' }, + seedFieldMeta: { type: 'text', overflow: 'vertical', verticalAlign: 'middle' }, + page: 7, + ...calculateExplicitVerticalPosition(1), + customText: + 'Count to 30, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty', + }, + { + type: FieldType.TEXT, + fieldMeta: { type: 'text', overflow: 'vertical', verticalAlign: 'bottom' }, + seedFieldMeta: { type: 'text', overflow: 'vertical', verticalAlign: 'bottom' }, + page: 7, + ...calculateExplicitVerticalPosition(2), + customText: + 'Count to 30, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty', + }, + + /** + * @@@@@@@@@@@@@@@@@@@@@@@ + * + * PAGE 8: CROP MODE + * + * @@@@@@@@@@@@@@@@@@@@@@@ + */ + // Box 1: Single-line crop + { + type: FieldType.TEXT, + fieldMeta: undefined, + seedFieldMeta: { type: 'text' }, + page: 8, + positionX: 10, + positionY: 15, + width: 25, + height: SINGLE_LINE_HEIGHT, + customText: 'This text should be cropped and not overflow', + }, + // Box 2: Multi-line crop + { + type: FieldType.TEXT, + fieldMeta: undefined, + seedFieldMeta: { type: 'text' }, + page: 8, + positionX: 10, + positionY: 30, + width: 25, + height: MULTI_LINE_HEIGHT, + customText: + 'Count to 30, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty', + }, +] as const; diff --git a/packages/app-tests/e2e/envelopes/envelope-alignment.spec.ts b/packages/app-tests/e2e/envelopes/envelope-alignment.spec.ts index 71c960ad7..7505ffc93 100644 --- a/packages/app-tests/e2e/envelopes/envelope-alignment.spec.ts +++ b/packages/app-tests/e2e/envelopes/envelope-alignment.spec.ts @@ -31,6 +31,9 @@ const baseUrl = `${WEBAPP_BASE_URL}/api/v2`; test.describe.configure({ mode: 'parallel', timeout: 60000 }); +/** + * DON'T COMMIT THIS WITHOUT THE "SKIP" COMMAND. + */ test.skip('seed alignment test document', async ({ page }) => { const user = await prisma.user.findFirstOrThrow({ where: { @@ -232,6 +235,47 @@ test('field placement visual regression', async ({ page, request }, testInfo) => }), ); + // Override email fields with test values after distribution. + // Email fields are auto-inserted with the signer's email during distribution, + // so we override customText directly to test with specific values. + const emailFields = await prisma.field.findMany({ + where: { + envelopeId: envelope.id, + type: FieldType.EMAIL, + }, + include: { + envelopeItem: { + select: { + title: true, + }, + }, + }, + }); + + await Promise.all( + emailFields.map(async (field) => { + const testFields = + field.envelopeItem.title === 'alignment-pdf' + ? ALIGNMENT_TEST_FIELDS + : FIELD_META_TEST_FIELDS; + + const foundField = testFields.find( + (f) => + f.type === FieldType.EMAIL && + field.page === f.page && + Number(field.positionX).toFixed(2) === f.positionX.toFixed(2) && + Number(field.positionY).toFixed(2) === f.positionY.toFixed(2), + ); + + if (foundField) { + await prisma.field.update({ + where: { id: field.id }, + data: { customText: foundField.customText }, + }); + } + }), + ); + const recipientToken = envelope.recipients[0].token; const signUrl = `/sign/${recipientToken}`; @@ -335,6 +379,45 @@ test.skip('download envelope images', async ({ page, request }) => { expect(distributeEnvelopeRequest.ok()).toBeTruthy(); + // Override email fields with test values after distribution. + const emailFields = await prisma.field.findMany({ + where: { + envelopeId: envelope.id, + type: FieldType.EMAIL, + }, + include: { + envelopeItem: { + select: { + title: true, + }, + }, + }, + }); + + await Promise.all( + emailFields.map(async (field) => { + const testFields = + field.envelopeItem.title === 'alignment-pdf' + ? ALIGNMENT_TEST_FIELDS + : FIELD_META_TEST_FIELDS; + + const foundField = testFields.find( + (f) => + f.type === FieldType.EMAIL && + field.page === f.page && + Number(field.positionX).toFixed(2) === f.positionX.toFixed(2) && + Number(field.positionY).toFixed(2) === f.positionY.toFixed(2), + ); + + if (foundField) { + await prisma.field.update({ + where: { id: field.id }, + data: { customText: foundField.customText }, + }); + } + }), + ); + const token = envelope.recipients[0].token; const signUrl = `/sign/${token}`; diff --git a/packages/app-tests/e2e/envelopes/envelope-overflow.spec.ts b/packages/app-tests/e2e/envelopes/envelope-overflow.spec.ts new file mode 100644 index 000000000..db50be152 --- /dev/null +++ b/packages/app-tests/e2e/envelopes/envelope-overflow.spec.ts @@ -0,0 +1,543 @@ +import { createCanvas } from '@napi-rs/canvas'; +import type { TestInfo } from '@playwright/test'; +import { expect, test } from '@playwright/test'; +import { DocumentStatus, EnvelopeType, FieldType } from '@prisma/client'; +import fs from 'node:fs'; +import path from 'node:path'; +import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs'; +import pixelMatch from 'pixelmatch'; +import { PNG } from 'pngjs'; + +import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download'; +import { prisma } from '@documenso/prisma'; +import { seedOverflowTestDocument } from '@documenso/prisma/seed/initial-seed'; +import { seedUser } from '@documenso/prisma/seed/users'; + +import { NEXT_PUBLIC_WEBAPP_URL } from '../../../lib/constants/app'; +import { isBase64Image } from '../../../lib/constants/signatures'; +import { createApiToken } from '../../../lib/server-only/public-api/create-api-token'; +import { RecipientRole } from '../../../prisma/generated/types'; +import type { + TCreateEnvelopePayload, + TCreateEnvelopeResponse, +} from '../../../trpc/server/envelope-router/create-envelope.types'; +import type { TDistributeEnvelopeRequest } from '../../../trpc/server/envelope-router/distribute-envelope.types'; +import { OVERFLOW_TEST_FIELDS } from '../../constants/field-overflow-pdf'; +import { apiSignin } from '../fixtures/authentication'; + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); +const baseUrl = `${WEBAPP_BASE_URL}/api/v2`; + +test.describe.configure({ mode: 'parallel', timeout: 60000 }); + +/** + * DON'T COMMIT THIS WITHOUT THE "SKIP" COMMAND. + */ +test.skip('seed overflow test document', async ({ page }) => { + const user = await prisma.user.findFirstOrThrow({ + where: { + email: 'example@documenso.com', + }, + include: { + ownedOrganisations: { + include: { + teams: true, + }, + }, + }, + }); + + const userId = user.id; + const teamId = user.ownedOrganisations[0].teams[0].id; + + await seedOverflowTestDocument({ + userId, + teamId, + recipientName: user.name || '', + recipientEmail: user.email, + insertFields: false, + status: DocumentStatus.DRAFT, + }); +}); + +test('overflow visual regression', async ({ page, request }, testInfo) => { + const { user, team } = await seedUser(); + + const { token } = await createApiToken({ + userId: user.id, + teamId: team.id, + tokenName: 'test', + expiresIn: null, + }); + + // Step 1: Create initial envelope with overflow PDF + const overflowPdf = fs.readFileSync( + path.join(__dirname, '../../../../assets/field-overflow.pdf'), + ); + + const formData = new FormData(); + + const overflowFields = OVERFLOW_TEST_FIELDS.map((field) => ({ + identifier: 'field-overflow', + type: field.type, + page: field.page, + positionX: field.positionX, + positionY: field.positionY, + width: field.width, + height: field.height, + fieldMeta: field.fieldMeta, + })); + + const createEnvelopePayload: TCreateEnvelopePayload = { + type: EnvelopeType.DOCUMENT, + title: 'Overflow Test', + recipients: [ + { + email: user.email, + name: user.name || '', + role: RecipientRole.SIGNER, + fields: overflowFields, + }, + ], + }; + + formData.append('payload', JSON.stringify(createEnvelopePayload)); + formData.append('files', new File([overflowPdf], 'field-overflow', { type: 'application/pdf' })); + + const createEnvelopeRequest = await request.post(`${baseUrl}/envelope/create`, { + headers: { Authorization: `Bearer ${token}` }, + multipart: formData, + }); + + expect(createEnvelopeRequest.ok()).toBeTruthy(); + expect(createEnvelopeRequest.status()).toBe(200); + + const { id: createdEnvelopeId }: TCreateEnvelopeResponse = await createEnvelopeRequest.json(); + + const envelope = await prisma.envelope.findUniqueOrThrow({ + where: { + id: createdEnvelopeId, + }, + include: { + recipients: true, + envelopeItems: true, + }, + }); + + const recipientId = envelope.recipients[0].id; + const overflowItem = envelope.envelopeItems.find((item: { order: number }) => item.order === 1); + + expect(recipientId).toBeDefined(); + expect(overflowItem).toBeDefined(); + + if (!overflowItem) { + throw new Error('Envelope item not found'); + } + + const distributeEnvelopeRequest = await request.post(`${baseUrl}/envelope/distribute`, { + headers: { Authorization: `Bearer ${token}` }, + data: { + envelopeId: envelope.id, + } satisfies TDistributeEnvelopeRequest, + }); + + expect(distributeEnvelopeRequest.ok()).toBeTruthy(); + + const uninsertedFields = await prisma.field.findMany({ + where: { + envelopeId: envelope.id, + OR: [ + { + inserted: false, + }, + { + // Include email fields because they are automatically inserted during envelope distribution. + // We need to extract it to override their values for accurate comparison in tests. + type: FieldType.EMAIL, + }, + ], + }, + include: { + envelopeItem: { + select: { + title: true, + }, + }, + }, + }); + + await Promise.all( + uninsertedFields.map(async (field) => { + const foundField = OVERFLOW_TEST_FIELDS.find( + (f) => + field.page === f.page && + Number(field.positionX).toFixed(2) === f.positionX.toFixed(2) && + Number(field.positionY).toFixed(2) === f.positionY.toFixed(2) && + Number(field.width).toFixed(2) === f.width.toFixed(2) && + Number(field.height).toFixed(2) === f.height.toFixed(2), + ); + + if (!foundField) { + throw new Error('Field not found'); + } + + await prisma.field.update({ + where: { + id: field.id, + }, + data: { + inserted: true, + customText: foundField.customText, + signature: foundField.signature + ? { + create: { + recipientId: envelope.recipients[0].id, + signatureImageAsBase64: isBase64Image(foundField.signature) + ? foundField.signature + : null, + typedSignature: isBase64Image(foundField.signature) ? null : foundField.signature, + }, + } + : undefined, + }, + }); + }), + ); + + // Override email fields with test values after distribution. + // Email fields are auto-inserted with the signer's email during distribution, + // so we override customText directly to test overflow with specific text lengths. + const emailFields = await prisma.field.findMany({ + where: { + envelopeId: envelope.id, + type: FieldType.EMAIL, + }, + }); + + await Promise.all( + emailFields.map(async (field) => { + const foundField = OVERFLOW_TEST_FIELDS.find( + (f) => + f.type === FieldType.EMAIL && + field.page === f.page && + Number(field.positionX).toFixed(2) === f.positionX.toFixed(2) && + Number(field.positionY).toFixed(2) === f.positionY.toFixed(2), + ); + + if (foundField) { + await prisma.field.update({ + where: { id: field.id }, + data: { customText: foundField.customText }, + }); + } + }), + ); + + 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: 10000, + }); + + const completedDocument = await prisma.envelope.findFirstOrThrow({ + where: { + id: envelope.id, + }, + include: { + envelopeItems: { + orderBy: { + order: 'asc', + }, + include: { + documentData: true, + }, + }, + }, + }); + + const storedImages = fs.readdirSync(path.join(__dirname, '../../visual-regression')); + + await Promise.all( + completedDocument.envelopeItems.map(async (item) => { + const documentUrl = getEnvelopeItemPdfUrl({ + type: 'download', + envelopeItem: item, + token: recipientToken, + version: 'signed', + }); + + const pdfData = await fetch(documentUrl).then(async (res) => await res.arrayBuffer()); + + const loadedImages = storedImages + .filter((image) => image.startsWith(`field-overflow-`)) + .sort((leftImage, rightImage) => { + return ( + getVisualRegressionImageIndex(leftImage) - getVisualRegressionImageIndex(rightImage) + ); + }) + .map((image) => fs.readFileSync(path.join(__dirname, '../../visual-regression', image))); + + await compareSignedPdfWithImages({ + id: 'field-overflow', + pdfData: new Uint8Array(pdfData), + images: loadedImages, + testInfo, + }); + }), + ); +}); + +/** + * Used to download the envelope images when updating the visual regression test. + * + * DON'T COMMIT THIS WITHOUT THE "SKIP" COMMAND. + */ +test.skip('download overflow images', async ({ page, request }) => { + const { user, team } = await seedUser(); + + const { token: apiToken } = await createApiToken({ + userId: user.id, + teamId: team.id, + tokenName: 'test', + expiresIn: null, + }); + + const envelope = await seedOverflowTestDocument({ + userId: user.id, + teamId: team.id, + recipientName: user.name || '', + recipientEmail: user.email, + insertFields: true, + status: DocumentStatus.DRAFT, + }); + + const distributeEnvelopeRequest = await request.post(`${baseUrl}/envelope/distribute`, { + headers: { Authorization: `Bearer ${apiToken}` }, + data: { + envelopeId: envelope.id, + } satisfies TDistributeEnvelopeRequest, + }); + + expect(distributeEnvelopeRequest.ok()).toBeTruthy(); + + // Override email fields with test values after distribution. + const emailFields = await prisma.field.findMany({ + where: { + envelopeId: envelope.id, + type: FieldType.EMAIL, + }, + }); + + await Promise.all( + emailFields.map(async (field) => { + const foundField = OVERFLOW_TEST_FIELDS.find( + (f) => + f.type === FieldType.EMAIL && + field.page === f.page && + Number(field.positionX).toFixed(2) === f.positionX.toFixed(2) && + Number(field.positionY).toFixed(2) === f.positionY.toFixed(2), + ); + + if (foundField) { + await prisma.field.update({ + where: { id: field.id }, + data: { customText: foundField.customText }, + }); + } + }), + ); + + const token = envelope.recipients[0].token; + + const signUrl = `/sign/${token}`; + + 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: 10000, + }); + + const completedDocument = await prisma.envelope.findFirstOrThrow({ + where: { + id: envelope.id, + }, + include: { + envelopeItems: { + orderBy: { + order: 'asc', + }, + include: { + documentData: true, + }, + }, + }, + }); + + await Promise.all( + completedDocument.envelopeItems.map(async (item) => { + const documentUrl = getEnvelopeItemPdfUrl({ + type: 'download', + envelopeItem: item, + token, + version: 'signed', + }); + + const pdfData = await fetch(documentUrl).then(async (res) => await res.arrayBuffer()); + + const pdfImages = await renderPdfToImage(new Uint8Array(pdfData)); + + for (const [index, { image }] of pdfImages.entries()) { + fs.writeFileSync( + path.join(__dirname, '../../visual-regression', `field-overflow-${index}.png`), + new Uint8Array(image), + ); + } + }), + ); +}); + +// ============================================================================ +// Helper functions +// ============================================================================ + +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'), + + // Rounded down because the certificate page somehow gives dimensions with decimals + width: Math.floor(viewport.width), + height: Math.floor(viewport.height), + }; + }), + ); +} + +type CompareSignedPdfWithImagesOptions = { + id: string; + pdfData: Uint8Array; + images: Buffer[]; + testInfo: TestInfo; +}; + +const compareSignedPdfWithImages = async ({ + id, + pdfData, + images, + testInfo, +}: CompareSignedPdfWithImagesOptions) => { + const renderedImages = await renderPdfToImage(pdfData); + + expect(images).toHaveLength(renderedImages.length); + + for (const [index, { image, width, height }] of renderedImages.entries()) { + const isCertificate = index === renderedImages.length - 1; + + // Skip certificate page comparison. + if (isCertificate) { + continue; + } + + const diff = new PNG({ width, height }); + + const storedImage = PNG.sync.read(images[index]).data; + + const newImage = PNG.sync.read(image).data; + + const comparison = pixelMatch( + new Uint8Array(storedImage), + new Uint8Array(newImage), + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + diff.data as unknown as Uint8Array, + width, + height, + { + threshold: 0.25, + // includeAA: true, // This allows stricter testing. + }, + ); + console.log(`${id}-${index}: ${comparison}`); + + const diffFilePath = path.join(testInfo.outputPath(), `${id}-${index}-diff.png`); + const oldFilePath = path.join(testInfo.outputPath(), `${id}-${index}-old.png`); + const newFilePath = path.join(testInfo.outputPath(), `${id}-${index}-new.png`); + + fs.writeFileSync(diffFilePath, new Uint8Array(PNG.sync.write(diff))); + fs.writeFileSync(oldFilePath, new Uint8Array(images[index])); + fs.writeFileSync(newFilePath, new Uint8Array(image)); + + expect.soft(comparison).toBeLessThan(2); + } +}; + +const getVisualRegressionImageIndex = (image: string) => { + const match = image.match(/-(\d+)\.png$/); + + if (!match) { + throw new Error(`Unexpected visual regression image name: ${image}`); + } + + return Number(match[1]); +}; diff --git a/packages/app-tests/visual-regression/alignment-pdf-0.png b/packages/app-tests/visual-regression/alignment-pdf-0.png index 27d098da2..a27da7e6d 100644 Binary files a/packages/app-tests/visual-regression/alignment-pdf-0.png and b/packages/app-tests/visual-regression/alignment-pdf-0.png differ diff --git a/packages/app-tests/visual-regression/field-overflow-0.png b/packages/app-tests/visual-regression/field-overflow-0.png new file mode 100644 index 000000000..528535d9e Binary files /dev/null and b/packages/app-tests/visual-regression/field-overflow-0.png differ diff --git a/packages/app-tests/visual-regression/field-overflow-1.png b/packages/app-tests/visual-regression/field-overflow-1.png new file mode 100644 index 000000000..d2eb151b2 Binary files /dev/null and b/packages/app-tests/visual-regression/field-overflow-1.png differ diff --git a/packages/app-tests/visual-regression/field-overflow-2.png b/packages/app-tests/visual-regression/field-overflow-2.png new file mode 100644 index 000000000..0458a52bc Binary files /dev/null and b/packages/app-tests/visual-regression/field-overflow-2.png differ diff --git a/packages/app-tests/visual-regression/field-overflow-3.png b/packages/app-tests/visual-regression/field-overflow-3.png new file mode 100644 index 000000000..df92c91c1 Binary files /dev/null and b/packages/app-tests/visual-regression/field-overflow-3.png differ diff --git a/packages/app-tests/visual-regression/field-overflow-4.png b/packages/app-tests/visual-regression/field-overflow-4.png new file mode 100644 index 000000000..10b342dcf Binary files /dev/null and b/packages/app-tests/visual-regression/field-overflow-4.png differ diff --git a/packages/app-tests/visual-regression/field-overflow-5.png b/packages/app-tests/visual-regression/field-overflow-5.png new file mode 100644 index 000000000..dfac26d32 Binary files /dev/null and b/packages/app-tests/visual-regression/field-overflow-5.png differ diff --git a/packages/app-tests/visual-regression/field-overflow-6.png b/packages/app-tests/visual-regression/field-overflow-6.png new file mode 100644 index 000000000..9f2f6604c Binary files /dev/null and b/packages/app-tests/visual-regression/field-overflow-6.png differ diff --git a/packages/app-tests/visual-regression/field-overflow-7.png b/packages/app-tests/visual-regression/field-overflow-7.png new file mode 100644 index 000000000..3d464f03f Binary files /dev/null and b/packages/app-tests/visual-regression/field-overflow-7.png differ diff --git a/packages/app-tests/visual-regression/field-overflow-8.png b/packages/app-tests/visual-regression/field-overflow-8.png new file mode 100644 index 000000000..b9f6c4e96 Binary files /dev/null and b/packages/app-tests/visual-regression/field-overflow-8.png differ diff --git a/packages/lib/types/field-meta.ts b/packages/lib/types/field-meta.ts index d5b3cbe29..8f756ca18 100644 --- a/packages/lib/types/field-meta.ts +++ b/packages/lib/types/field-meta.ts @@ -16,6 +16,34 @@ export const FIELD_MAX_LETTER_SPACING = 100; export const DEFAULT_FIELD_FONT_SIZE = 12; +export const DEFAULT_SIGNATURE_OVERFLOW_MODE = 'auto'; +export const DEFAULT_DATE_OVERFLOW_MODE = 'auto'; +export const DEFAULT_EMAIL_OVERFLOW_MODE = 'auto'; + +/** + * The overflow mode for a field. + * + * - 'auto': Will overflow horizontally if no room to wrap vertically. + * - 'horizontal': Overflow horizontally, will not wrap at all. + * - 'vertical': Overflow vertically, will wrap at the field width. + * - 'crop': Crop the text to the field bounds, will not overflow at all. + * + * @default 'crop' + */ +export const ZFieldOverflowMode = z.enum(['auto', 'horizontal', 'vertical', 'crop']); +export type TFieldOverflowMode = z.infer; + +/** + * Resolves the overflow mode for a field. + * + * Returns 'crop' when undefined (the default for most fields). + */ +export const resolveFieldOverflowMode = ( + fieldMeta?: { overflow?: TFieldOverflowMode } | null, +): TFieldOverflowMode => { + return fieldMeta?.overflow ?? 'crop'; +}; + /** * Grouped field types that use the same generic text rendering function. */ @@ -47,6 +75,7 @@ export const ZBaseFieldMeta = z.object({ required: z.boolean().optional(), readOnly: z.boolean().optional(), fontSize: z.number().min(8).max(96).default(DEFAULT_FIELD_FONT_SIZE).optional(), + overflow: ZFieldOverflowMode.optional(), }); export type TBaseFieldMeta = z.infer; @@ -72,6 +101,7 @@ export type TNameFieldMeta = z.infer; export const ZEmailFieldMeta = ZBaseFieldMeta.extend({ type: z.literal('email'), textAlign: ZFieldTextAlignSchema.optional(), + overflow: ZFieldOverflowMode.optional().default(DEFAULT_EMAIL_OVERFLOW_MODE), }); export type TEmailFieldMeta = z.infer; @@ -79,6 +109,7 @@ export type TEmailFieldMeta = z.infer; export const ZDateFieldMeta = ZBaseFieldMeta.extend({ type: z.literal('date'), textAlign: ZFieldTextAlignSchema.optional(), + overflow: ZFieldOverflowMode.optional().default(DEFAULT_DATE_OVERFLOW_MODE), }); export type TDateFieldMeta = z.infer; @@ -156,6 +187,7 @@ export type TDropdownFieldMeta = z.infer; export const ZSignatureFieldMeta = ZBaseFieldMeta.extend({ type: z.literal('signature'), + overflow: ZFieldOverflowMode.optional().default(DEFAULT_SIGNATURE_OVERFLOW_MODE), }); export type TSignatureFieldMeta = z.infer; @@ -283,6 +315,7 @@ export const FIELD_DATE_META_DEFAULT_VALUES: TDateFieldMeta = { type: 'date', fontSize: DEFAULT_FIELD_FONT_SIZE, textAlign: 'left', + overflow: DEFAULT_DATE_OVERFLOW_MODE, }; export const FIELD_TEXT_META_DEFAULT_VALUES: TTextFieldMeta = { @@ -322,6 +355,7 @@ export const FIELD_EMAIL_META_DEFAULT_VALUES: TEmailFieldMeta = { type: 'email', fontSize: DEFAULT_FIELD_FONT_SIZE, textAlign: 'left', + overflow: DEFAULT_EMAIL_OVERFLOW_MODE, }; export const FIELD_RADIO_META_DEFAULT_VALUES: TRadioFieldMeta = { @@ -356,6 +390,7 @@ export const FIELD_DROPDOWN_META_DEFAULT_VALUES: TDropdownFieldMeta = { export const FIELD_SIGNATURE_META_DEFAULT_VALUES: TSignatureFieldMeta = { type: 'signature', fontSize: DEFAULT_SIGNATURE_TEXT_FONT_SIZE, + overflow: DEFAULT_SIGNATURE_OVERFLOW_MODE, }; export const FIELD_META_DEFAULT_VALUES: Record = { diff --git a/packages/lib/universal/field-renderer/calculate-overflow-layout.ts b/packages/lib/universal/field-renderer/calculate-overflow-layout.ts new file mode 100644 index 000000000..1b868fc67 --- /dev/null +++ b/packages/lib/universal/field-renderer/calculate-overflow-layout.ts @@ -0,0 +1,340 @@ +import Konva from 'konva'; + +import type { TFieldOverflowMode } from '../../types/field-meta'; + +type OverflowLayoutParams = { + /** The resolved overflow mode ('crop' | 'auto' | 'horizontal' | 'vertical'). */ + overflowMode: TFieldOverflowMode; + + /** True when rendering the field type name (like "Text", "Date", "Email") or a user label, not actual user content. */ + isLabel: boolean; + + /** The text content to render. Used to determine if text overflows the field bounds. */ + textToRender: string; + + /** Font size in pixels. */ + fontSize: number; + + /** CSS font family string. */ + fontFamily: string; + + /** Line height multiplier. */ + lineHeight: number; + + /** Letter spacing in pixels. */ + letterSpacing: number; + + /** Horizontal text alignment. */ + textAlign: 'left' | 'center' | 'right'; + + /** Vertical text alignment. */ + verticalAlign: 'top' | 'middle' | 'bottom'; + + /** Text x position within the group (e.g. padding offset). */ + baseX: number; + + /** Text y position within the group. */ + baseY: number; + + /** Text width at field bounds (fieldWidth minus any padding). */ + baseWidth: number; + + /** Text height at field bounds (fieldHeight). */ + baseHeight: number; + + /** Group x position on the page (fieldX from calculateFieldPosition). */ + groupX: number; + + /** Group y position on the page (fieldY from calculateFieldPosition). */ + groupY: number; + + /** Full page width in pixels. */ + pageWidth: number; + + /** Full page height in pixels. */ + pageHeight: number; +}; + +type OverflowLayoutResult = { + x: number; + y: number; + width: number; + height: number; + wrap: 'word' | 'none'; + textAlign: 'left' | 'center' | 'right'; + verticalAlign: 'top' | 'middle' | 'bottom'; +}; + +/** + * Calculate layout metrics for the text within the field. + * + * Returns: + * - exceedsWidth: whether the unwrapped text exceeds the field width + * - exceedsHeightWhenWrapped: whether wrapping the text at field width exceeds the field height + * - hasRoomForMoreThanOneLine: whether the field can fit 2+ lines of text + */ +const calculateLayout = (params: { + textToRender: string; + fontSize: number; + fontFamily: string; + lineHeight: number; + letterSpacing: number; + baseWidth: number; + baseHeight: number; +}): { + exceedsWidth: boolean; + exceedsHeightWhenWrapped: boolean; + hasRoomForMoreThanOneLine: boolean; +} => { + const { textToRender, fontSize, fontFamily, lineHeight, letterSpacing, baseWidth, baseHeight } = + params; + + // Measure the text without width constraint to get natural width and single-line height. + const unwrappedNode = new Konva.Text({ + text: textToRender, + fontSize, + fontFamily, + lineHeight, + letterSpacing, + }); + + const exceedsWidth = unwrappedNode.width() > baseWidth; + const oneLineHeight = unwrappedNode.height(); + + unwrappedNode.destroy(); + + const hasRoomForMoreThanOneLine = baseHeight >= oneLineHeight * 2; + + // Measure the text wrapped at field width to check vertical overflow. + const wrappedNode = new Konva.Text({ + text: textToRender, + fontSize, + fontFamily, + lineHeight, + letterSpacing, + width: baseWidth, + wrap: 'word', + }); + + const exceedsHeightWhenWrapped = wrappedNode.height() > baseHeight; + + wrappedNode.destroy(); + + return { exceedsWidth, exceedsHeightWhenWrapped, hasRoomForMoreThanOneLine }; +}; + +/** + * Calculate horizontal overflow layout based on text alignment. + * + * The text node is expanded beyond the field bounds toward the page edges. + * - left-aligned: extends rightward to page right edge + * - right-aligned: extends leftward to page left edge + * - center-aligned: extends symmetrically toward the closer page edge + */ +const calculateHorizontalOverflow = (params: OverflowLayoutParams): OverflowLayoutResult => { + const { textAlign, baseX, baseY, baseWidth, baseHeight, groupX, pageWidth } = params; + + if (textAlign === 'right') { + // Extend leftward to page left edge. + // Right edge of text stays at (baseX + baseWidth) within the group. + const newX = -groupX; + const newWidth = groupX + baseX + baseWidth; + + return { + x: newX, + y: baseY, + width: newWidth, + height: baseHeight, + wrap: 'none', + textAlign, + verticalAlign: params.verticalAlign, + }; + } + + if (textAlign === 'center') { + // Extend symmetrically from the text center toward the closer page edge. + const leftSpace = groupX + baseX; + const rightSpace = pageWidth - (groupX + baseX + baseWidth); + const maxExtend = Math.min(leftSpace, rightSpace); + + const newX = baseX - maxExtend; + const newWidth = baseWidth + maxExtend * 2; + + return { + x: newX, + y: baseY, + width: newWidth, + height: baseHeight, + wrap: 'none', + textAlign, + verticalAlign: params.verticalAlign, + }; + } + + // Default: left-aligned — extend rightward to page right edge. + const newWidth = pageWidth - groupX - baseX; + + return { + x: baseX, + y: baseY, + width: newWidth, + height: baseHeight, + wrap: 'none', + textAlign, + verticalAlign: params.verticalAlign, + }; +}; + +/** + * Calculate vertical overflow layout based on vertical alignment. + * + * The text node keeps the field width (text wraps) and expands height toward the page edges. + * - top aligned: extends downward to page bottom + * - bottom aligned: extends upward to page top + * - middle aligned: extends symmetrically up and down toward the closer page edge + */ +const calculateVerticalOverflow = (params: OverflowLayoutParams): OverflowLayoutResult => { + const { verticalAlign, textAlign, baseX, baseY, baseWidth, baseHeight, groupY, pageHeight } = + params; + + if (verticalAlign === 'bottom') { + // Extend upward to page top edge. + // Bottom edge of text stays at (baseY + baseHeight) within the group. + const newY = -groupY; + const newHeight = groupY + baseY + baseHeight; + + return { + x: baseX, + y: newY, + width: baseWidth, + height: newHeight, + wrap: 'word', + textAlign, + verticalAlign: 'bottom', + }; + } + + if (verticalAlign === 'middle') { + // Extend both up and down from the field center. + // Text stays vertically centered at the original field position. + const upSpace = groupY + baseY; + const downSpace = pageHeight - (groupY + baseY + baseHeight); + const maxExtend = Math.min(upSpace, downSpace); + + const newY = baseY - maxExtend; + const newHeight = baseHeight + maxExtend * 2; + + return { + x: baseX, + y: newY, + width: baseWidth, + height: newHeight, + wrap: 'word', + textAlign, + verticalAlign: 'middle', + }; + } + + // Default: top — extend downward to page bottom edge. + const newHeight = pageHeight - groupY - baseY; + + return { + x: baseX, + y: baseY, + width: baseWidth, + height: newHeight, + wrap: 'word', + textAlign, + verticalAlign: 'top', + }; +}; + +/** + * Calculate overflow-aware text layout dimensions. + * + * Returns { x, y, width, height, wrap } to spread into a Konva.Text setAttrs() call. + * + * For 'crop' mode or placeholder content, returns the original field bounds (current behavior). + * For 'horizontal'/'vertical'/'auto', expands the text node dimensions toward the page edges + * based on text alignment and field position. + */ +export const calculateOverflowLayout = (params: OverflowLayoutParams): OverflowLayoutResult => { + const { overflowMode, isLabel, baseX, baseY, baseWidth, baseHeight } = params; + + // No overflow for placeholders or crop mode — return original field bounds. + if (isLabel || overflowMode === 'crop') { + return { + x: baseX, + y: baseY, + width: baseWidth, + height: baseHeight, + wrap: 'word', + textAlign: params.textAlign, + verticalAlign: params.verticalAlign, + }; + } + + if (overflowMode === 'horizontal') { + return calculateHorizontalOverflow(params); + } + + if (overflowMode === 'vertical') { + return calculateVerticalOverflow(params); + } + + // Auto mode: measure the text and field to decide overflow direction. + const layout = calculateLayout({ + textToRender: params.textToRender, + fontSize: params.fontSize, + fontFamily: params.fontFamily, + lineHeight: params.lineHeight, + letterSpacing: params.letterSpacing, + baseWidth, + baseHeight, + }); + + // Auto single-line: overflow horizontal only when text exceeds field width. + // Center text align is overridden to left so it overflows right. + if (!layout.hasRoomForMoreThanOneLine) { + if (!layout.exceedsWidth) { + // Text fits — keep original alignment, no overflow needed. + return { + x: baseX, + y: baseY, + width: baseWidth, + height: baseHeight, + wrap: 'none', + textAlign: params.textAlign, + verticalAlign: params.verticalAlign, + }; + } + + return calculateHorizontalOverflow({ + ...params, + textAlign: params.textAlign === 'center' ? 'left' : params.textAlign, + }); + } + + // Auto multi-line: overflow vertical only when wrapped text exceeds field height. + // Middle vertical align is only overridden to top if the text actually overflows vertically. + // If it fits, keep middle so the text stays centered within the field. + if (!layout.exceedsHeightWhenWrapped) { + // Text fits when wrapped — keep original alignment, no overflow needed. + return { + x: baseX, + y: baseY, + width: baseWidth, + height: baseHeight, + wrap: 'word', + textAlign: params.textAlign, + verticalAlign: params.verticalAlign, + }; + } + + const verticalAlignOverride = params.verticalAlign === 'middle' ? 'top' : params.verticalAlign; + + return calculateVerticalOverflow({ + ...params, + verticalAlign: verticalAlignOverride, + }); +}; diff --git a/packages/lib/universal/field-renderer/render-generic-text-field.ts b/packages/lib/universal/field-renderer/render-generic-text-field.ts index 6b90e48da..42c8ded73 100644 --- a/packages/lib/universal/field-renderer/render-generic-text-field.ts +++ b/packages/lib/universal/field-renderer/render-generic-text-field.ts @@ -7,7 +7,9 @@ import { FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN, FIELD_DEFAULT_LETTER_SPACING, FIELD_DEFAULT_LINE_HEIGHT, + resolveFieldOverflowMode, } from '../../types/field-meta'; +import { calculateOverflowLayout } from './calculate-overflow-layout'; import { createFieldHoverInteraction, konvaTextFill, @@ -20,10 +22,14 @@ import { calculateFieldPosition } from './field-renderer'; const DEFAULT_TEXT_X_PADDING = 6; -const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOptions): Konva.Text => { +const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOptions) => { const { pageWidth, pageHeight, mode = 'edit', pageLayer, translations } = options; - const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight); + const { fieldX, fieldY, fieldWidth, fieldHeight } = calculateFieldPosition( + field, + pageWidth, + pageHeight, + ); const fieldMeta = field.fieldMeta as GenericTextFieldTypeMetas | undefined; @@ -41,7 +47,10 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption const textY = 0; const textFontSize = fieldMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE; - // By default, render the field name or label centered + // By default, render the field name or label centered. + // isLabel tracks whether we're rendering the field type name (like "Text", "Date", "Email") + // or a user label — overflow should not apply to these, only to actual content. + let isLabel = true; let textToRender: string = fieldMeta?.label || fieldTypeName; let textAlign: 'left' | 'center' | 'right' = 'center'; let textVerticalAlign: 'top' | 'middle' | 'bottom' = FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN; @@ -53,6 +62,7 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption const value = fieldMeta?.type === 'text' ? fieldMeta.text : fieldMeta.value; if (value) { + isLabel = false; textToRender = value; textVerticalAlign = fieldMeta.verticalAlign || FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN; @@ -73,6 +83,7 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption const value = fieldMeta?.type === 'text' ? fieldMeta.text : fieldMeta.value; if (value) { + isLabel = false; textToRender = value; textVerticalAlign = fieldMeta.verticalAlign || FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN; @@ -84,6 +95,7 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption // Override everything with value if it's inserted. if (field.inserted) { + isLabel = false; textToRender = field.customText; textAlign = fieldMeta?.textAlign || FIELD_DEFAULT_GENERIC_ALIGN; @@ -95,25 +107,54 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption } } + const overflowLayout = calculateOverflowLayout({ + overflowMode: resolveFieldOverflowMode(fieldMeta), + isLabel, + textToRender, + fontSize: textFontSize, + fontFamily: konvaTextFontFamily, + lineHeight: textLineHeight, + letterSpacing: textLetterSpacing, + textAlign, + verticalAlign: textVerticalAlign, + baseX: textX + DEFAULT_TEXT_X_PADDING, + baseY: textY, + baseWidth: fieldWidth - DEFAULT_TEXT_X_PADDING * 2, + baseHeight: fieldHeight, + groupX: fieldX, + groupY: fieldY, + pageWidth, + pageHeight, + }); + // Note: Do not use native text padding since it's uniform. // We only want to have padding on the left and right hand sides. fieldText.setAttrs({ - x: textX + DEFAULT_TEXT_X_PADDING, - y: textY, - verticalAlign: textVerticalAlign, - wrap: 'word', + x: overflowLayout.x, + y: overflowLayout.y, + verticalAlign: overflowLayout.verticalAlign, + wrap: overflowLayout.wrap, text: textToRender, fontSize: textFontSize, - align: textAlign, + align: overflowLayout.textAlign, lineHeight: textLineHeight, letterSpacing: textLetterSpacing, fontFamily: konvaTextFontFamily, fill: konvaTextFill, - width: fieldWidth - DEFAULT_TEXT_X_PADDING * 2, - height: fieldHeight, + width: overflowLayout.width, + height: overflowLayout.height, } satisfies Partial); - return fieldText; + return { + fieldText, + isLabel, + textToRender, + textFontSize, + textAlign, + textVerticalAlign, + textLineHeight, + textLetterSpacing, + }; }; export const renderGenericTextFieldElement = ( @@ -121,6 +162,8 @@ export const renderGenericTextFieldElement = ( options: RenderFieldElementOptions, ) => { const { mode = 'edit', pageLayer, color } = options; + const { pageWidth, pageHeight } = options; + const fieldMeta = field.fieldMeta as GenericTextFieldTypeMetas | undefined; const isFirstRender = !pageLayer.findOne(`#${field.renderId}`); @@ -136,13 +179,20 @@ export const renderGenericTextFieldElement = ( // Render the field background and text. const fieldRect = upsertFieldRect(field, options); - const fieldText = upsertFieldText(field, options); + const { + fieldText, + isLabel, + textToRender, + textFontSize, + textAlign, + textVerticalAlign, + textLineHeight, + textLetterSpacing, + } = upsertFieldText(field, options); fieldGroup.add(fieldRect); fieldGroup.add(fieldText); - // This is to keep the text inside the field at the same size - // when the field is resized. Without this the text would be stretched. fieldGroup.on('transform', () => { const groupScaleX = fieldGroup.scaleX(); const groupScaleY = fieldGroup.scaleY(); @@ -154,17 +204,16 @@ export const renderGenericTextFieldElement = ( const rectWidth = fieldRect.width() * groupScaleX; const rectHeight = fieldRect.height() * groupScaleY; - // Update text dimensions + // During active transform, use crop dimensions (field bounds only). + fieldText.x(DEFAULT_TEXT_X_PADDING); + fieldText.y(0); fieldText.width(rectWidth - DEFAULT_TEXT_X_PADDING * 2); fieldText.height(rectHeight); - - // Force Konva to recalculate text layout - fieldText.height(); + fieldText.wrap('word'); fieldGroup.getLayer()?.batchDraw(); }); - // Reset the text after transform has ended. fieldGroup.on('transformend', () => { fieldText.scaleX(1); fieldText.scaleY(1); @@ -172,12 +221,33 @@ export const renderGenericTextFieldElement = ( const rectWidth = fieldRect.width(); const rectHeight = fieldRect.height(); - // Update text dimensions - fieldText.width(rectWidth - DEFAULT_TEXT_X_PADDING * 2); - fieldText.height(rectHeight); + // Recalculate overflow layout with new field dimensions. + const newOverflowLayout = calculateOverflowLayout({ + overflowMode: resolveFieldOverflowMode(fieldMeta), + isLabel, + textToRender, + fontSize: textFontSize, + fontFamily: konvaTextFontFamily, + lineHeight: textLineHeight, + letterSpacing: textLetterSpacing, + textAlign, + verticalAlign: textVerticalAlign, + baseX: DEFAULT_TEXT_X_PADDING, + baseY: 0, + baseWidth: rectWidth - DEFAULT_TEXT_X_PADDING * 2, + baseHeight: rectHeight, + groupX: fieldGroup.x(), + groupY: fieldGroup.y(), + pageWidth, + pageHeight, + }); - // Force Konva to recalculate text layout - fieldText.height(); + fieldText.x(newOverflowLayout.x); + fieldText.y(newOverflowLayout.y); + fieldText.width(newOverflowLayout.width); + fieldText.height(newOverflowLayout.height); + fieldText.wrap(newOverflowLayout.wrap); + fieldText.verticalAlign(newOverflowLayout.verticalAlign); fieldGroup.getLayer()?.batchDraw(); }); diff --git a/packages/lib/universal/field-renderer/render-signature-field.ts b/packages/lib/universal/field-renderer/render-signature-field.ts index bd7daa6c1..2799d8da9 100644 --- a/packages/lib/universal/field-renderer/render-signature-field.ts +++ b/packages/lib/universal/field-renderer/render-signature-field.ts @@ -2,6 +2,9 @@ import Konva from 'konva'; import { DEFAULT_SIGNATURE_TEXT_FONT_SIZE } from '../../constants/pdf'; import { AppError } from '../../errors/app-error'; +import type { TSignatureFieldMeta } from '../../types/field-meta'; +import { resolveFieldOverflowMode } from '../../types/field-meta'; +import { calculateOverflowLayout } from './calculate-overflow-layout'; import { createFieldHoverInteraction, upsertFieldGroup, @@ -40,6 +43,18 @@ const getImageDimensions = (img: HTMLImageElement, fieldWidth: number, fieldHeig }; }; +type FieldSignature = + | { + node: Konva.Text; + isImageSignature: false; + isLabel: boolean; + } + | { + node: Konva.Image; + isImageSignature: true; + isLabel: boolean; + }; + /** * The pixel ratio used when caching the signature image as an offscreen bitmap. * @@ -108,10 +123,14 @@ const createSignatureImage = ( const createFieldSignature = ( field: FieldToRender, options: RenderFieldElementOptions, -): Konva.Text | Konva.Image => { +): FieldSignature => { const { pageWidth, pageHeight, mode = 'edit', translations } = options; - const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight); + const { fieldX, fieldY, fieldWidth, fieldHeight } = calculateFieldPosition( + field, + pageWidth, + pageHeight, + ); const fontSize = field.fieldMeta?.fontSize || DEFAULT_SIGNATURE_TEXT_FONT_SIZE; const fieldText = new Konva.Text({ @@ -140,7 +159,11 @@ const createFieldSignature = ( } if (field.inserted && signature?.signatureImageAsBase64) { - return createSignatureImage(signature.signatureImageAsBase64, fieldWidth, fieldHeight); + return { + node: createSignatureImage(signature.signatureImageAsBase64, fieldWidth, fieldHeight), + isImageSignature: true, + isLabel: false, + }; } } @@ -157,31 +180,61 @@ const createFieldSignature = ( } if (signature?.signatureImageAsBase64) { - return createSignatureImage(signature.signatureImageAsBase64, fieldWidth, fieldHeight); + return { + node: createSignatureImage(signature.signatureImageAsBase64, fieldWidth, fieldHeight), + isImageSignature: true, + isLabel: false, + }; } } - fieldText.setAttrs({ - x: textX, - y: textY, + const fieldMeta = field.fieldMeta as TSignatureFieldMeta | undefined; + + // Whether we're rendering the field type name (like "Signature") vs actual signed content. + // Overflow should not apply to the label. + const isLabel = !signature?.typedSignature; + + const overflowLayout = calculateOverflowLayout({ + overflowMode: resolveFieldOverflowMode(fieldMeta), + isLabel, + textToRender, + fontSize, + fontFamily: 'Caveat, sans-serif', + lineHeight: 1, + letterSpacing: 0, + textAlign: 'center', verticalAlign: 'middle', - wrap: 'char', + baseX: textX, + baseY: textY, + baseWidth: fieldWidth, + baseHeight: fieldHeight, + groupX: fieldX, + groupY: fieldY, + pageWidth, + pageHeight, + }); + + fieldText.setAttrs({ + x: overflowLayout.x, + y: overflowLayout.y, + verticalAlign: overflowLayout.verticalAlign, + wrap: overflowLayout.wrap, text: textToRender, fontSize, fontFamily: 'Caveat, sans-serif', - align: 'center', - width: fieldWidth, - height: fieldHeight, + align: overflowLayout.textAlign, + width: overflowLayout.width, + height: overflowLayout.height, } satisfies Partial); - return fieldText; + return { node: fieldText, isImageSignature: false, isLabel }; }; export const renderSignatureFieldElement = ( field: FieldToRender, options: RenderFieldElementOptions, ) => { - const { mode = 'edit', pageLayer, color } = options; + const { mode = 'edit', pageLayer, pageWidth, pageHeight, color } = options; const isFirstRender = !pageLayer.findOne(`#${field.renderId}`); @@ -198,13 +251,11 @@ export const renderSignatureFieldElement = ( // Render the field background and text. const fieldRect = upsertFieldRect(field, options); - const fieldSignature = createFieldSignature(field, options); + const { node: fieldSignature, isImageSignature, isLabel } = createFieldSignature(field, options); fieldGroup.add(fieldRect); fieldGroup.add(fieldSignature); - // This is to keep the text inside the field at the same size - // when the field is resized. Without this the text would be stretched. fieldGroup.on('transform', () => { const groupScaleX = fieldGroup.scaleX(); const groupScaleY = fieldGroup.scaleY(); @@ -216,17 +267,19 @@ export const renderSignatureFieldElement = ( const rectWidth = fieldRect.width() * groupScaleX; const rectHeight = fieldRect.height() * groupScaleY; - // Update text dimensions + // During active transform, use crop dimensions (field bounds only). + if (!isImageSignature) { + fieldSignature.x(0); + fieldSignature.y(0); + fieldSignature.wrap('word'); + } + fieldSignature.width(rectWidth); fieldSignature.height(rectHeight); - // Force Konva to recalculate text layout - fieldSignature.height(); - fieldGroup.getLayer()?.batchDraw(); }); - // Reset the text after transform has ended. fieldGroup.on('transformend', () => { fieldSignature.scaleX(1); fieldSignature.scaleY(1); @@ -234,12 +287,39 @@ export const renderSignatureFieldElement = ( const rectWidth = fieldRect.width(); const rectHeight = fieldRect.height(); - // Update text dimensions - fieldSignature.width(rectWidth); // Account for padding - fieldSignature.height(rectHeight); + if (!isImageSignature) { + const fieldMeta = field.fieldMeta as TSignatureFieldMeta | undefined; - // Force Konva to recalculate text layout - fieldSignature.height(); + const newOverflowLayout = calculateOverflowLayout({ + overflowMode: resolveFieldOverflowMode(fieldMeta), + isLabel, + textToRender: fieldSignature.text(), + fontSize: fieldSignature.fontSize(), + fontFamily: 'Caveat, sans-serif', + lineHeight: 1, + letterSpacing: 0, + textAlign: 'center', + verticalAlign: 'middle', + baseX: 0, + baseY: 0, + baseWidth: rectWidth, + baseHeight: rectHeight, + groupX: fieldGroup.x(), + groupY: fieldGroup.y(), + pageWidth, + pageHeight, + }); + + fieldSignature.x(newOverflowLayout.x); + fieldSignature.y(newOverflowLayout.y); + fieldSignature.width(newOverflowLayout.width); + fieldSignature.height(newOverflowLayout.height); + fieldSignature.wrap(newOverflowLayout.wrap); + fieldSignature.verticalAlign(newOverflowLayout.verticalAlign); + } else { + fieldSignature.width(rectWidth); + fieldSignature.height(rectHeight); + } fieldGroup.getLayer()?.batchDraw(); }); diff --git a/packages/prisma/seed/initial-seed.ts b/packages/prisma/seed/initial-seed.ts index fe77dc870..7f3653590 100644 --- a/packages/prisma/seed/initial-seed.ts +++ b/packages/prisma/seed/initial-seed.ts @@ -3,6 +3,7 @@ import path from 'node:path'; import { ALIGNMENT_TEST_FIELDS } from '@documenso/app-tests/constants/field-alignment-pdf'; import { FIELD_META_TEST_FIELDS } from '@documenso/app-tests/constants/field-meta-pdf'; +import { OVERFLOW_TEST_FIELDS } from '@documenso/app-tests/constants/field-overflow-pdf'; import { isBase64Image } from '@documenso/lib/constants/signatures'; import { incrementDocumentId, @@ -183,6 +184,22 @@ export const seedDatabase = async () => { userId: adminUser.user.id, teamId: adminUser.team.id, }), + seedOverflowTestDocument({ + userId: exampleUser.user.id, + teamId: exampleUser.team.id, + recipientName: exampleUser.user.name || '', + recipientEmail: exampleUser.user.email, + insertFields: false, + status: DocumentStatus.DRAFT, + }), + seedOverflowTestDocument({ + userId: adminUser.user.id, + teamId: adminUser.team.id, + recipientName: adminUser.user.name || '', + recipientEmail: adminUser.user.email, + insertFields: false, + status: DocumentStatus.DRAFT, + }), seedAlignmentTestDocument({ userId: exampleUser.user.id, teamId: exampleUser.team.id, @@ -443,3 +460,123 @@ export const seedAlignmentTestDocument = async ({ }, }); }; + +export const seedOverflowTestDocument = async ({ + userId, + teamId, + recipientName, + recipientEmail, + insertFields, + status, +}: { + userId: number; + teamId: number; + recipientName: string; + recipientEmail: string; + insertFields: boolean; + status: DocumentStatus; +}) => { + const overflowPdf = fs + .readFileSync(path.join(__dirname, '../../../assets/field-overflow.pdf')) + .toString('base64'); + + const overflowDocumentData = await createDocumentData({ documentData: overflowPdf }); + + const secondaryId = await incrementDocumentId().then((v) => v.formattedDocumentId); + + const documentMeta = await prisma.documentMeta.create({ + data: {}, + }); + + const createdEnvelope = await prisma.envelope.create({ + data: { + id: prefixedId('envelope'), + secondaryId, + internalVersion: 2, + type: EnvelopeType.DOCUMENT, + documentMetaId: documentMeta.id, + source: DocumentSource.DOCUMENT, + title: 'Overflow Test', + status, + envelopeItems: { + createMany: { + data: [ + { + id: prefixedId('envelope_item'), + title: 'field-overflow', + documentDataId: overflowDocumentData.id, + order: 1, + }, + ], + }, + }, + userId, + teamId, + recipients: { + create: { + name: recipientName, + email: recipientEmail, + token: nanoid(), + sendStatus: status === 'DRAFT' ? SendStatus.NOT_SENT : SendStatus.SENT, + signingStatus: status === 'COMPLETED' ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED, + readStatus: status !== 'DRAFT' ? ReadStatus.OPENED : ReadStatus.NOT_OPENED, + }, + }, + }, + include: { + recipients: true, + envelopeItems: true, + }, + }); + + const { id, recipients, envelopeItems } = createdEnvelope; + + const recipientId = recipients[0].id; + const envelopeItemId = envelopeItems.find((item) => item.order === 1)?.id; + + if (!envelopeItemId) { + throw new Error('Envelope item not found'); + } + + await Promise.all( + OVERFLOW_TEST_FIELDS.map(async (field) => { + // Use seedFieldMeta (full meta with all defaults) instead of fieldMeta + // (minimal meta for API testing) since the seed bypasses API validation. + const { fieldMeta: _fieldMeta, seedFieldMeta, ...fieldData } = field; + + await prisma.field.create({ + data: { + ...fieldData, + fieldMeta: seedFieldMeta, + recipientId, + envelopeItemId, + envelopeId: id, + customText: insertFields ? field.customText : '', + inserted: + insertFields && + ((!seedFieldMeta?.readOnly && Boolean(field.customText)) || field.type === 'SIGNATURE'), + signature: + field.signature && insertFields + ? { + create: { + recipientId, + signatureImageAsBase64: isBase64Image(field.signature) ? field.signature : null, + typedSignature: isBase64Image(field.signature) ? null : field.signature, + }, + } + : undefined, + }, + }); + }), + ); + + return await prisma.envelope.findFirstOrThrow({ + where: { + id: createdEnvelope.id, + }, + include: { + recipients: true, + envelopeItems: true, + }, + }); +}; diff --git a/packages/ui/primitives/document-flow/field-item-advanced-settings.tsx b/packages/ui/primitives/document-flow/field-item-advanced-settings.tsx index 0386662f1..ca4842ced 100644 --- a/packages/ui/primitives/document-flow/field-item-advanced-settings.tsx +++ b/packages/ui/primitives/document-flow/field-item-advanced-settings.tsx @@ -10,6 +10,8 @@ import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave'; import { type TBaseFieldMeta as BaseFieldMeta, type TCheckboxFieldMeta as CheckboxFieldMeta, + DEFAULT_DATE_OVERFLOW_MODE, + DEFAULT_EMAIL_OVERFLOW_MODE, type TDateFieldMeta as DateFieldMeta, type TDropdownFieldMeta as DropdownFieldMeta, type TEmailFieldMeta as EmailFieldMeta, @@ -83,12 +85,14 @@ const getDefaultState = (fieldType: FieldType): FieldMeta => { type: 'email', fontSize: 14, textAlign: 'left', + overflow: DEFAULT_EMAIL_OVERFLOW_MODE, }; case FieldType.DATE: return { type: 'date', fontSize: 14, textAlign: 'left', + overflow: DEFAULT_DATE_OVERFLOW_MODE, }; case FieldType.TEXT: return { @@ -186,7 +190,6 @@ export const FieldAdvancedSettings = forwardRef {})); diff --git a/packages/ui/primitives/document-flow/field-items-advanced-settings/date-field.tsx b/packages/ui/primitives/document-flow/field-items-advanced-settings/date-field.tsx index 20c061e02..05a9325c4 100644 --- a/packages/ui/primitives/document-flow/field-items-advanced-settings/date-field.tsx +++ b/packages/ui/primitives/document-flow/field-items-advanced-settings/date-field.tsx @@ -1,7 +1,10 @@ import { Trans, useLingui } from '@lingui/react/macro'; import { validateFields as validateDateFields } from '@documenso/lib/advanced-fields-validation/validate-fields'; -import { type TDateFieldMeta as DateFieldMeta } from '@documenso/lib/types/field-meta'; +import { + DEFAULT_DATE_OVERFLOW_MODE, + type TDateFieldMeta as DateFieldMeta, +} from '@documenso/lib/types/field-meta'; import { Input } from '@documenso/ui/primitives/input'; import { Label } from '@documenso/ui/primitives/label'; import { @@ -48,6 +51,7 @@ export const DateFieldAdvancedSettings = ({ const errors = validateDateFields({ fontSize, + overflow: fieldState.overflow ?? DEFAULT_DATE_OVERFLOW_MODE, type: 'date', }); @@ -64,7 +68,7 @@ export const DateFieldAdvancedSettings = ({ handleInput('fontSize', e.target.value)} @@ -82,7 +86,7 @@ export const DateFieldAdvancedSettings = ({ value={fieldState.textAlign} onValueChange={(value) => handleInput('textAlign', value)} > - + diff --git a/packages/ui/primitives/document-flow/field-items-advanced-settings/email-field.tsx b/packages/ui/primitives/document-flow/field-items-advanced-settings/email-field.tsx index e7123d0c5..579c5fab6 100644 --- a/packages/ui/primitives/document-flow/field-items-advanced-settings/email-field.tsx +++ b/packages/ui/primitives/document-flow/field-items-advanced-settings/email-field.tsx @@ -1,7 +1,10 @@ import { Trans, useLingui } from '@lingui/react/macro'; import { validateFields as validateEmailFields } from '@documenso/lib/advanced-fields-validation/validate-fields'; -import { type TEmailFieldMeta as EmailFieldMeta } from '@documenso/lib/types/field-meta'; +import { + DEFAULT_EMAIL_OVERFLOW_MODE, + type TEmailFieldMeta as EmailFieldMeta, +} from '@documenso/lib/types/field-meta'; import { Input } from '@documenso/ui/primitives/input'; import { Label } from '@documenso/ui/primitives/label'; import { @@ -30,6 +33,7 @@ export const EmailFieldAdvancedSettings = ({ const errors = validateEmailFields({ fontSize, + overflow: fieldState.overflow ?? DEFAULT_EMAIL_OVERFLOW_MODE, type: 'email', }); @@ -46,7 +50,7 @@ export const EmailFieldAdvancedSettings = ({ handleInput('fontSize', e.target.value)} @@ -64,7 +68,7 @@ export const EmailFieldAdvancedSettings = ({ value={fieldState.textAlign} onValueChange={(value) => handleInput('textAlign', value)} > - + diff --git a/scripts/generate-overflow-test-pdf.mjs b/scripts/generate-overflow-test-pdf.mjs new file mode 100644 index 000000000..1e7a0f7f3 --- /dev/null +++ b/scripts/generate-overflow-test-pdf.mjs @@ -0,0 +1,260 @@ +// scripts/generate-overflow-test-pdf.mjs +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { chromium } from 'playwright'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// --- Helper functions to generate HTML for each page --- + +function box(left, top, width, height) { + return `left:${left}%;top:${top}%;width:${width}%;height:${height}%;`; +} + +function labelStyle(left, top) { + return `left:${left}%;top:${top}%;`; +} + +function makeBox(left, top, width, height, label, { labelBelow = false } = {}) { + const labelTop = labelBelow ? top + height + 0.5 : top - 1.5; + return ` +
${label}
+
`; +} + +// --- Pages 1-2: Date / Email (3×3 multi-line grid with text align variants) --- + +function makeFieldOverflowPageWithGrid(title) { + const startX = 10; + const boxWidth = 35; + + // Section A: Single-line height + const slHeight = 1.75; + const slStartY = 15; + const slRowSpacing = 8; + const sectionARows = [ + { y: slStartY, label: 'M_AUTO TA_LEFT VA_MIDDLE' }, + { y: slStartY + slRowSpacing, label: 'M_AUTO TA_LEFT VA_MIDDLE' }, + { y: slStartY + 2 * slRowSpacing, label: 'M_AUTO TA_LEFT VA_MIDDLE' }, + ]; + + // Section B: Multi-line height — 3×3 grid + // Rows: TA_LEFT, TA_CENTER, TA_RIGHT + // Columns: short, medium, long text + const mlHeight = 12; + const mlBoxWidth = 30; + const mlColumnX = [2.5, 35, 67.5]; + const mlRowY = [45, 63, 83]; + const mlTextAligns = ['LEFT', 'CENTER', 'RIGHT']; + + let content = ` +
${title}
`; + + for (const r of sectionARows) { + content += makeBox(startX, r.y, boxWidth, slHeight, r.label); + } + + for (let ri = 0; ri < 3; ri++) { + for (let ci = 0; ci < 3; ci++) { + const label = `M_AUTO TA_${mlTextAligns[ri]} VA_MIDDLE`; + content += makeBox(mlColumnX[ci], mlRowY[ri], mlBoxWidth, mlHeight, label); + } + } + + return content; +} + +// --- Page 3: Signature (stacked multi-line, no text align control) --- + +function makeSignatureOverflowPage(title) { + const startX = 10; + const boxWidth = 35; + + // Section A: Single-line height + const slHeight = 1.75; + const slStartY = 15; + const slRowSpacing = 8; + const sectionARows = [ + { y: slStartY, label: 'M_AUTO TA_CENTER VA_MIDDLE' }, + { y: slStartY + slRowSpacing, label: 'M_AUTO TA_CENTER VA_MIDDLE' }, + { y: slStartY + 2 * slRowSpacing, label: 'M_AUTO TA_CENTER VA_MIDDLE' }, + ]; + + // Section B: Multi-line height — stacked single column + const mlHeight = 12; + const mlStartY = 45; + const mlRowSpacing = 18; + const sectionBRows = [ + { y: mlStartY, label: 'M_AUTO TA_CENTER VA_MIDDLE' }, + { y: mlStartY + mlRowSpacing, label: 'M_AUTO TA_CENTER VA_MIDDLE' }, + { y: mlStartY + 2 * mlRowSpacing, label: 'M_AUTO TA_CENTER VA_MIDDLE' }, + ]; + + let content = ` +
${title}
`; + + for (const r of sectionARows) { + content += makeBox(startX, r.y, boxWidth, slHeight, r.label); + } + + for (const r of sectionBRows) { + content += makeBox(startX, r.y, boxWidth, mlHeight, r.label); + } + + return content; +} + +// --- Pages 4-5: Text Field Auto Mode --- + +function makeTextAutoPage(title, boxHeight, isSingleLine) { + const boxWidth = 28; + + const columns = [ + { col: 0, x: 5, textAlign: 'left' }, + { col: 1, x: 35.5, textAlign: 'center' }, + { col: 2, x: 66, textAlign: 'right' }, + ]; + + const verticalAligns = ['top', 'middle', 'bottom']; + + let content = ` +
${title}
`; + + if (isSingleLine) { + // Single-line: stagger all 9 items evenly down the page. + // Each item gets its own Y position to avoid horizontal overflow collision. + const itemCount = 9; // 3 rows × 3 columns + const startY = 10; + const endY = 92; + const spacing = (endY - startY) / (itemCount - 1); + + let itemIndex = 0; + for (let ri = 0; ri < 3; ri++) { + for (let ci = 0; ci < columns.length; ci++) { + const col = columns[ci]; + const y = startY + itemIndex * spacing; + const label = `M_AUTO TA_${col.textAlign.toUpperCase()} VA_${verticalAligns[ri].toUpperCase()}`; + content += makeBox(col.x, y, boxWidth, boxHeight, label); + itemIndex++; + } + } + } else { + // Multi-line: 3 rows evenly spaced, bottom row near page bottom. + // Box is 12% tall. Top of last box at 80% so bottom edge is at 92%. + const startY = 10; + const lastRowY = 80; // 80 + 12 = 92% (page bottom with padding) + const midY = (startY + lastRowY) / 2; // 45% + const rowYPositions = [startY, midY, lastRowY]; + + for (let ri = 0; ri < 3; ri++) { + const labelBelow = verticalAligns[ri] === 'bottom'; + + for (const col of columns) { + const label = `M_AUTO TA_${col.textAlign.toUpperCase()} VA_${verticalAligns[ri].toUpperCase()}`; + content += makeBox(col.x, rowYPositions[ri], boxWidth, boxHeight, label, { labelBelow }); + } + } + } + + return content; +} + +// --- Page 6: Explicit Modes --- + +function makePage6() { + const boxWidth = 25; + + let content = ` +
Text Field Explicit Modes
+`; + + // Section A: Horizontal mode — centered on page, staggered vertically + const centeredX = (100 - boxWidth) / 2; // 37.5% + const horizontalRows = [ + { x: centeredX, y: 15, label: 'M_HORIZONTAL TA_LEFT VA_TOP' }, + { x: centeredX, y: 21, label: 'M_HORIZONTAL TA_CENTER VA_TOP' }, + { x: centeredX, y: 27, label: 'M_HORIZONTAL TA_RIGHT VA_TOP' }, + ]; + + for (const r of horizontalRows) { + content += makeBox(r.x, r.y, boxWidth, 1.75, r.label); + } + + // Section B: Vertical mode — place side by side so vertical overflow has room below + content += ''; + + const verticalCols = [ + { x: 5, y: 43, label: 'M_VERTICAL TA_LEFT VA_TOP', labelBelow: false }, + { x: 37.5, y: 43, label: 'M_VERTICAL TA_LEFT VA_MIDDLE', labelBelow: false }, + { x: 70, y: 43, label: 'M_VERTICAL TA_LEFT VA_BOTTOM', labelBelow: true }, + ]; + + for (const c of verticalCols) { + content += makeBox(c.x, c.y, boxWidth, 12, c.label, { labelBelow: c.labelBelow }); + } + + return content; +} + +// --- Page 7: Crop Mode --- + +function makePage7() { + return ` +
Crop Mode (no overflow)
+ ${makeBox(10, 15, 25, 1.75, 'M_CROP TA_LEFT VA_TOP')} + ${makeBox(10, 30, 25, 12, 'M_CROP TA_LEFT VA_TOP')}`; +} + +// --- Assemble all pages --- + +function buildPage(contentFn) { + return `
${typeof contentFn === 'string' ? contentFn : contentFn}
`; +} + +const html = ` + + + + + + ${buildPage(makeFieldOverflowPageWithGrid('Date Field Overflow Tests'))} + ${buildPage(makeFieldOverflowPageWithGrid('Email Field Overflow Tests'))} + ${buildPage(makeSignatureOverflowPage('Signature Field Overflow Tests'))} + ${buildPage(makeTextAutoPage('Text Field Auto - Single-line Height', 1.75, true))} + ${buildPage(makeTextAutoPage('Text Field Auto - Multi-line Height', 12, false))} + ${buildPage(makeTextAutoPage('Text Field Auto - Multi-line Height Overflow', 12, false))} + ${buildPage(makePage6())} + ${buildPage(makePage7())} + +`; + +async function main() { + const browser = await chromium.launch(); + const page = await browser.newPage(); + + await page.setContent(html, { waitUntil: 'networkidle' }); + + const outputPath = path.join(__dirname, '../assets/field-overflow.pdf'); + + await page.pdf({ + path: outputPath, + format: 'A4', + printBackground: true, + margin: { top: '0', right: '0', bottom: '0', left: '0' }, + }); + + await browser.close(); + console.log(`PDF generated: assets/field-overflow.pdf`); +} + +main().catch(console.error);