feat: add AI field auto-placement with canvas registry

This commit is contained in:
Ephraim Atta-Duncan
2025-10-29 23:03:58 +00:00
parent 94098bd762
commit 29be66a844
7 changed files with 623 additions and 279 deletions

View File

@ -11,6 +11,10 @@ import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fi
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import {
registerPageCanvas,
unregisterPageCanvas,
} from '@documenso/lib/client-only/utils/page-canvas-registry';
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
import {
MIN_FIELD_HEIGHT_PX,
@ -56,6 +60,15 @@ export default function EnvelopeEditorFieldsPageRenderer() {
[editorFields.localFields, pageContext.pageNumber],
);
/**
* Cleanup: Unregister canvas when component unmounts
*/
useEffect(() => {
return () => {
unregisterPageCanvas(pageContext.pageNumber);
};
}, [pageContext.pageNumber]);
const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
const { current: container } = canvasElement;
@ -214,6 +227,15 @@ export default function EnvelopeEditorFieldsPageRenderer() {
currentStage.on('transformend', () => setIsFieldChanging(false));
currentPageLayer.batchDraw();
// Register this page's canvas references now that everything is initialized
if (canvasElement.current && currentStage) {
registerPageCanvas({
pageNumber: pageContext.pageNumber,
pdfCanvas: canvasElement.current,
konvaStage: currentStage,
});
}
};
/**

View File

@ -1,4 +1,4 @@
import { lazy, useEffect, useMemo } from 'react';
import { lazy, useEffect, useMemo, useState } from 'react';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
@ -11,6 +11,10 @@ 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 {
compositePageToBlob,
getPageCanvasRefs,
} from '@documenso/lib/client-only/utils/page-canvas-registry';
import type {
TCheckboxFieldMeta,
TDateFieldMeta,
@ -24,12 +28,15 @@ import type {
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 } 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';
@ -49,6 +56,94 @@ const EnvelopeEditorFieldsPageRenderer = lazy(
async () => import('./envelope-editor-fields-page-renderer'),
);
/**
* Enforces minimum field dimensions and centers the field when expanding to meet minimums.
*
* AI often detects form lines as very thin fields (0.2-0.5% height). This function ensures
* fields meet minimum usability requirements by expanding them to at least 30px height and
* 36px width, while keeping them centered on their original position.
*
* @param params - Field dimensions and page size
* @param params.positionX - Field X position as percentage (0-100)
* @param params.positionY - Field Y position as percentage (0-100)
* @param params.width - Field width as percentage (0-100)
* @param params.height - Field height as percentage (0-100)
* @param params.pageWidth - Page width in pixels
* @param params.pageHeight - Page height in pixels
* @returns Adjusted field dimensions with minimums enforced and centered
*
* @example
* // AI detected a thin line: 0.3% height
* const adjusted = enforceMinimumFieldDimensions({
* positionX: 20, positionY: 50, width: 30, height: 0.3,
* pageWidth: 800, pageHeight: 1100
* });
* // Result: height expanded to ~2.7% (30px), centered on original position
*/
/**
* Enforces minimum field dimensions with centered expansion.
*
* If a field is smaller than the minimum width or height, it will be expanded
* to meet the minimum requirements while staying centered on its original position.
*/
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;
// Convert percentage to pixels to check against minimums
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 FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
[FieldType.SIGNATURE]: msg`Signature Settings`,
[FieldType.FREE_SIGNATURE]: msg`Free Signature Settings`,
@ -69,6 +164,9 @@ export const EnvelopeEditorFieldsPage = () => {
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
const { t } = useLingui();
const { toast } = useToast();
const [isAutoAddingFields, setIsAutoAddingFields] = useState(false);
const selectedField = useMemo(
() => structuredClone(editorFields.selectedField),
@ -187,6 +285,134 @@ export const EnvelopeEditorFieldsPage = () => {
selectedRecipientId={editorFields.selectedRecipient?.id ?? null}
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
/>
<Button
className="mt-4 w-full"
variant="outline"
disabled={isAutoAddingFields}
onClick={async () => {
setIsAutoAddingFields(true);
try {
const blob = await compositePageToBlob(1);
if (!blob) {
toast({
title: t`Error`,
description: t`Failed to capture page. Please ensure the document is fully loaded.`,
variant: 'destructive',
});
return;
}
console.log('Successfully captured page 1 as PNG Blob:', {
size: `${(blob.size / 1024).toFixed(2)} KB`,
type: blob.type,
});
console.log('Blob object:', blob);
console.log('[Auto Add Fields] Sending image to AI endpoint...');
const formData = new FormData();
formData.append('image', blob, 'page-1.png');
const response = await fetch('/api/ai/detect-object-and-draw', {
method: 'POST',
body: formData,
credentials: 'include',
});
if (!response.ok) {
throw new Error(`AI detection failed: ${response.statusText}`);
}
const detectedFields = await response.json();
console.log(
`[Auto Add Fields] Detected ${detectedFields.length} fields:`,
detectedFields,
);
if (!editorFields.selectedRecipient || !currentEnvelopeItem) {
toast({
title: t`Warning`,
description: t`Please select a recipient before adding fields.`,
variant: 'destructive',
});
return;
}
const pageCanvasRefs = getPageCanvasRefs(1);
if (!pageCanvasRefs) {
console.warn(
'[Auto Add Fields] Could not get page dimensions for minimum field enforcement',
);
}
let addedCount = 0;
for (const detected of detectedFields) {
const [ymin, xmin, ymax, xmax] = detected.box_2d;
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;
try {
editorFields.addField({
envelopeItemId: currentEnvelopeItem.id,
page: 1,
type: fieldType,
positionX,
positionY,
width,
height,
recipientId: editorFields.selectedRecipient.id,
fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[fieldType]),
});
addedCount++;
} catch (error) {
console.error(`Failed to add ${fieldType} field:`, error);
}
}
console.log(
`[Auto Add Fields] Successfully added ${addedCount} fields to the document`,
);
toast({
title: t`Success`,
description: t`Added ${addedCount} fields to the document`,
});
} catch (error) {
console.error('Auto add fields error:', error);
toast({
title: t`Error`,
description: t`An unexpected error occurred while capturing the page.`,
variant: 'destructive',
});
} finally {
setIsAutoAddingFields(false);
}
}}
>
{isAutoAddingFields ? <Trans>Processing...</Trans> : <Trans>Auto add fields</Trans>}
</Button>
</section>
{/* Field details section. */}

View File

@ -1,12 +1,11 @@
import { useMemo, useState } from 'react';
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
import type { DropResult } from '@hello-pangea/dnd';
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentStatus } from '@prisma/client';
import { FileWarningIcon, GripVerticalIcon, Loader2 } from 'lucide-react';
import { X } from 'lucide-react';
import { FileWarningIcon, GripVerticalIcon, Loader2, X } from 'lucide-react';
import { ErrorCode as DropzoneErrorCode, type FileRejection } from 'react-dropzone';
import { Link } from 'react-router';