mirror of
https://github.com/documenso/documenso.git
synced 2025-11-12 15:53:02 +10:00
Compare commits
7 Commits
4a0425b120
...
exp/autopl
| Author | SHA1 | Date | |
|---|---|---|---|
| ac4b3737d6 | |||
| cdfd373958 | |||
| 233e6e603c | |||
| 134d5ac03e | |||
| 00e33c5331 | |||
| 29be66a844 | |||
| 94098bd762 |
@ -135,6 +135,14 @@ NEXT_PUBLIC_FEATURE_BILLING_ENABLED=
|
|||||||
# OPTIONAL: Leave blank to allow users to signup through /signup page.
|
# OPTIONAL: Leave blank to allow users to signup through /signup page.
|
||||||
NEXT_PUBLIC_DISABLE_SIGNUP=
|
NEXT_PUBLIC_DISABLE_SIGNUP=
|
||||||
|
|
||||||
|
# [[AI]]
|
||||||
|
# AI Gateway
|
||||||
|
AI_GATEWAY_API_KEY=""
|
||||||
|
# OPTIONAL: API key for Google Generative AI (Gemini). Get your key from https://ai.google.dev
|
||||||
|
GOOGLE_GENERATIVE_AI_API_KEY=""
|
||||||
|
# OPTIONAL: Enable AI field detection debug mode to save preview images with bounding boxes
|
||||||
|
NEXT_PUBLIC_AI_DEBUG_PREVIEW=
|
||||||
|
|
||||||
# [[E2E Tests]]
|
# [[E2E Tests]]
|
||||||
E2E_TEST_AUTHENTICATE_USERNAME="Test User"
|
E2E_TEST_AUTHENTICATE_USERNAME="Test User"
|
||||||
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
|
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -60,3 +60,6 @@ CLAUDE.md
|
|||||||
|
|
||||||
# agents
|
# agents
|
||||||
.specs
|
.specs
|
||||||
|
|
||||||
|
# ai debug previews
|
||||||
|
packages/assets/ai-previews/
|
||||||
|
|||||||
@ -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 { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
||||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-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 { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
|
||||||
import {
|
import {
|
||||||
MIN_FIELD_HEIGHT_PX,
|
MIN_FIELD_HEIGHT_PX,
|
||||||
@ -56,6 +60,15 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
[editorFields.localFields, pageContext.pageNumber],
|
[editorFields.localFields, pageContext.pageNumber],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup: Unregister canvas when component unmounts
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
unregisterPageCanvas(pageContext.pageNumber);
|
||||||
|
};
|
||||||
|
}, [pageContext.pageNumber]);
|
||||||
|
|
||||||
const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
|
const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
|
||||||
const { current: container } = canvasElement;
|
const { current: container } = canvasElement;
|
||||||
|
|
||||||
@ -214,6 +227,15 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
currentStage.on('transformend', () => setIsFieldChanging(false));
|
currentStage.on('transformend', () => setIsFieldChanging(false));
|
||||||
|
|
||||||
currentPageLayer.batchDraw();
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { lazy, useEffect, useMemo } from 'react';
|
import { lazy, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import type { MessageDescriptor } from '@lingui/core';
|
import type { MessageDescriptor } from '@lingui/core';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
@ -11,6 +11,8 @@ import { match } from 'ts-pattern';
|
|||||||
|
|
||||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-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/ai';
|
||||||
import type {
|
import type {
|
||||||
TCheckboxFieldMeta,
|
TCheckboxFieldMeta,
|
||||||
TDateFieldMeta,
|
TDateFieldMeta,
|
||||||
@ -24,12 +26,15 @@ import type {
|
|||||||
TSignatureFieldMeta,
|
TSignatureFieldMeta,
|
||||||
TTextFieldMeta,
|
TTextFieldMeta,
|
||||||
} from '@documenso/lib/types/field-meta';
|
} 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 { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
||||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
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 PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
||||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
|
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
|
||||||
import { Separator } from '@documenso/ui/primitives/separator';
|
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 { EditorFieldCheckboxForm } from '~/components/forms/editor/editor-field-checkbox-form';
|
||||||
import { EditorFieldDateForm } from '~/components/forms/editor/editor-field-date-form';
|
import { EditorFieldDateForm } from '~/components/forms/editor/editor-field-date-form';
|
||||||
@ -49,6 +54,136 @@ const EnvelopeEditorFieldsPageRenderer = lazy(
|
|||||||
async () => import('./envelope-editor-fields-page-renderer'),
|
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
|
||||||
|
*/
|
||||||
|
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 processAllPagesWithAI = async (params: {
|
||||||
|
documentDataId: string;
|
||||||
|
onProgress: (current: number, total: number) => void;
|
||||||
|
}): Promise<{
|
||||||
|
fieldsPerPage: Map<number, TDetectedFormField[]>;
|
||||||
|
errors: Map<number, Error>;
|
||||||
|
}> => {
|
||||||
|
const { documentDataId, onProgress } = params;
|
||||||
|
const fieldsPerPage = new Map<number, TDetectedFormField[]>();
|
||||||
|
const errors = new Map<number, Error>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Make single API call to process all pages server-side
|
||||||
|
onProgress(0, 1);
|
||||||
|
|
||||||
|
const response = await fetch('/api/ai/detect-form-fields', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ documentId: documentDataId }),
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`AI detection failed: ${response.statusText} - ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const detectedFields: TDetectedFormField[] = await response.json();
|
||||||
|
|
||||||
|
// Group fields by page number
|
||||||
|
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) {
|
||||||
|
// If request fails, treat it as error for all pages
|
||||||
|
errors.set(0, error instanceof Error ? error : new Error(String(error)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { fieldsPerPage, errors };
|
||||||
|
};
|
||||||
|
|
||||||
const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
|
const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
|
||||||
[FieldType.SIGNATURE]: msg`Signature Settings`,
|
[FieldType.SIGNATURE]: msg`Signature Settings`,
|
||||||
[FieldType.FREE_SIGNATURE]: msg`Free Signature Settings`,
|
[FieldType.FREE_SIGNATURE]: msg`Free Signature Settings`,
|
||||||
@ -69,6 +204,13 @@ export const EnvelopeEditorFieldsPage = () => {
|
|||||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||||
|
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [isAutoAddingFields, setIsAutoAddingFields] = useState(false);
|
||||||
|
const [processingProgress, setProcessingProgress] = useState<{
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const selectedField = useMemo(
|
const selectedField = useMemo(
|
||||||
() => structuredClone(editorFields.selectedField),
|
() => structuredClone(editorFields.selectedField),
|
||||||
@ -107,8 +249,17 @@ export const EnvelopeEditorFieldsPage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-full">
|
<div className="relative flex h-full">
|
||||||
<div className="flex w-full flex-col overflow-y-auto">
|
<div className="relative flex w-full flex-col overflow-y-auto">
|
||||||
{/* Horizontal envelope item selector */}
|
{/* Horizontal envelope item selector */}
|
||||||
|
{isAutoAddingFields && (
|
||||||
|
<>
|
||||||
|
<div className="edge-glow edge-glow-top pointer-events-none fixed left-0 right-0 top-0 z-20 h-16" />
|
||||||
|
<div className="edge-glow edge-glow-right pointer-events-none fixed bottom-0 right-0 top-0 z-20 w-16" />
|
||||||
|
<div className="edge-glow edge-glow-bottom pointer-events-none fixed bottom-0 left-0 right-0 z-20 h-16" />
|
||||||
|
<div className="edge-glow edge-glow-left pointer-events-none fixed bottom-0 left-0 top-0 z-20 w-16" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
|
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
|
||||||
|
|
||||||
{/* Document View */}
|
{/* Document View */}
|
||||||
@ -187,6 +338,141 @@ export const EnvelopeEditorFieldsPage = () => {
|
|||||||
selectedRecipientId={editorFields.selectedRecipient?.id ?? null}
|
selectedRecipientId={editorFields.selectedRecipient?.id ?? null}
|
||||||
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
|
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="mt-4 w-full"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isAutoAddingFields}
|
||||||
|
onClick={async () => {
|
||||||
|
setIsAutoAddingFields(true);
|
||||||
|
setProcessingProgress(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!editorFields.selectedRecipient || !currentEnvelopeItem) {
|
||||||
|
toast({
|
||||||
|
title: t`Warning`,
|
||||||
|
description: t`Please select a recipient before adding fields.`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentEnvelopeItem.documentDataId) {
|
||||||
|
toast({
|
||||||
|
title: t`Error`,
|
||||||
|
description: t`Document data not found. Please try reloading the page.`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { fieldsPerPage, errors } = await processAllPagesWithAI({
|
||||||
|
documentDataId: currentEnvelopeItem.documentDataId,
|
||||||
|
onProgress: (current, total) => {
|
||||||
|
setProcessingProgress({ current, total });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let totalAdded = 0;
|
||||||
|
for (const [pageNumber, detectedFields] of fieldsPerPage.entries()) {
|
||||||
|
const pageCanvasRefs = getPageCanvasRefs(pageNumber);
|
||||||
|
|
||||||
|
for (const detected of detectedFields) {
|
||||||
|
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;
|
||||||
|
|
||||||
|
try {
|
||||||
|
editorFields.addField({
|
||||||
|
envelopeItemId: currentEnvelopeItem.id,
|
||||||
|
page: pageNumber,
|
||||||
|
type: fieldType,
|
||||||
|
positionX,
|
||||||
|
positionY,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
recipientId: editorFields.selectedRecipient.id,
|
||||||
|
fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[fieldType]),
|
||||||
|
});
|
||||||
|
totalAdded++;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to add field on page ${pageNumber}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const successfulPages = fieldsPerPage.size;
|
||||||
|
const failedPages = errors.size;
|
||||||
|
|
||||||
|
if (totalAdded > 0) {
|
||||||
|
let description = t`Added ${totalAdded} fields`;
|
||||||
|
if (fieldsPerPage.size > 1) {
|
||||||
|
description = t`Added ${totalAdded} fields across ${successfulPages} pages`;
|
||||||
|
}
|
||||||
|
if (failedPages > 0) {
|
||||||
|
description = t`Added ${totalAdded} fields across ${successfulPages} pages. ${failedPages} pages failed.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t`Success`,
|
||||||
|
description,
|
||||||
|
});
|
||||||
|
} else if (failedPages > 0) {
|
||||||
|
toast({
|
||||||
|
title: t`Error`,
|
||||||
|
description: t`Failed to detect fields on ${failedPages} pages. Please try again.`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: t`Info`,
|
||||||
|
description: t`No fields were detected in the document`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: t`Error`,
|
||||||
|
description: t`An unexpected error occurred while processing pages.`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsAutoAddingFields(false);
|
||||||
|
setProcessingProgress(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isAutoAddingFields ? (
|
||||||
|
processingProgress ? (
|
||||||
|
<Trans>
|
||||||
|
Processing page {processingProgress.current} of {processingProgress.total}...
|
||||||
|
</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Processing...</Trans>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Trans>Auto add fields</Trans>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Field details section. */}
|
{/* Field details section. */}
|
||||||
|
|||||||
@ -1,12 +1,11 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
|
|
||||||
import type { DropResult } 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 { msg } from '@lingui/core/macro';
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import { DocumentStatus } from '@prisma/client';
|
import { DocumentStatus } from '@prisma/client';
|
||||||
import { FileWarningIcon, GripVerticalIcon, Loader2 } from 'lucide-react';
|
import { FileWarningIcon, GripVerticalIcon, Loader2, X } from 'lucide-react';
|
||||||
import { X } from 'lucide-react';
|
|
||||||
import { ErrorCode as DropzoneErrorCode, type FileRejection } from 'react-dropzone';
|
import { ErrorCode as DropzoneErrorCode, type FileRejection } from 'react-dropzone';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,8 @@
|
|||||||
"with:env": "dotenv -e ../../.env -e ../../.env.local --"
|
"with:env": "dotenv -e ../../.env -e ../../.env.local --"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/google": "^2.0.25",
|
||||||
|
"@ai-sdk/react": "^2.0.82",
|
||||||
"@cantoo/pdf-lib": "^2.5.2",
|
"@cantoo/pdf-lib": "^2.5.2",
|
||||||
"@documenso/api": "*",
|
"@documenso/api": "*",
|
||||||
"@documenso/assets": "*",
|
"@documenso/assets": "*",
|
||||||
@ -38,6 +40,7 @@
|
|||||||
"@react-router/serve": "^7.6.0",
|
"@react-router/serve": "^7.6.0",
|
||||||
"@simplewebauthn/browser": "^9.0.1",
|
"@simplewebauthn/browser": "^9.0.1",
|
||||||
"@simplewebauthn/server": "^9.0.3",
|
"@simplewebauthn/server": "^9.0.3",
|
||||||
|
"ai": "^5.0.82",
|
||||||
"autoprefixer": "^10.4.13",
|
"autoprefixer": "^10.4.13",
|
||||||
"colord": "^2.9.3",
|
"colord": "^2.9.3",
|
||||||
"framer-motion": "^10.12.8",
|
"framer-motion": "^10.12.8",
|
||||||
@ -68,6 +71,7 @@
|
|||||||
"remix-themes": "^2.0.4",
|
"remix-themes": "^2.0.4",
|
||||||
"satori": "^0.12.1",
|
"satori": "^0.12.1",
|
||||||
"sharp": "0.32.6",
|
"sharp": "0.32.6",
|
||||||
|
"skia-canvas": "^3.0.8",
|
||||||
"tailwindcss": "^3.4.15",
|
"tailwindcss": "^3.4.15",
|
||||||
"ts-pattern": "^5.0.5",
|
"ts-pattern": "^5.0.5",
|
||||||
"ua-parser-js": "^1.0.37",
|
"ua-parser-js": "^1.0.37",
|
||||||
|
|||||||
420
apps/remix/server/api/ai.ts
Normal file
420
apps/remix/server/api/ai.ts
Normal file
@ -0,0 +1,420 @@
|
|||||||
|
// sort-imports-ignore
|
||||||
|
|
||||||
|
// ---- PATCH pdfjs-dist's canvas require BEFORE importing it ----
|
||||||
|
import { createRequire } from 'module';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { Canvas, Image } from 'skia-canvas';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url || fileURLToPath(new URL('.', import.meta.url)));
|
||||||
|
const Module = require('module');
|
||||||
|
|
||||||
|
const originalRequire = Module.prototype.require;
|
||||||
|
Module.prototype.require = function (path: string) {
|
||||||
|
if (path === 'canvas') {
|
||||||
|
return {
|
||||||
|
createCanvas: (width: number, height: number) => new Canvas(width, height),
|
||||||
|
Image, // needed by pdfjs-dist
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line prefer-rest-params, @typescript-eslint/consistent-type-assertions
|
||||||
|
return originalRequire.apply(this, arguments as unknown as [string]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use dynamic require to bypass Vite SSR transformation
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
const pdfjsLib = require('pdfjs-dist/legacy/build/pdf.js');
|
||||||
|
|
||||||
|
import { generateObject } from 'ai';
|
||||||
|
import { mkdir, writeFile } from 'fs/promises';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { join } from 'path';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
||||||
|
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
|
||||||
|
import { env } from '@documenso/lib/utils/env';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import type { HonoEnv } from '../router';
|
||||||
|
import {
|
||||||
|
type TDetectFormFieldsResponse,
|
||||||
|
ZDetectFormFieldsRequestSchema,
|
||||||
|
ZDetectedFormFieldSchema,
|
||||||
|
} from './ai.types';
|
||||||
|
|
||||||
|
const renderPdfToImage = async (pdfBytes: Uint8Array) => {
|
||||||
|
const loadingTask = pdfjsLib.getDocument({ data: pdfBytes });
|
||||||
|
const pdf = await loadingTask.promise;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const scale = 4;
|
||||||
|
|
||||||
|
const pages = await Promise.all(
|
||||||
|
Array.from({ length: pdf.numPages }, async (_, index) => {
|
||||||
|
const pageNumber = index + 1;
|
||||||
|
const page = await pdf.getPage(pageNumber);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const viewport = page.getViewport({ scale });
|
||||||
|
|
||||||
|
const virtualCanvas = new Canvas(viewport.width, viewport.height);
|
||||||
|
const context = virtualCanvas.getContext('2d');
|
||||||
|
context.imageSmoothingEnabled = false;
|
||||||
|
|
||||||
|
await page.render({ canvasContext: context, viewport }).promise;
|
||||||
|
|
||||||
|
return {
|
||||||
|
image: await virtualCanvas.toBuffer('png'),
|
||||||
|
pageNumber,
|
||||||
|
width: Math.floor(viewport.width),
|
||||||
|
height: Math.floor(viewport.height),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
page.cleanup();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return pages;
|
||||||
|
} finally {
|
||||||
|
await pdf.destroy();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resizeAndCompressImage = async (imageBuffer: Buffer): Promise<Buffer> => {
|
||||||
|
const metadata = await sharp(imageBuffer).metadata();
|
||||||
|
const originalWidth = metadata.width || 0;
|
||||||
|
|
||||||
|
if (originalWidth > 1000) {
|
||||||
|
return await sharp(imageBuffer)
|
||||||
|
.resize({ width: 1000, withoutEnlargement: true })
|
||||||
|
.jpeg({ quality: 70 })
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
return await sharp(imageBuffer).jpeg({ quality: 70 }).toBuffer();
|
||||||
|
};
|
||||||
|
|
||||||
|
const detectObjectsPrompt = `You are analyzing a form document image to detect fillable fields for the Documenso document signing platform.
|
||||||
|
|
||||||
|
IMPORTANT RULES:
|
||||||
|
1. Only detect EMPTY/UNFILLED fields (ignore boxes that already contain text or data)
|
||||||
|
2. Analyze nearby text labels to determine the field type
|
||||||
|
3. Return bounding boxes for the fillable area only, NOT the label text
|
||||||
|
4. Each boundingBox must be in the format [ymin, xmin, ymax, xmax] where all coordinates are NORMALIZED to a 0-1000 scale
|
||||||
|
|
||||||
|
CRITICAL: UNDERSTANDING FILLABLE AREAS
|
||||||
|
The "fillable area" is ONLY the empty space where a user will write, type, sign, or check.
|
||||||
|
- ✓ CORRECT: The blank underscore where someone writes their name: "Name: _________" → box ONLY the underscores
|
||||||
|
- ✓ CORRECT: The empty white rectangle inside a box outline → box ONLY the empty space, not any printed text
|
||||||
|
- ✓ CORRECT: The blank space to the right of a label: "Email: [ empty box ]" → box ONLY the empty box, exclude "Email:"
|
||||||
|
- ✗ INCORRECT: Including the word "Signature:" that appears to the left of a signature line
|
||||||
|
- ✗ INCORRECT: Including printed labels, instructions, or descriptive text near the field
|
||||||
|
- ✗ INCORRECT: Extending the box to include text just because it's close to the fillable area
|
||||||
|
|
||||||
|
VISUALIZING THE DISTINCTION:
|
||||||
|
- If there's text (printed words/labels) near an empty box or line, they are SEPARATE elements
|
||||||
|
- The text is a LABEL telling the user what to fill
|
||||||
|
- The empty space is the FILLABLE AREA where they actually write/sign
|
||||||
|
- Your bounding box should capture ONLY the empty space, even if the label is immediately adjacent
|
||||||
|
|
||||||
|
FIELD TYPES TO DETECT:
|
||||||
|
• SIGNATURE - Signature lines, boxes labeled 'Signature', 'Sign here', 'Authorized signature', 'X____'
|
||||||
|
• INITIALS - Small boxes labeled 'Initials', 'Initial here', typically smaller than signature fields
|
||||||
|
• NAME - Boxes labeled 'Name', 'Full name', 'Your name', 'Print name', 'Printed name'
|
||||||
|
• EMAIL - Boxes labeled 'Email', 'Email address', 'E-mail', 'Email:'
|
||||||
|
• DATE - Boxes labeled 'Date', 'Date signed', "Today's date", or showing date format placeholders like 'MM/DD/YYYY', '__/__/____'
|
||||||
|
• CHECKBOX - Empty checkbox squares (☐) with or without labels, typically small square boxes
|
||||||
|
• RADIO - Empty radio button circles (○) in groups, typically circular selection options
|
||||||
|
• NUMBER - Boxes labeled with numeric context: 'Amount', 'Quantity', 'Phone', 'Phone number', 'ZIP', 'ZIP code', 'Age', 'Price', '#'
|
||||||
|
• DROPDOWN - Boxes with dropdown indicators (▼, ↓) or labeled 'Select', 'Choose', 'Please select'
|
||||||
|
• TEXT - Any other empty text input boxes, general input fields, unlabeled boxes, or when field type is uncertain
|
||||||
|
|
||||||
|
DETECTION GUIDELINES:
|
||||||
|
- Read text located near the box (above, to the left, or inside the box boundary) to infer the field type
|
||||||
|
- IMPORTANT: Use the nearby text to CLASSIFY the field type, but DO NOT include that text in the bounding box
|
||||||
|
- If you're uncertain which type fits best, default to TEXT
|
||||||
|
- For checkboxes and radio buttons: Detect each individual box/circle separately, not the label
|
||||||
|
- Signature fields are often longer horizontal lines or larger boxes
|
||||||
|
- Date fields often show format hints or date separators (slashes, dashes)
|
||||||
|
- Look for visual patterns: underscores (____), horizontal lines, box outlines
|
||||||
|
|
||||||
|
BOUNDING BOX PLACEMENT (CRITICAL):
|
||||||
|
- Your coordinates must capture ONLY the empty fillable space (the blank area where input goes)
|
||||||
|
- EXCLUDE all printed text labels, even if they are:
|
||||||
|
· Directly to the left of the field (e.g., "Name: _____")
|
||||||
|
· Directly above the field (e.g., "Signature" printed above a line)
|
||||||
|
· Very close to the field with minimal spacing
|
||||||
|
· Inside the same outlined box as the fillable area
|
||||||
|
- The label text helps you IDENTIFY the field type, but must be EXCLUDED from the bounding box
|
||||||
|
- If you detect a label "Email:" followed by a blank box, draw the box around ONLY the blank box, not the word "Email:"
|
||||||
|
|
||||||
|
COORDINATE SYSTEM:
|
||||||
|
- [ymin, xmin, ymax, xmax] normalized to 0-1000 scale
|
||||||
|
- Top-left corner: ymin and xmin close to 0
|
||||||
|
- Bottom-right corner: ymax and xmax close to 1000
|
||||||
|
- Coordinates represent positions on a 1000x1000 grid overlaid on the image
|
||||||
|
|
||||||
|
FIELD SIZING STRATEGY FOR LINE-BASED FIELDS:
|
||||||
|
When detecting thin horizontal lines for SIGNATURE, INITIALS, NAME, EMAIL, DATE, TEXT, or NUMBER fields:
|
||||||
|
1. Analyze the visual context around the detected line:
|
||||||
|
- Look at the empty space ABOVE the detected line
|
||||||
|
- Observe the spacing to any text labels, headers, or other form elements above
|
||||||
|
- Assess what would be a reasonable field height to make the field clearly visible when filled
|
||||||
|
2. Expand UPWARD from the detected line to create a usable field:
|
||||||
|
- Keep ymax (bottom) at the detected line position (the line becomes the bottom edge)
|
||||||
|
- Extend ymin (top) upward into the available whitespace
|
||||||
|
- Aim to use 60-80% of the clear whitespace above the line, while being reasonable
|
||||||
|
- The expanded field should provide comfortable space for signing/writing (minimum 30 units tall)
|
||||||
|
3. Apply minimum dimensions: height at least 30 units (3% of 1000-scale), width at least 36 units
|
||||||
|
4. Ensure ymin >= 0 (do not go off-page). If ymin would be negative, clamp to 0
|
||||||
|
5. Do NOT apply this expansion to CHECKBOX, RADIO, or DROPDOWN fields - use detected dimensions for those
|
||||||
|
6. Example: If you detect a signature line at ymax=500 with clear whitespace extending up to y=400:
|
||||||
|
- Available whitespace: 100 units
|
||||||
|
- Use 60-80% of that: 60-80 units
|
||||||
|
- Expanded field: [ymin=420, xmin=200, ymax=500, xmax=600] (creates 80-unit tall field)
|
||||||
|
- This gives comfortable signing space while respecting the form layout`;
|
||||||
|
|
||||||
|
const runFormFieldDetection = async (
|
||||||
|
imageBuffer: Buffer,
|
||||||
|
pageNumber: number,
|
||||||
|
): Promise<TDetectFormFieldsResponse> => {
|
||||||
|
const compressedImageBuffer = await resizeAndCompressImage(imageBuffer);
|
||||||
|
const base64Image = compressedImageBuffer.toString('base64');
|
||||||
|
|
||||||
|
const result = await generateObject({
|
||||||
|
model: 'google/gemini-2.5-pro',
|
||||||
|
output: 'array',
|
||||||
|
schema: ZDetectedFormFieldSchema,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'image',
|
||||||
|
image: `data:image/jpeg;base64,${base64Image}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: detectObjectsPrompt,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.object.map((field) => ({
|
||||||
|
...field,
|
||||||
|
pageNumber,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const aiRoute = new Hono<HonoEnv>().post('/detect-form-fields', async (c) => {
|
||||||
|
try {
|
||||||
|
const { user } = await getSession(c.req.raw);
|
||||||
|
|
||||||
|
const body = await c.req.json();
|
||||||
|
const parsed = ZDetectFormFieldsRequestSchema.safeParse(body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||||
|
message: 'Document ID is required',
|
||||||
|
userMessage: 'Please provide a valid document ID.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { documentId } = parsed.data;
|
||||||
|
|
||||||
|
const documentData = await prisma.documentData.findUnique({
|
||||||
|
where: { id: documentId },
|
||||||
|
include: {
|
||||||
|
envelopeItem: {
|
||||||
|
include: {
|
||||||
|
envelope: {
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
teamId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!documentData || !documentData.envelopeItem) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||||
|
message: `Document data not found: ${documentId}`,
|
||||||
|
userMessage: 'The requested document does not exist.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const envelope = documentData.envelopeItem.envelope;
|
||||||
|
|
||||||
|
const isDirectOwner = envelope.userId === user.id;
|
||||||
|
|
||||||
|
let hasTeamAccess = false;
|
||||||
|
if (envelope.teamId) {
|
||||||
|
try {
|
||||||
|
await getTeamById({ teamId: envelope.teamId, userId: user.id });
|
||||||
|
hasTeamAccess = true;
|
||||||
|
} catch (error) {
|
||||||
|
hasTeamAccess = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDirectOwner && !hasTeamAccess) {
|
||||||
|
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||||
|
message: `User ${user.id} does not have access to document ${documentId}`,
|
||||||
|
userMessage: 'You do not have permission to access this document.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const pdfBytes = await getFileServerSide({
|
||||||
|
type: documentData.type,
|
||||||
|
data: documentData.initialData || documentData.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderedPages = await renderPdfToImage(pdfBytes);
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
renderedPages.map(async (page) => {
|
||||||
|
return await runFormFieldDetection(page.image, page.pageNumber);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const detectedFields: TDetectFormFieldsResponse = [];
|
||||||
|
for (const [index, result] of results.entries()) {
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
detectedFields.push(...result.value);
|
||||||
|
} else {
|
||||||
|
const pageNumber = renderedPages[index]?.pageNumber ?? index + 1;
|
||||||
|
console.error(`Failed to detect fields on page ${pageNumber}:`, result.reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (env('NEXT_PUBLIC_AI_DEBUG_PREVIEW') === 'true') {
|
||||||
|
const debugDir = join(process.cwd(), '..', '..', 'packages', 'assets', 'ai-previews');
|
||||||
|
await mkdir(debugDir, { recursive: true });
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const timestamp = now
|
||||||
|
.toISOString()
|
||||||
|
.replace(/[-:]/g, '')
|
||||||
|
.replace(/\..+/, '')
|
||||||
|
.replace('T', '_');
|
||||||
|
|
||||||
|
for (const page of renderedPages) {
|
||||||
|
const padding = { left: 80, top: 20, right: 20, bottom: 40 };
|
||||||
|
const canvas = new Canvas(
|
||||||
|
page.width + padding.left + padding.right,
|
||||||
|
page.height + padding.top + padding.bottom,
|
||||||
|
);
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
img.src = page.image;
|
||||||
|
ctx.drawImage(img, padding.left, padding.top);
|
||||||
|
|
||||||
|
ctx.strokeStyle = 'rgba(255, 0, 0, 0.5)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
|
||||||
|
for (let i = 0; i <= 1000; i += 100) {
|
||||||
|
const x = padding.left + (i / 1000) * page.width;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, padding.top);
|
||||||
|
ctx.lineTo(x, page.height + padding.top);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i <= 1000; i += 100) {
|
||||||
|
const y = padding.top + (i / 1000) * page.height;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(padding.left, y);
|
||||||
|
ctx.lineTo(page.width + padding.left, y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
const colors = ['#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF'];
|
||||||
|
|
||||||
|
const pageFields = detectedFields.filter((f) => f.pageNumber === page.pageNumber);
|
||||||
|
pageFields.forEach((field, index) => {
|
||||||
|
const [ymin, xmin, ymax, xmax] = field.boundingBox.map((coord) => coord / 1000);
|
||||||
|
|
||||||
|
const x = xmin * page.width + padding.left;
|
||||||
|
const y = ymin * page.height + padding.top;
|
||||||
|
const width = (xmax - xmin) * page.width;
|
||||||
|
const height = (ymax - ymin) * page.height;
|
||||||
|
|
||||||
|
ctx.strokeStyle = colors[index % colors.length];
|
||||||
|
ctx.lineWidth = 5;
|
||||||
|
ctx.strokeRect(x, y, width, height);
|
||||||
|
|
||||||
|
ctx.fillStyle = colors[index % colors.length];
|
||||||
|
ctx.font = '20px Arial';
|
||||||
|
ctx.fillText(field.label, x, y - 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.strokeStyle = '#000000';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.font = '26px Arial';
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(padding.left, padding.top);
|
||||||
|
ctx.lineTo(padding.left, page.height + padding.top);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.textAlign = 'right';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
for (let i = 0; i <= 1000; i += 100) {
|
||||||
|
const y = padding.top + (i / 1000) * page.height;
|
||||||
|
ctx.fillStyle = '#000000';
|
||||||
|
ctx.fillText(i.toString(), padding.left - 5, y);
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(padding.left - 5, y);
|
||||||
|
ctx.lineTo(padding.left, y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(padding.left, page.height + padding.top);
|
||||||
|
ctx.lineTo(page.width + padding.left, page.height + padding.top);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'top';
|
||||||
|
for (let i = 0; i <= 1000; i += 100) {
|
||||||
|
const x = padding.left + (i / 1000) * page.width;
|
||||||
|
ctx.fillStyle = '#000000';
|
||||||
|
ctx.fillText(i.toString(), x, page.height + padding.top + 5);
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, page.height + padding.top);
|
||||||
|
ctx.lineTo(x, page.height + padding.top + 5);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputFilename = `detected_form_fields_${timestamp}_page_${page.pageNumber}.png`;
|
||||||
|
const outputPath = join(debugDir, outputFilename);
|
||||||
|
|
||||||
|
const pngBuffer = await canvas.toBuffer('png');
|
||||||
|
await writeFile(outputPath, new Uint8Array(pngBuffer));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json<TDetectFormFieldsResponse>(detectedFields);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AppError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Failed to detect form fields from PDF:', error);
|
||||||
|
|
||||||
|
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||||
|
message: `Failed to detect form fields from PDF: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
userMessage: 'An error occurred while detecting form fields. Please try again.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
50
apps/remix/server/api/ai.types.ts
Normal file
50
apps/remix/server/api/ai.types.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import type { TDetectedFormField } from '@documenso/lib/types/ai';
|
||||||
|
|
||||||
|
export const ZGenerateTextRequestSchema = z.object({
|
||||||
|
prompt: z.string().min(1, 'Prompt is required').max(5000, 'Prompt is too long'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZGenerateTextResponseSchema = z.object({
|
||||||
|
text: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TGenerateTextRequest = z.infer<typeof ZGenerateTextRequestSchema>;
|
||||||
|
export type TGenerateTextResponse = z.infer<typeof ZGenerateTextResponseSchema>;
|
||||||
|
|
||||||
|
export const ZDetectedFormFieldSchema = z.object({
|
||||||
|
boundingBox: z
|
||||||
|
.array(z.number())
|
||||||
|
.length(4)
|
||||||
|
.describe('Bounding box [ymin, xmin, ymax, xmax] in normalized 0-1000 range'),
|
||||||
|
label: z
|
||||||
|
.enum([
|
||||||
|
'SIGNATURE',
|
||||||
|
'INITIALS',
|
||||||
|
'NAME',
|
||||||
|
'EMAIL',
|
||||||
|
'DATE',
|
||||||
|
'TEXT',
|
||||||
|
'NUMBER',
|
||||||
|
'RADIO',
|
||||||
|
'CHECKBOX',
|
||||||
|
'DROPDOWN',
|
||||||
|
])
|
||||||
|
.describe('Documenso field type inferred from nearby label text or visual characteristics'),
|
||||||
|
pageNumber: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
|
.describe('1-indexed page number where field was detected'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZDetectFormFieldsRequestSchema = z.object({
|
||||||
|
documentId: z.string().min(1, { message: 'Document ID is required' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZDetectFormFieldsResponseSchema = z.array(ZDetectedFormFieldSchema);
|
||||||
|
|
||||||
|
export type TDetectFormFieldsRequest = z.infer<typeof ZDetectFormFieldsRequestSchema>;
|
||||||
|
export type TDetectFormFieldsResponse = z.infer<typeof ZDetectFormFieldsResponseSchema>;
|
||||||
|
export type { TDetectedFormField };
|
||||||
@ -14,6 +14,7 @@ import { getIpAddress } from '@documenso/lib/universal/get-ip-address';
|
|||||||
import { logger } from '@documenso/lib/utils/logger';
|
import { logger } from '@documenso/lib/utils/logger';
|
||||||
import { openApiDocument } from '@documenso/trpc/server/open-api';
|
import { openApiDocument } from '@documenso/trpc/server/open-api';
|
||||||
|
|
||||||
|
import { aiRoute } from './api/ai';
|
||||||
import { filesRoute } from './api/files';
|
import { filesRoute } from './api/files';
|
||||||
import { type AppContext, appContext } from './context';
|
import { type AppContext, appContext } from './context';
|
||||||
import { appMiddleware } from './middleware';
|
import { appMiddleware } from './middleware';
|
||||||
@ -83,6 +84,9 @@ app.route('/api/auth', auth);
|
|||||||
// Files route.
|
// Files route.
|
||||||
app.route('/api/files', filesRoute);
|
app.route('/api/files', filesRoute);
|
||||||
|
|
||||||
|
// AI route.
|
||||||
|
app.route('/api/ai', aiRoute);
|
||||||
|
|
||||||
// API servers.
|
// API servers.
|
||||||
app.use(`/api/v1/*`, cors());
|
app.use(`/api/v1/*`, cors());
|
||||||
app.route('/api/v1', tsRestHonoApp);
|
app.route('/api/v1', tsRestHonoApp);
|
||||||
|
|||||||
212
package-lock.json
generated
212
package-lock.json
generated
@ -16,12 +16,13 @@
|
|||||||
"@documenso/prisma": "^0.0.0",
|
"@documenso/prisma": "^0.0.0",
|
||||||
"@lingui/conf": "^5.2.0",
|
"@lingui/conf": "^5.2.0",
|
||||||
"@lingui/core": "^5.2.0",
|
"@lingui/core": "^5.2.0",
|
||||||
|
"ai": "^5.0.82",
|
||||||
"inngest-cli": "^0.29.1",
|
"inngest-cli": "^0.29.1",
|
||||||
"luxon": "^3.5.0",
|
"luxon": "^3.5.0",
|
||||||
"mupdf": "^1.0.0",
|
"mupdf": "^1.0.0",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"typescript": "5.6.2",
|
"typescript": "5.6.2",
|
||||||
"zod": "3.24.1"
|
"zod": "3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commitlint/cli": "^17.7.1",
|
"@commitlint/cli": "^17.7.1",
|
||||||
@ -91,6 +92,8 @@
|
|||||||
"name": "@documenso/remix",
|
"name": "@documenso/remix",
|
||||||
"version": "1.13.1",
|
"version": "1.13.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/google": "^2.0.25",
|
||||||
|
"@ai-sdk/react": "^2.0.82",
|
||||||
"@cantoo/pdf-lib": "^2.5.2",
|
"@cantoo/pdf-lib": "^2.5.2",
|
||||||
"@documenso/api": "*",
|
"@documenso/api": "*",
|
||||||
"@documenso/assets": "*",
|
"@documenso/assets": "*",
|
||||||
@ -115,6 +118,7 @@
|
|||||||
"@react-router/serve": "^7.6.0",
|
"@react-router/serve": "^7.6.0",
|
||||||
"@simplewebauthn/browser": "^9.0.1",
|
"@simplewebauthn/browser": "^9.0.1",
|
||||||
"@simplewebauthn/server": "^9.0.3",
|
"@simplewebauthn/server": "^9.0.3",
|
||||||
|
"ai": "^5.0.82",
|
||||||
"autoprefixer": "^10.4.13",
|
"autoprefixer": "^10.4.13",
|
||||||
"colord": "^2.9.3",
|
"colord": "^2.9.3",
|
||||||
"framer-motion": "^10.12.8",
|
"framer-motion": "^10.12.8",
|
||||||
@ -145,6 +149,7 @@
|
|||||||
"remix-themes": "^2.0.4",
|
"remix-themes": "^2.0.4",
|
||||||
"satori": "^0.12.1",
|
"satori": "^0.12.1",
|
||||||
"sharp": "0.32.6",
|
"sharp": "0.32.6",
|
||||||
|
"skia-canvas": "^3.0.8",
|
||||||
"tailwindcss": "^3.4.15",
|
"tailwindcss": "^3.4.15",
|
||||||
"ts-pattern": "^5.0.5",
|
"ts-pattern": "^5.0.5",
|
||||||
"ua-parser-js": "^1.0.37",
|
"ua-parser-js": "^1.0.37",
|
||||||
@ -187,6 +192,92 @@
|
|||||||
"integrity": "sha512-EGs8PguQqAAUIcQfK4E9xdXxB6s2GK4sJfT/vcc9V1ELIvC4LH/zXu2t/5fajtv6oiRCxdv7BgtVK3vWgROxag==",
|
"integrity": "sha512-EGs8PguQqAAUIcQfK4E9xdXxB6s2GK4sJfT/vcc9V1ELIvC4LH/zXu2t/5fajtv6oiRCxdv7BgtVK3vWgROxag==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@ai-sdk/gateway": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-/vCoMKtod+A74/BbkWsaAflWKz1ovhX5lmJpIaXQXtd6gyexZncjotBTbFM8rVJT9LKJ/Kx7iVVo3vh+KT+IJg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@ai-sdk/provider": "2.0.0",
|
||||||
|
"@ai-sdk/provider-utils": "3.0.14",
|
||||||
|
"@vercel/oidc": "3.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.25.76 || ^4.1.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@ai-sdk/google": {
|
||||||
|
"version": "2.0.25",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-2.0.25.tgz",
|
||||||
|
"integrity": "sha512-tH2rA3428jnY6COoPfKB/BoQMs57sv9t+PEdyIB9ePtlV9dnVUbfKcdKoEcAaVffNZ6pzk8otrQYnu67pyn8TQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@ai-sdk/provider": "2.0.0",
|
||||||
|
"@ai-sdk/provider-utils": "3.0.14"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.25.76 || ^4.1.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@ai-sdk/provider": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"json-schema": "^0.4.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@ai-sdk/provider-utils": {
|
||||||
|
"version": "3.0.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.14.tgz",
|
||||||
|
"integrity": "sha512-CYRU6L7IlR7KslSBVxvlqlybQvXJln/PI57O8swhOaDIURZbjRP2AY3igKgUsrmWqqnFFUHP+AwTN8xqJAknnA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@ai-sdk/provider": "2.0.0",
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"eventsource-parser": "^3.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.25.76 || ^4.1.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@ai-sdk/react": {
|
||||||
|
"version": "2.0.82",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-2.0.82.tgz",
|
||||||
|
"integrity": "sha512-InaGqykKGFq/XA6Vhh2Hyy38nzeMpqp8eWxjTNEQA5Gwcal0BVNuZyTbTIL5t5VNXV+pQPDhe9ak1+mc9qxjog==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@ai-sdk/provider-utils": "3.0.14",
|
||||||
|
"ai": "5.0.82",
|
||||||
|
"swr": "^2.2.5",
|
||||||
|
"throttleit": "2.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18 || ^19 || ^19.0.0-rc",
|
||||||
|
"zod": "^3.25.76 || ^4.1.8"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"zod": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@alloc/quick-lru": {
|
"node_modules/@alloc/quick-lru": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
||||||
@ -11178,6 +11269,12 @@
|
|||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@standard-schema/spec": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@swagger-api/apidom-ast": {
|
"node_modules/@swagger-api/apidom-ast": {
|
||||||
"version": "1.0.0-beta.39",
|
"version": "1.0.0-beta.39",
|
||||||
"resolved": "https://registry.npmjs.org/@swagger-api/apidom-ast/-/apidom-ast-1.0.0-beta.39.tgz",
|
"resolved": "https://registry.npmjs.org/@swagger-api/apidom-ast/-/apidom-ast-1.0.0-beta.39.tgz",
|
||||||
@ -11866,6 +11963,15 @@
|
|||||||
"zod": "3.22.4"
|
"zod": "3.22.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@team-plain/typescript-sdk/node_modules/zod": {
|
||||||
|
"version": "3.22.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz",
|
||||||
|
"integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@theguild/remark-mermaid": {
|
"node_modules/@theguild/remark-mermaid": {
|
||||||
"version": "0.0.5",
|
"version": "0.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@theguild/remark-mermaid/-/remark-mermaid-0.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@theguild/remark-mermaid/-/remark-mermaid-0.0.5.tgz",
|
||||||
@ -13130,6 +13236,15 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@vercel/oidc": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-yNEQvPcVrK9sIe637+I0jD6leluPxzwJKx/Haw6F4H77CdDsszUn5V3o96LPziXkSNE2B83+Z3mjqGKBK/R6Gg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vvo/tzdb": {
|
"node_modules/@vvo/tzdb": {
|
||||||
"version": "6.161.0",
|
"version": "6.161.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vvo/tzdb/-/tzdb-6.161.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vvo/tzdb/-/tzdb-6.161.0.tgz",
|
||||||
@ -13271,6 +13386,33 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ai": {
|
||||||
|
"version": "5.0.82",
|
||||||
|
"resolved": "https://registry.npmjs.org/ai/-/ai-5.0.82.tgz",
|
||||||
|
"integrity": "sha512-wmZZfsU40qB77umrcj3YzMSk6cUP5gxLXZDPfiSQLBLegTVXPUdSJC603tR7JB5JkhBDzN5VLaliuRKQGKpUXg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@ai-sdk/gateway": "2.0.3",
|
||||||
|
"@ai-sdk/provider": "2.0.0",
|
||||||
|
"@ai-sdk/provider-utils": "3.0.14",
|
||||||
|
"@opentelemetry/api": "1.9.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.25.76 || ^4.1.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ai/node_modules/@opentelemetry/api": {
|
||||||
|
"version": "1.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||||
|
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "8.17.1",
|
"version": "8.17.1",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||||
@ -18595,6 +18737,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/eventsource-parser": {
|
||||||
|
"version": "3.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz",
|
||||||
|
"integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/execa": {
|
"node_modules/execa": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
|
||||||
@ -21307,6 +21458,15 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/inngest/node_modules/zod": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-HqnGsCdVZ2xc0qWPLdO25WnseXThh0kEYKIdV5F/hTHO75hNZFp8thxSeHhiPrHZKrFTo1SOgkAj9po5bexZlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/input-otp": {
|
"node_modules/input-otp": {
|
||||||
"version": "1.4.2",
|
"version": "1.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz",
|
||||||
@ -22405,6 +22565,12 @@
|
|||||||
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
|
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/json-schema": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
|
||||||
|
"license": "(AFL-2.1 OR BSD-3-Clause)"
|
||||||
|
},
|
||||||
"node_modules/json-schema-traverse": {
|
"node_modules/json-schema-traverse": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||||
@ -32722,6 +32888,19 @@
|
|||||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/swr": {
|
||||||
|
"version": "2.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/swr/-/swr-2.3.6.tgz",
|
||||||
|
"integrity": "sha512-wfHRmHWk/isGNMwlLGlZX5Gzz/uTgo0o2IRuTMcf4CPuPFJZlq0rDaKUx+ozB5nBOReNV1kiOyzMfj+MBMikLw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dequal": "^2.0.3",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tailwind-merge": {
|
"node_modules/tailwind-merge": {
|
||||||
"version": "1.14.0",
|
"version": "1.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-1.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-1.14.0.tgz",
|
||||||
@ -33059,6 +33238,18 @@
|
|||||||
"real-require": "^0.2.0"
|
"real-require": "^0.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/throttleit": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/through": {
|
"node_modules/through": {
|
||||||
"version": "2.3.8",
|
"version": "2.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
|
||||||
@ -36088,9 +36279,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "3.24.1",
|
"version": "3.25.76",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
"integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==",
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
@ -36163,7 +36354,7 @@
|
|||||||
"superjson": "^1.13.1",
|
"superjson": "^1.13.1",
|
||||||
"swagger-ui-react": "^5.21.0",
|
"swagger-ui-react": "^5.21.0",
|
||||||
"ts-pattern": "^5.0.5",
|
"ts-pattern": "^5.0.5",
|
||||||
"zod": "3.24.1"
|
"zod": "3.25.76"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/api/node_modules/ts-pattern": {
|
"packages/api/node_modules/ts-pattern": {
|
||||||
@ -36384,7 +36575,7 @@
|
|||||||
"luxon": "^3.5.0",
|
"luxon": "^3.5.0",
|
||||||
"nanoid": "^5.1.5",
|
"nanoid": "^5.1.5",
|
||||||
"ts-pattern": "^5.0.5",
|
"ts-pattern": "^5.0.5",
|
||||||
"zod": "3.24.1"
|
"zod": "3.25.76"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/auth/node_modules/ts-pattern": {
|
"packages/auth/node_modules/ts-pattern": {
|
||||||
@ -36404,7 +36595,7 @@
|
|||||||
"micro": "^10.0.1",
|
"micro": "^10.0.1",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"ts-pattern": "^5.0.5",
|
"ts-pattern": "^5.0.5",
|
||||||
"zod": "3.24.1"
|
"zod": "3.25.76"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/ee/node_modules/ts-pattern": {
|
"packages/ee/node_modules/ts-pattern": {
|
||||||
@ -36722,6 +36913,7 @@
|
|||||||
"micro": "^10.0.1",
|
"micro": "^10.0.1",
|
||||||
"nanoid": "^5.1.5",
|
"nanoid": "^5.1.5",
|
||||||
"oslo": "^0.17.0",
|
"oslo": "^0.17.0",
|
||||||
|
"pdfjs-dist": "3.11.174",
|
||||||
"pg": "^8.11.3",
|
"pg": "^8.11.3",
|
||||||
"pino": "^9.7.0",
|
"pino": "^9.7.0",
|
||||||
"pino-pretty": "^13.0.0",
|
"pino-pretty": "^13.0.0",
|
||||||
@ -36734,7 +36926,7 @@
|
|||||||
"skia-canvas": "^3.0.8",
|
"skia-canvas": "^3.0.8",
|
||||||
"stripe": "^12.7.0",
|
"stripe": "^12.7.0",
|
||||||
"ts-pattern": "^5.0.5",
|
"ts-pattern": "^5.0.5",
|
||||||
"zod": "3.24.1"
|
"zod": "3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/browser-chromium": "1.52.0",
|
"@playwright/browser-chromium": "1.52.0",
|
||||||
@ -37164,7 +37356,7 @@
|
|||||||
"superjson": "^1.13.1",
|
"superjson": "^1.13.1",
|
||||||
"trpc-to-openapi": "2.0.4",
|
"trpc-to-openapi": "2.0.4",
|
||||||
"ts-pattern": "^5.0.5",
|
"ts-pattern": "^5.0.5",
|
||||||
"zod": "3.24.1"
|
"zod": "3.25.76"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/trpc/node_modules/ts-pattern": {
|
"packages/trpc/node_modules/ts-pattern": {
|
||||||
@ -37235,7 +37427,7 @@
|
|||||||
"tailwind-merge": "^1.12.0",
|
"tailwind-merge": "^1.12.0",
|
||||||
"tailwindcss-animate": "^1.0.5",
|
"tailwindcss-animate": "^1.0.5",
|
||||||
"ts-pattern": "^5.0.5",
|
"ts-pattern": "^5.0.5",
|
||||||
"zod": "3.24.1"
|
"zod": "3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@documenso/tailwind-config": "*",
|
"@documenso/tailwind-config": "*",
|
||||||
|
|||||||
@ -71,15 +71,13 @@
|
|||||||
"@documenso/prisma": "^0.0.0",
|
"@documenso/prisma": "^0.0.0",
|
||||||
"@lingui/conf": "^5.2.0",
|
"@lingui/conf": "^5.2.0",
|
||||||
"@lingui/core": "^5.2.0",
|
"@lingui/core": "^5.2.0",
|
||||||
|
"ai": "^5.0.82",
|
||||||
"inngest-cli": "^0.29.1",
|
"inngest-cli": "^0.29.1",
|
||||||
"luxon": "^3.5.0",
|
"luxon": "^3.5.0",
|
||||||
"mupdf": "^1.0.0",
|
"mupdf": "^1.0.0",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"typescript": "5.6.2",
|
"typescript": "5.6.2",
|
||||||
"zod": "3.24.1"
|
"zod": "3.25.76"
|
||||||
},
|
|
||||||
"overrides": {
|
|
||||||
"zod": "3.24.1"
|
|
||||||
},
|
},
|
||||||
"trigger.dev": {
|
"trigger.dev": {
|
||||||
"endpointId": "documenso-app"
|
"endpointId": "documenso-app"
|
||||||
|
|||||||
@ -25,6 +25,6 @@
|
|||||||
"superjson": "^1.13.1",
|
"superjson": "^1.13.1",
|
||||||
"swagger-ui-react": "^5.21.0",
|
"swagger-ui-react": "^5.21.0",
|
||||||
"ts-pattern": "^5.0.5",
|
"ts-pattern": "^5.0.5",
|
||||||
"zod": "3.24.1"
|
"zod": "3.25.76"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -20,6 +20,6 @@
|
|||||||
"luxon": "^3.5.0",
|
"luxon": "^3.5.0",
|
||||||
"nanoid": "^5.1.5",
|
"nanoid": "^5.1.5",
|
||||||
"ts-pattern": "^5.0.5",
|
"ts-pattern": "^5.0.5",
|
||||||
"zod": "3.24.1"
|
"zod": "3.25.76"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -19,6 +19,6 @@
|
|||||||
"micro": "^10.0.1",
|
"micro": "^10.0.1",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"ts-pattern": "^5.0.5",
|
"ts-pattern": "^5.0.5",
|
||||||
"zod": "3.24.1"
|
"zod": "3.25.76"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
110
packages/lib/client-only/utils/page-canvas-registry.ts
Normal file
110
packages/lib/client-only/utils/page-canvas-registry.ts
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import type Konva from 'konva';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents canvas references for a specific PDF page.
|
||||||
|
*/
|
||||||
|
export interface PageCanvasRefs {
|
||||||
|
/** The page number (1-indexed) */
|
||||||
|
pageNumber: number;
|
||||||
|
/** The canvas element containing the rendered PDF */
|
||||||
|
pdfCanvas: HTMLCanvasElement;
|
||||||
|
/** The Konva stage containing field overlays */
|
||||||
|
konvaStage: Konva.Stage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Module-level registry to store canvas references by page number.
|
||||||
|
* This allows any component to access page canvases without prop drilling.
|
||||||
|
*/
|
||||||
|
const pageCanvasRegistry = new Map<number, PageCanvasRefs>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a page's canvas references.
|
||||||
|
* Call this when a page renderer mounts and has valid canvas refs.
|
||||||
|
*
|
||||||
|
* @param refs - The canvas references to register
|
||||||
|
*/
|
||||||
|
export const registerPageCanvas = (refs: PageCanvasRefs): void => {
|
||||||
|
pageCanvasRegistry.set(refs.pageNumber, refs);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregister a page's canvas references.
|
||||||
|
* Call this when a page renderer unmounts to prevent memory leaks.
|
||||||
|
*
|
||||||
|
* @param pageNumber - The page number to unregister
|
||||||
|
*/
|
||||||
|
export const unregisterPageCanvas = (pageNumber: number): void => {
|
||||||
|
pageCanvasRegistry.delete(pageNumber);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get canvas references for a specific page.
|
||||||
|
*
|
||||||
|
* @param pageNumber - The page number to retrieve
|
||||||
|
* @returns The canvas references, or undefined if not registered
|
||||||
|
*/
|
||||||
|
export const getPageCanvasRefs = (pageNumber: number): PageCanvasRefs | undefined => {
|
||||||
|
return pageCanvasRegistry.get(pageNumber);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all registered page numbers.
|
||||||
|
*
|
||||||
|
* @returns Array of page numbers currently registered
|
||||||
|
*/
|
||||||
|
export const getRegisteredPageNumbers = (): number[] => {
|
||||||
|
return Array.from(pageCanvasRegistry.keys()).sort((a, b) => a - b);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composite a PDF page with its field overlays into a single PNG Blob.
|
||||||
|
* This creates a temporary canvas, draws the PDF canvas first (background),
|
||||||
|
* then draws the Konva canvas on top (field overlays).
|
||||||
|
*
|
||||||
|
* @param pageNumber - The page number to composite (1-indexed)
|
||||||
|
* @returns Promise that resolves to a PNG Blob, or null if page not found or compositing fails
|
||||||
|
*/
|
||||||
|
export const compositePageToBlob = async (pageNumber: number): Promise<Blob | null> => {
|
||||||
|
const refs = getPageCanvasRefs(pageNumber);
|
||||||
|
|
||||||
|
if (!refs) {
|
||||||
|
console.warn(`Page ${pageNumber} is not registered for canvas capture`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create temporary canvas with same dimensions as PDF canvas
|
||||||
|
const tempCanvas = document.createElement('canvas');
|
||||||
|
tempCanvas.width = refs.pdfCanvas.width;
|
||||||
|
tempCanvas.height = refs.pdfCanvas.height;
|
||||||
|
|
||||||
|
const ctx = tempCanvas.getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
console.error('Failed to get 2D context for temporary canvas');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw PDF canvas first (background layer)
|
||||||
|
ctx.drawImage(refs.pdfCanvas, 0, 0);
|
||||||
|
|
||||||
|
// Get Konva canvas and draw on top (field overlays)
|
||||||
|
// Note: Konva's toCanvas() returns a new canvas with all layers rendered
|
||||||
|
const konvaCanvas = refs.konvaStage.toCanvas();
|
||||||
|
ctx.drawImage(konvaCanvas, 0, 0);
|
||||||
|
|
||||||
|
// Convert to PNG Blob
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
tempCanvas.toBlob((blob) => {
|
||||||
|
if (blob) {
|
||||||
|
resolve(blob);
|
||||||
|
} else {
|
||||||
|
reject(new Error('Failed to convert canvas to blob'));
|
||||||
|
}
|
||||||
|
}, 'image/png');
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error compositing page ${pageNumber}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -43,6 +43,7 @@
|
|||||||
"micro": "^10.0.1",
|
"micro": "^10.0.1",
|
||||||
"nanoid": "^5.1.5",
|
"nanoid": "^5.1.5",
|
||||||
"oslo": "^0.17.0",
|
"oslo": "^0.17.0",
|
||||||
|
"pdfjs-dist": "3.11.174",
|
||||||
"pg": "^8.11.3",
|
"pg": "^8.11.3",
|
||||||
"pino": "^9.7.0",
|
"pino": "^9.7.0",
|
||||||
"pino-pretty": "^13.0.0",
|
"pino-pretty": "^13.0.0",
|
||||||
@ -55,7 +56,7 @@
|
|||||||
"skia-canvas": "^3.0.8",
|
"skia-canvas": "^3.0.8",
|
||||||
"stripe": "^12.7.0",
|
"stripe": "^12.7.0",
|
||||||
"ts-pattern": "^5.0.5",
|
"ts-pattern": "^5.0.5",
|
||||||
"zod": "3.24.1"
|
"zod": "3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/browser-chromium": "1.52.0",
|
"@playwright/browser-chromium": "1.52.0",
|
||||||
|
|||||||
15
packages/lib/types/ai.ts
Normal file
15
packages/lib/types/ai.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
export type TDetectedFormField = {
|
||||||
|
boundingBox: number[];
|
||||||
|
label:
|
||||||
|
| 'SIGNATURE'
|
||||||
|
| 'INITIALS'
|
||||||
|
| 'NAME'
|
||||||
|
| 'EMAIL'
|
||||||
|
| 'DATE'
|
||||||
|
| 'TEXT'
|
||||||
|
| 'NUMBER'
|
||||||
|
| 'RADIO'
|
||||||
|
| 'CHECKBOX'
|
||||||
|
| 'DROPDOWN';
|
||||||
|
pageNumber: number;
|
||||||
|
};
|
||||||
@ -257,6 +257,7 @@ export const FIELD_NUMBER_META_DEFAULT_VALUES: TNumberFieldMeta = {
|
|||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
label: '',
|
label: '',
|
||||||
placeholder: '',
|
placeholder: '',
|
||||||
|
value: '',
|
||||||
required: false,
|
required: false,
|
||||||
readOnly: false,
|
readOnly: false,
|
||||||
};
|
};
|
||||||
|
|||||||
19
packages/lib/universal/field-renderer/field-constants.ts
Normal file
19
packages/lib/universal/field-renderer/field-constants.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Shared constants for field dimension enforcement.
|
||||||
|
*
|
||||||
|
* These constants ensure consistency between:
|
||||||
|
* 1. AI prompt (server/api/ai.ts) - instructs Gemini on minimum field dimensions
|
||||||
|
* 2. Client enforcement (envelope-editor-fields-page.tsx) - fallback validation
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimum field height in pixels.
|
||||||
|
* Fields smaller than this will be expanded to meet minimum usability requirements.
|
||||||
|
*/
|
||||||
|
export const MIN_FIELD_HEIGHT_PX = 30;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimum field width in pixels.
|
||||||
|
* Fields smaller than this will be expanded to meet minimum usability requirements.
|
||||||
|
*/
|
||||||
|
export const MIN_FIELD_WIDTH_PX = 36;
|
||||||
@ -21,6 +21,6 @@
|
|||||||
"superjson": "^1.13.1",
|
"superjson": "^1.13.1",
|
||||||
"trpc-to-openapi": "2.0.4",
|
"trpc-to-openapi": "2.0.4",
|
||||||
"ts-pattern": "^5.0.5",
|
"ts-pattern": "^5.0.5",
|
||||||
"zod": "3.24.1"
|
"zod": "3.25.76"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
5
packages/tsconfig/process-env.d.ts
vendored
5
packages/tsconfig/process-env.d.ts
vendored
@ -80,6 +80,11 @@ declare namespace NodeJS {
|
|||||||
NEXT_PRIVATE_INNGEST_APP_ID?: string;
|
NEXT_PRIVATE_INNGEST_APP_ID?: string;
|
||||||
NEXT_PRIVATE_INNGEST_EVENT_KEY?: string;
|
NEXT_PRIVATE_INNGEST_EVENT_KEY?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google Generative AI (Gemini)
|
||||||
|
*/
|
||||||
|
GOOGLE_GENERATIVE_AI_API_KEY?: string;
|
||||||
|
|
||||||
POSTGRES_URL?: string;
|
POSTGRES_URL?: string;
|
||||||
DATABASE_URL?: string;
|
DATABASE_URL?: string;
|
||||||
POSTGRES_PRISMA_URL?: string;
|
POSTGRES_PRISMA_URL?: string;
|
||||||
|
|||||||
@ -78,6 +78,6 @@
|
|||||||
"tailwind-merge": "^1.12.0",
|
"tailwind-merge": "^1.12.0",
|
||||||
"tailwindcss-animate": "^1.0.5",
|
"tailwindcss-animate": "^1.0.5",
|
||||||
"ts-pattern": "^5.0.5",
|
"ts-pattern": "^5.0.5",
|
||||||
"zod": "3.24.1"
|
"zod": "3.25.76"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -197,6 +197,56 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes edgeGlow {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.edge-glow {
|
||||||
|
animation: edgeGlow 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edge-glow-top {
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgba(162, 231, 113, 0.4) 0%,
|
||||||
|
rgba(162, 231, 113, 0.2) 20%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edge-glow-right {
|
||||||
|
background: linear-gradient(
|
||||||
|
to left,
|
||||||
|
rgba(162, 231, 113, 0.4) 0%,
|
||||||
|
rgba(162, 231, 113, 0.2) 20%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edge-glow-bottom {
|
||||||
|
background: linear-gradient(
|
||||||
|
to top,
|
||||||
|
rgba(162, 231, 113, 0.4) 0%,
|
||||||
|
rgba(162, 231, 113, 0.2) 20%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edge-glow-left {
|
||||||
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
rgba(162, 231, 113, 0.4) 0%,
|
||||||
|
rgba(162, 231, 113, 0.2) 20%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Custom CSS for printing reports
|
* Custom CSS for printing reports
|
||||||
* - Sets page margins to 0.5 inches
|
* - Sets page margins to 0.5 inches
|
||||||
|
|||||||
@ -46,6 +46,7 @@
|
|||||||
"NEXT_PUBLIC_WEBAPP_URL",
|
"NEXT_PUBLIC_WEBAPP_URL",
|
||||||
"NEXT_PRIVATE_INTERNAL_WEBAPP_URL",
|
"NEXT_PRIVATE_INTERNAL_WEBAPP_URL",
|
||||||
"NEXT_PUBLIC_POSTHOG_KEY",
|
"NEXT_PUBLIC_POSTHOG_KEY",
|
||||||
|
"NEXT_PUBLIC_AI_DEBUG_PREVIEW",
|
||||||
"NEXT_PUBLIC_FEATURE_BILLING_ENABLED",
|
"NEXT_PUBLIC_FEATURE_BILLING_ENABLED",
|
||||||
"NEXT_PUBLIC_DISABLE_SIGNUP",
|
"NEXT_PUBLIC_DISABLE_SIGNUP",
|
||||||
"NEXT_PRIVATE_PLAIN_API_KEY",
|
"NEXT_PRIVATE_PLAIN_API_KEY",
|
||||||
|
|||||||
Reference in New Issue
Block a user