import { lazy, useEffect, useMemo, useState } from 'react'; import type { MessageDescriptor } from '@lingui/core'; import { msg, plural } from '@lingui/core/macro'; import { Trans, useLingui } from '@lingui/react/macro'; import { FieldType, RecipientRole } from '@prisma/client'; import { FileTextIcon } from 'lucide-react'; import { Link } from 'react-router'; import { isDeepEqual } from 'remeda'; import { match } from 'ts-pattern'; import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider'; import { getPageCanvasRefs } from '@documenso/lib/client-only/utils/page-canvas-registry'; import type { TDetectedFormField } from '@documenso/lib/types/document-analysis'; import type { TCheckboxFieldMeta, TDateFieldMeta, TDropdownFieldMeta, TEmailFieldMeta, TFieldMetaSchema, TInitialsFieldMeta, TNameFieldMeta, TNumberFieldMeta, TRadioFieldMeta, TSignatureFieldMeta, TTextFieldMeta, } from '@documenso/lib/types/field-meta'; import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta'; import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients'; import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector'; import { Separator } from '@documenso/ui/primitives/separator'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { EditorFieldCheckboxForm } from '~/components/forms/editor/editor-field-checkbox-form'; import { EditorFieldDateForm } from '~/components/forms/editor/editor-field-date-form'; import { EditorFieldDropdownForm } from '~/components/forms/editor/editor-field-dropdown-form'; import { EditorFieldEmailForm } from '~/components/forms/editor/editor-field-email-form'; import { EditorFieldInitialsForm } from '~/components/forms/editor/editor-field-initials-form'; import { EditorFieldNameForm } from '~/components/forms/editor/editor-field-name-form'; import { EditorFieldNumberForm } from '~/components/forms/editor/editor-field-number-form'; import { EditorFieldRadioForm } from '~/components/forms/editor/editor-field-radio-form'; import { EditorFieldSignatureForm } from '~/components/forms/editor/editor-field-signature-form'; import { EditorFieldTextForm } from '~/components/forms/editor/editor-field-text-form'; import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop'; import { EnvelopeRendererFileSelector } from './envelope-file-selector'; const EnvelopeEditorFieldsPageRenderer = lazy( async () => import('./envelope-editor-fields-page-renderer'), ); const enforceMinimumFieldDimensions = (params: { positionX: number; positionY: number; width: number; height: number; pageWidth: number; pageHeight: number; }): { positionX: number; positionY: number; width: number; height: number; } => { const MIN_HEIGHT_PX = 30; const MIN_WIDTH_PX = 36; const widthPx = (params.width / 100) * params.pageWidth; const heightPx = (params.height / 100) * params.pageHeight; let adjustedWidth = params.width; let adjustedHeight = params.height; let adjustedPositionX = params.positionX; let adjustedPositionY = params.positionY; if (widthPx < MIN_WIDTH_PX) { const centerXPx = (params.positionX / 100) * params.pageWidth + widthPx / 2; adjustedWidth = (MIN_WIDTH_PX / params.pageWidth) * 100; adjustedPositionX = ((centerXPx - MIN_WIDTH_PX / 2) / params.pageWidth) * 100; if (adjustedPositionX < 0) { adjustedPositionX = 0; } else if (adjustedPositionX + adjustedWidth > 100) { adjustedPositionX = 100 - adjustedWidth; } } if (heightPx < MIN_HEIGHT_PX) { const centerYPx = (params.positionY / 100) * params.pageHeight + heightPx / 2; adjustedHeight = (MIN_HEIGHT_PX / params.pageHeight) * 100; adjustedPositionY = ((centerYPx - MIN_HEIGHT_PX / 2) / params.pageHeight) * 100; if (adjustedPositionY < 0) { adjustedPositionY = 0; } else if (adjustedPositionY + adjustedHeight > 100) { adjustedPositionY = 100 - adjustedHeight; } } return { positionX: adjustedPositionX, positionY: adjustedPositionY, width: adjustedWidth, height: adjustedHeight, }; }; const detectFormFieldsInDocument = async (params: { envelopeId: string; onProgress: (current: number, total: number) => void; }): Promise<{ fieldsPerPage: Map; errors: Map; }> => { const { envelopeId, onProgress } = params; const fieldsPerPage = new Map(); const errors = new Map(); try { onProgress(0, 1); const response = await fetch('/api/ai/detect-fields', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ envelopeId }), credentials: 'include', }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Field detection failed: ${response.statusText} - ${errorText}`); } const detectedFields: TDetectedFormField[] = await response.json(); for (const field of detectedFields) { if (!fieldsPerPage.has(field.pageNumber)) { fieldsPerPage.set(field.pageNumber, []); } fieldsPerPage.get(field.pageNumber)!.push(field); } onProgress(1, 1); } catch (error) { errors.set(0, error instanceof Error ? error : new Error(String(error))); } return { fieldsPerPage, errors }; }; const FieldSettingsTypeTranslations: Record = { [FieldType.SIGNATURE]: msg`Signature Settings`, [FieldType.FREE_SIGNATURE]: msg`Free Signature Settings`, [FieldType.TEXT]: msg`Text Settings`, [FieldType.DATE]: msg`Date Settings`, [FieldType.EMAIL]: msg`Email Settings`, [FieldType.NAME]: msg`Name Settings`, [FieldType.INITIALS]: msg`Initials Settings`, [FieldType.NUMBER]: msg`Number Settings`, [FieldType.RADIO]: msg`Radio Settings`, [FieldType.CHECKBOX]: msg`Checkbox Settings`, [FieldType.DROPDOWN]: msg`Dropdown Settings`, }; export const EnvelopeEditorFieldsPage = () => { const { envelope, editorFields, relativePath } = useCurrentEnvelopeEditor(); const { currentEnvelopeItem } = useCurrentEnvelopeRender(); const { t } = useLingui(); const { toast } = useToast(); const [isDetectingFields, setIsAutoAddingFields] = useState(false); const [processingProgress, setProcessingProgress] = useState<{ current: number; total: number; } | null>(null); const [hasAutoPlacedFields, setHasAutoPlacedFields] = useState(false); const selectedField = useMemo( () => structuredClone(editorFields.selectedField), [editorFields.selectedField], ); const updateSelectedFieldMeta = (fieldMeta: TFieldMetaSchema) => { if (!selectedField) { return; } const isMetaSame = isDeepEqual(selectedField.fieldMeta, fieldMeta); if (!isMetaSame) { editorFields.updateFieldByFormId(selectedField.formId, { fieldMeta, }); } }; useEffect(() => { const firstSelectableRecipient = envelope.recipients.find( (recipient) => recipient.role === RecipientRole.SIGNER || recipient.role === RecipientRole.APPROVER, ); editorFields.setSelectedRecipient(firstSelectableRecipient?.id ?? null); }, []); useEffect(() => { if (hasAutoPlacedFields || !currentEnvelopeItem) { return; } const storageKey = `autoPlaceFields_${envelope.id}`; const storedData = sessionStorage.getItem(storageKey); if (!storedData) { return; } sessionStorage.removeItem(storageKey); setHasAutoPlacedFields(true); try { const { fields: detectedFields, recipientCount } = JSON.parse(storedData) as { fields: TDetectedFormField[]; recipientCount: number; }; let totalAdded = 0; const fieldsPerPage = new Map(); for (const field of detectedFields) { if (!fieldsPerPage.has(field.pageNumber)) { fieldsPerPage.set(field.pageNumber, []); } fieldsPerPage.get(field.pageNumber)!.push(field); } for (const [pageNumber, fields] of fieldsPerPage.entries()) { const pageCanvasRefs = getPageCanvasRefs(pageNumber); for (const detected of fields) { const [ymin, xmin, ymax, xmax] = detected.boundingBox; let positionX = (xmin / 1000) * 100; let positionY = (ymin / 1000) * 100; let width = ((xmax - xmin) / 1000) * 100; let height = ((ymax - ymin) / 1000) * 100; if (pageCanvasRefs) { const adjusted = enforceMinimumFieldDimensions({ positionX, positionY, width, height, pageWidth: pageCanvasRefs.pdfCanvas.width, pageHeight: pageCanvasRefs.pdfCanvas.height, }); positionX = adjusted.positionX; positionY = adjusted.positionY; width = adjusted.width; height = adjusted.height; } const fieldType = detected.label as FieldType; const resolvedRecipientId = envelope.recipients.find((recipient) => recipient.id === detected.recipientId)?.id ?? editorFields.selectedRecipient?.id ?? envelope.recipients[0]?.id; if (!resolvedRecipientId) { console.warn('Skipping detected field because no recipient could be resolved', { detectedRecipientId: detected.recipientId, }); continue; } try { editorFields.addField({ envelopeItemId: currentEnvelopeItem.id, page: pageNumber, type: fieldType, positionX, positionY, width, height, recipientId: resolvedRecipientId, fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[fieldType]), }); totalAdded++; } catch (error) { console.error(`Failed to add field on page ${pageNumber}:`, error); } } } if (totalAdded > 0) { toast({ title: t`Recipients and fields added`, description: t`Added ${recipientCount} ${plural(recipientCount, { one: 'recipient', other: 'recipients', })} and ${totalAdded} ${plural(totalAdded, { one: 'field', other: 'fields' })}`, duration: 5000, }); } else { toast({ title: t`Recipients added`, description: t`Added ${recipientCount} ${plural(recipientCount, { one: 'recipient', other: 'recipients', })}. No fields were detected in the document.`, duration: 5000, }); } } catch (error) { console.error('Failed to auto-place fields:', error); toast({ title: t`Field placement failed`, description: t`Failed to automatically place fields. You can add them manually.`, variant: 'destructive', duration: 5000, }); } }, [ currentEnvelopeItem, envelope.id, envelope.recipients, editorFields, hasAutoPlacedFields, t, toast, ]); return (
{/* Horizontal envelope item selector */} {isDetectingFields && ( <>
)} {/* Document View */}
{envelope.recipients.length === 0 && (
Missing Recipients You need at least one recipient to add fields
)} {currentEnvelopeItem !== null ? ( ) : (

No documents found

Please upload a document to continue

)}
{/* Right Section - Form Fields Panel */} {currentEnvelopeItem && envelope.recipients.length > 0 && (
{/* Recipient selector section. */}

Selected Recipient

editorFields.setSelectedRecipient(recipient.id) } recipients={envelope.recipients} className="w-full" align="end" /> {editorFields.selectedRecipient && !canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields) && ( This recipient can no longer be modified as they have signed a field, or completed the document. )}
{/* Add fields section. */}

Add Fields

{/* Field details section. */} {selectedField && (

{t(FieldSettingsTypeTranslations[selectedField.type])}

{match(selectedField.type) .with(FieldType.SIGNATURE, () => ( updateSelectedFieldMeta(value)} /> )) .with(FieldType.CHECKBOX, () => ( updateSelectedFieldMeta(value)} /> )) .with(FieldType.DATE, () => ( updateSelectedFieldMeta(value)} /> )) .with(FieldType.DROPDOWN, () => ( updateSelectedFieldMeta(value)} /> )) .with(FieldType.EMAIL, () => ( updateSelectedFieldMeta(value)} /> )) .with(FieldType.INITIALS, () => ( updateSelectedFieldMeta(value)} /> )) .with(FieldType.NAME, () => ( updateSelectedFieldMeta(value)} /> )) .with(FieldType.NUMBER, () => ( updateSelectedFieldMeta(value)} /> )) .with(FieldType.RADIO, () => ( updateSelectedFieldMeta(value)} /> )) .with(FieldType.TEXT, () => ( updateSelectedFieldMeta(value)} /> )) .otherwise(() => null)}
)}
)}
); };