From 9db42accf3ca2cda84a097f8008abb724b67147f Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Tue, 28 Jan 2025 12:29:38 +1100 Subject: [PATCH] feat: add text align option to fields (#1610) Adds the ability to align text to the left, center or right for relevant fields. Previously text was always centered which can be less desirable. See attached debug document which has left, center and right text alignments set for fields. image N/A - Added text align option - Update the insert in pdf method to support different alignments - Added a debug mode to field insertion - Ran manual tests using the debug mode --- .../document-signing-date-field.tsx | 23 +++++++-- .../document-signing-email-field.tsx | 23 +++++++-- .../document-signing-name-field.tsx | 23 +++++++-- .../document-signing-number-field.tsx | 25 +++++++--- .../document-signing-text-field.tsx | 29 +++++++++--- .../server-only/pdf/insert-field-in-pdf.ts | 47 ++++++++++++++++++- packages/lib/types/field-meta.ts | 10 ++++ .../field-item-advanced-settings.tsx | 6 +++ .../date-field.tsx | 28 +++++++++++ .../email-field.tsx | 28 +++++++++++ .../initials-field.tsx | 23 +++++++++ .../name-field.tsx | 28 +++++++++++ .../number-field.tsx | 21 +++++++++ .../text-field.tsx | 28 +++++++++++ 14 files changed, 319 insertions(+), 23 deletions(-) diff --git a/apps/remix/app/components/general/document-signing/document-signing-date-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-date-field.tsx index 823fe3740..255c07849 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-date-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-date-field.tsx @@ -13,12 +13,14 @@ import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones' import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; +import { ZDateFieldMeta } from '@documenso/lib/types/field-meta'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { trpc } from '@documenso/trpc/react'; import type { TRemovedSignedFieldWithTokenMutationSchema, TSignFieldWithTokenMutationSchema, } from '@documenso/trpc/server/field-router/schema'; +import { cn } from '@documenso/ui/lib/utils'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { DocumentSigningFieldContainer } from './document-signing-field-container'; @@ -54,6 +56,9 @@ export const DocumentSigningDateField = ({ const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading; + const safeFieldMeta = ZDateFieldMeta.safeParse(field.fieldMeta); + const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null; + const localDateString = convertToLocalSystemFormat(field.customText, dateFormat, timezone); const isDifferentTime = field.inserted && localDateString !== field.customText; @@ -143,9 +148,21 @@ export const DocumentSigningDateField = ({ )} {field.inserted && ( -

- {localDateString} -

+
+

+ {localDateString} +

+
)} ); diff --git a/apps/remix/app/components/general/document-signing/document-signing-email-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-email-field.tsx index 4f4b16d16..bf943f178 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-email-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-email-field.tsx @@ -8,12 +8,14 @@ import { useRevalidator } from 'react-router'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; +import { ZEmailFieldMeta } from '@documenso/lib/types/field-meta'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { trpc } from '@documenso/trpc/react'; import type { TRemovedSignedFieldWithTokenMutationSchema, TSignFieldWithTokenMutationSchema, } from '@documenso/trpc/server/field-router/schema'; +import { cn } from '@documenso/ui/lib/utils'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { DocumentSigningFieldContainer } from './document-signing-field-container'; @@ -48,6 +50,9 @@ export const DocumentSigningEmailField = ({ const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading; + const safeFieldMeta = ZEmailFieldMeta.safeParse(field.fieldMeta); + const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null; + const onSign = async (authOptions?: TRecipientActionAuth) => { try { const value = providedEmail ?? ''; @@ -126,9 +131,21 @@ export const DocumentSigningEmailField = ({ )} {field.inserted && ( -

- {field.customText} -

+
+

+ {field.customText} +

+
)} ); diff --git a/apps/remix/app/components/general/document-signing/document-signing-name-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-name-field.tsx index 3a97c4dca..5fea0f1aa 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-name-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-name-field.tsx @@ -10,12 +10,14 @@ import { useRevalidator } from 'react-router'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; +import { ZNameFieldMeta } from '@documenso/lib/types/field-meta'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { trpc } from '@documenso/trpc/react'; import type { TRemovedSignedFieldWithTokenMutationSchema, TSignFieldWithTokenMutationSchema, } from '@documenso/trpc/server/field-router/schema'; +import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog'; import { Input } from '@documenso/ui/primitives/input'; @@ -58,6 +60,9 @@ export const DocumentSigningNameField = ({ const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading; + const safeFieldMeta = ZNameFieldMeta.safeParse(field.fieldMeta); + const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null; + const [showFullNameModal, setShowFullNameModal] = useState(false); const [localFullName, setLocalFullName] = useState(''); @@ -172,9 +177,21 @@ export const DocumentSigningNameField = ({ )} {field.inserted && ( -

- {field.customText} -

+
+

+ {field.customText} +

+
)} diff --git a/apps/remix/app/components/general/document-signing/document-signing-number-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-number-field.tsx index 78b474e29..8d293599e 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-number-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-number-field.tsx @@ -54,8 +54,9 @@ export const DocumentSigningNumberField = ({ const [showRadioModal, setShowRadioModal] = useState(false); - const parsedFieldMeta = field.fieldMeta ? ZNumberFieldMeta.parse(field.fieldMeta) : null; - const isReadOnly = parsedFieldMeta?.readOnly; + const safeFieldMeta = ZNumberFieldMeta.safeParse(field.fieldMeta); + const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null; + const defaultValue = parsedFieldMeta?.value; const [localNumber, setLocalNumber] = useState( parsedFieldMeta?.value ? String(parsedFieldMeta.value) : '0', @@ -210,7 +211,7 @@ export const DocumentSigningNumberField = ({ useEffect(() => { if ( (!field.inserted && defaultValue && localNumber) || - (!field.inserted && isReadOnly && defaultValue) + (!field.inserted && parsedFieldMeta?.readOnly && defaultValue) ) { void executeActionAuthProcedure({ onReauthFormSubmit: async (authOptions) => await onSign(authOptions), @@ -262,9 +263,21 @@ export const DocumentSigningNumberField = ({ )} {field.inserted && ( -

- {field.customText} -

+
+

+ {field.customText} +

+
)} diff --git a/apps/remix/app/components/general/document-signing/document-signing-text-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-text-field.tsx index 466bcef1d..a1aa569fd 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-text-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-text-field.tsx @@ -62,7 +62,8 @@ export const DocumentSigningTextField = ({ isPending: isRemoveSignedFieldWithTokenLoading, } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); - const parsedFieldMeta = field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : null; + const safeFieldMeta = ZTextFieldMeta.safeParse(field.fieldMeta); + const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null; const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading; const shouldAutoSignField = @@ -261,11 +262,23 @@ export const DocumentSigningTextField = ({ )} {field.inserted && ( -

- {field.customText.length < 20 - ? field.customText - : field.customText.substring(0, 15) + '...'} -

+
+

+ {field.customText.length < 20 + ? field.customText + : field.customText.substring(0, 15) + '...'} +

+
)} @@ -281,6 +294,10 @@ export const DocumentSigningTextField = ({ className={cn('mt-2 w-full rounded-md', { 'border-2 border-red-300 ring-2 ring-red-200 ring-offset-2 ring-offset-red-200 focus-visible:border-red-400 focus-visible:ring-4 focus-visible:ring-red-200 focus-visible:ring-offset-2 focus-visible:ring-offset-red-200': userInputHasErrors, + 'text-left': parsedFieldMeta?.textAlign === 'left', + 'text-center': + !parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center', + 'text-right': parsedFieldMeta?.textAlign === 'right', })} value={localText} onChange={handleTextChange} diff --git a/packages/lib/server-only/pdf/insert-field-in-pdf.ts b/packages/lib/server-only/pdf/insert-field-in-pdf.ts index c81372179..403c42559 100644 --- a/packages/lib/server-only/pdf/insert-field-in-pdf.ts +++ b/packages/lib/server-only/pdf/insert-field-in-pdf.ts @@ -2,7 +2,7 @@ import fontkit from '@pdf-lib/fontkit'; import { FieldType } from '@prisma/client'; import type { PDFDocument } from 'pdf-lib'; -import { RotationTypes, degrees, radiansToDegrees } from 'pdf-lib'; +import { RotationTypes, degrees, radiansToDegrees, rgb } from 'pdf-lib'; import { P, match } from 'ts-pattern'; import { @@ -34,6 +34,9 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu ]); const isSignatureField = isSignatureFieldType(field.type); + const isDebugMode = + // eslint-disable-next-line turbo/no-undeclared-env-vars + process.env.DEBUG_PDF_INSERT === '1' || process.env.DEBUG_PDF_INSERT === 'true'; pdf.registerFontkit(fontkit); @@ -81,6 +84,35 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu const fieldX = pageWidth * (Number(field.positionX) / 100); const fieldY = pageHeight * (Number(field.positionY) / 100); + // Draw debug box if debug mode is enabled + if (isDebugMode) { + let debugX = fieldX; + let debugY = pageHeight - fieldY - fieldHeight; // Invert Y for PDF coordinates + + if (pageRotationInDegrees !== 0) { + const adjustedPosition = adjustPositionForRotation( + pageWidth, + pageHeight, + debugX, + debugY, + pageRotationInDegrees, + ); + + debugX = adjustedPosition.xPos; + debugY = adjustedPosition.yPos; + } + + page.drawRectangle({ + x: debugX, + y: debugY, + width: fieldWidth, + height: fieldHeight, + borderColor: rgb(1, 0, 0), // Red + borderWidth: 1, + rotate: degrees(pageRotationInDegrees), + }); + } + const font = await pdf.embedFont( isSignatureField ? fontCaveat : fontNoto, isSignatureField ? { features: { calt: false } } : undefined, @@ -276,6 +308,7 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu const meta = Parser ? Parser.safeParse(field.fieldMeta) : null; const customFontSize = meta?.success && meta.data.fontSize ? meta.data.fontSize : null; + const textAlign = meta?.success && meta.data.textAlign ? meta.data.textAlign : 'center'; const longestLineInTextForWidth = field.customText .split('\n') .sort((a, b) => b.length - a.length)[0]; @@ -291,7 +324,17 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize); - let textX = fieldX + (fieldWidth - textWidth) / 2; + // Add padding similar to web display (roughly 0.5rem equivalent in PDF units) + const padding = 8; // PDF points, roughly equivalent to 0.5rem + + // Calculate X position based on text alignment with padding + let textX = fieldX + padding; // Left alignment starts after padding + if (textAlign === 'center') { + textX = fieldX + (fieldWidth - textWidth) / 2; // Center alignment ignores padding + } else if (textAlign === 'right') { + textX = fieldX + fieldWidth - textWidth - padding; // Right alignment respects right padding + } + let textY = fieldY + (fieldHeight - textHeight) / 2; // Invert the Y axis since PDFs use a bottom-left coordinate system diff --git a/packages/lib/types/field-meta.ts b/packages/lib/types/field-meta.ts index 2d926f6a1..83cca0ef8 100644 --- a/packages/lib/types/field-meta.ts +++ b/packages/lib/types/field-meta.ts @@ -10,9 +10,14 @@ export const ZBaseFieldMeta = z.object({ export type TBaseFieldMeta = z.infer; +export const ZFieldTextAlignSchema = z.enum(['left', 'center', 'right']); + +export type TFieldTextAlignSchema = z.infer; + export const ZInitialsFieldMeta = ZBaseFieldMeta.extend({ type: z.literal('initials'), fontSize: z.number().min(8).max(96).optional(), + textAlign: ZFieldTextAlignSchema.optional(), }); export type TInitialsFieldMeta = z.infer; @@ -20,6 +25,7 @@ export type TInitialsFieldMeta = z.infer; export const ZNameFieldMeta = ZBaseFieldMeta.extend({ type: z.literal('name'), fontSize: z.number().min(8).max(96).optional(), + textAlign: ZFieldTextAlignSchema.optional(), }); export type TNameFieldMeta = z.infer; @@ -27,6 +33,7 @@ export type TNameFieldMeta = z.infer; export const ZEmailFieldMeta = ZBaseFieldMeta.extend({ type: z.literal('email'), fontSize: z.number().min(8).max(96).optional(), + textAlign: ZFieldTextAlignSchema.optional(), }); export type TEmailFieldMeta = z.infer; @@ -34,6 +41,7 @@ export type TEmailFieldMeta = z.infer; export const ZDateFieldMeta = ZBaseFieldMeta.extend({ type: z.literal('date'), fontSize: z.number().min(8).max(96).optional(), + textAlign: ZFieldTextAlignSchema.optional(), }); export type TDateFieldMeta = z.infer; @@ -43,6 +51,7 @@ export const ZTextFieldMeta = ZBaseFieldMeta.extend({ text: z.string().optional(), characterLimit: z.number().optional(), fontSize: z.number().min(8).max(96).optional(), + textAlign: ZFieldTextAlignSchema.optional(), }); export type TTextFieldMeta = z.infer; @@ -54,6 +63,7 @@ export const ZNumberFieldMeta = ZBaseFieldMeta.extend({ minValue: z.number().optional(), maxValue: z.number().optional(), fontSize: z.number().min(8).max(96).optional(), + textAlign: ZFieldTextAlignSchema.optional(), }); export type TNumberFieldMeta = z.infer; 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 6a79da3ab..ea6097f73 100644 --- a/packages/ui/primitives/document-flow/field-item-advanced-settings.tsx +++ b/packages/ui/primitives/document-flow/field-item-advanced-settings.tsx @@ -69,21 +69,25 @@ const getDefaultState = (fieldType: FieldType): FieldMeta => { return { type: 'initials', fontSize: 14, + textAlign: 'left', }; case FieldType.NAME: return { type: 'name', fontSize: 14, + textAlign: 'left', }; case FieldType.EMAIL: return { type: 'email', fontSize: 14, + textAlign: 'left', }; case FieldType.DATE: return { type: 'date', fontSize: 14, + textAlign: 'left', }; case FieldType.TEXT: return { @@ -95,6 +99,7 @@ const getDefaultState = (fieldType: FieldType): FieldMeta => { fontSize: 14, required: false, readOnly: false, + textAlign: 'left', }; case FieldType.NUMBER: return { @@ -108,6 +113,7 @@ const getDefaultState = (fieldType: FieldType): FieldMeta => { required: false, readOnly: false, fontSize: 14, + textAlign: 'left', }; case FieldType.RADIO: return { 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 b979d09d7..8f182c5a2 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 @@ -6,6 +6,13 @@ import { validateFields as validateDateFields } from '@documenso/lib/advanced-fi import { 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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; type DateFieldAdvancedSettingsProps = { fieldState: DateFieldMeta; @@ -67,6 +74,27 @@ export const DateFieldAdvancedSettings = ({ max={96} /> + +
+ + + +
); }; 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 4eb944265..dc92ffdd3 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 @@ -6,6 +6,13 @@ import { validateFields as validateEmailFields } from '@documenso/lib/advanced-f import { 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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; type EmailFieldAdvancedSettingsProps = { fieldState: EmailFieldMeta; @@ -49,6 +56,27 @@ export const EmailFieldAdvancedSettings = ({ max={96} /> + +
+ + + +
); }; diff --git a/packages/ui/primitives/document-flow/field-items-advanced-settings/initials-field.tsx b/packages/ui/primitives/document-flow/field-items-advanced-settings/initials-field.tsx index ac9290b36..fba5f22fe 100644 --- a/packages/ui/primitives/document-flow/field-items-advanced-settings/initials-field.tsx +++ b/packages/ui/primitives/document-flow/field-items-advanced-settings/initials-field.tsx @@ -7,6 +7,8 @@ import { type TInitialsFieldMeta as InitialsFieldMeta } from '@documenso/lib/typ import { Input } from '@documenso/ui/primitives/input'; import { Label } from '@documenso/ui/primitives/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../select'; + type InitialsFieldAdvancedSettingsProps = { fieldState: InitialsFieldMeta; handleFieldChange: (key: keyof InitialsFieldMeta, value: string | boolean) => void; @@ -49,6 +51,27 @@ export const InitialsFieldAdvancedSettings = ({ max={96} /> + +
+ + + +
); }; diff --git a/packages/ui/primitives/document-flow/field-items-advanced-settings/name-field.tsx b/packages/ui/primitives/document-flow/field-items-advanced-settings/name-field.tsx index f7ffc00f0..7acaa6d7f 100644 --- a/packages/ui/primitives/document-flow/field-items-advanced-settings/name-field.tsx +++ b/packages/ui/primitives/document-flow/field-items-advanced-settings/name-field.tsx @@ -6,6 +6,13 @@ import { validateFields as validateNameFields } from '@documenso/lib/advanced-fi import { type TNameFieldMeta as NameFieldMeta } from '@documenso/lib/types/field-meta'; import { Input } from '@documenso/ui/primitives/input'; import { Label } from '@documenso/ui/primitives/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; type NameFieldAdvancedSettingsProps = { fieldState: NameFieldMeta; @@ -49,6 +56,27 @@ export const NameFieldAdvancedSettings = ({ max={96} /> + +
+ + + +
); }; diff --git a/packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx b/packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx index 9043c16d8..998a73442 100644 --- a/packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx +++ b/packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx @@ -134,6 +134,27 @@ export const NumberFieldAdvancedSettings = ({ /> +
+ + + +
+
+
+ + + +
+