diff --git a/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx b/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx index 211746faf..ed495f83a 100644 --- a/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx +++ b/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx @@ -3,12 +3,13 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { AppError } from '@documenso/lib/errors/app-error'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; +import { hasOverlappingFields } from '@documenso/lib/utils/fields-overlap'; import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients'; import { zEmail } from '@documenso/lib/utils/zod'; import { trpc, trpc as trpcReact } from '@documenso/trpc/react'; import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper'; import { cn } from '@documenso/ui/lib/utils'; -import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; import { Dialog, @@ -32,7 +33,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { Trans, useLingui } from '@lingui/react/macro'; import { DocumentDistributionMethod, DocumentStatus, EnvelopeType } from '@prisma/client'; import { AnimatePresence, motion } from 'framer-motion'; -import { InfoIcon } from 'lucide-react'; +import { AlertTriangleIcon, InfoIcon } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { useForm } from 'react-hook-form'; import { useNavigate } from 'react-router'; @@ -138,6 +139,27 @@ export const EnvelopeDistributeDialog = ({ }); }, [recipientsWithIndex, envelope.authOptions]); + /** + * Whether any fields significantly overlap each other. This is surfaced as a + * non-blocking warning since overlapping fields still allow sending, but can + * complicate the signing process or cause fields to behave unexpectedly. + */ + const hasOverlappingEnvelopeFields = useMemo( + () => + hasOverlappingFields( + envelope.fields.map((field) => ({ + id: field.id, + envelopeItemId: field.envelopeItemId, + page: field.page, + positionX: Number(field.positionX), + positionY: Number(field.positionY), + width: Number(field.width), + height: Number(field.height), + })), + ), + [envelope.fields], + ); + const invalidEnvelopeCode = useMemo(() => { if (recipientsMissingSignatureFields.length > 0) { return 'MISSING_SIGNATURES'; @@ -240,6 +262,24 @@ export const EnvelopeDistributeDialog = ({
+ {hasOverlappingEnvelopeFields && ( + + + +
+ + Overlapping fields detected + + + + Some fields are placed on top of each other. This may complicate the signing process or cause + fields to not work as expected. + + +
+
+ )} + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx index a5676dc60..db8f6ffbb 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx @@ -1,3 +1,4 @@ +import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields'; import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer'; import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; @@ -13,6 +14,7 @@ import { } from '@documenso/lib/universal/field-renderer/field-renderer'; import { renderField } from '@documenso/lib/universal/field-renderer/render-field'; import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields'; +import { getOverlappingFieldPairs } from '@documenso/lib/utils/fields-overlap'; import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients'; import { Command, @@ -62,6 +64,36 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR [editorFields.localFields, pageNumber, currentEnvelopeItem?.id], ); + /** + * Debounce the fields used for overlap highlighting so we don't recompute on every + * small drag/resize tick. Overlaps only occur within the same page and envelope + * item, so computing from this page's fields alone is sufficient. + */ + const debouncedPageFields = useDebouncedValue(localPageFields, 300); + + const overlappingFieldFormIds = useMemo(() => { + const formIds = new Set(); + + const pairs = getOverlappingFieldPairs( + debouncedPageFields.map((field) => ({ + id: field.formId, + envelopeItemId: field.envelopeItemId, + page: field.page, + positionX: field.positionX, + positionY: field.positionY, + width: field.width, + height: field.height, + })), + ); + + for (const pair of pairs) { + formIds.add(pair.fieldA.id); + formIds.add(pair.fieldB.id); + } + + return formIds; + }, [debouncedPageFields]); + const handleResizeOrMove = (event: KonvaEventObject) => { const isDragEvent = event.type === 'dragend'; @@ -113,6 +145,62 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR pageLayer.current?.batchDraw(); }; + /** + * Draws (or removes) a dashed warning outline over a field that significantly + * overlaps another field. The highlight is a child of the field group so it moves + * and resizes with the field, and sits on top of the field's own rect (which is + * re-styled on every render and would otherwise clobber a direct stroke change). + */ + const syncOverlapHighlight = (fieldGroup: Konva.Group, isOverlapping: boolean) => { + const existingHighlight = fieldGroup.findOne('.field-overlap-highlight'); + + // Skip while a field is actively being dragged/resized. The highlight is driven + // by debounced field data, so it would lag behind and distort during the gesture. + // It is repainted once the gesture settles (the effect re-runs on isFieldChanging). + if (isFieldChanging) { + existingHighlight?.destroy(); + return; + } + + if (!isOverlapping) { + existingHighlight?.destroy(); + return; + } + + const fieldRect = fieldGroup.findOne('.field-rect'); + + if (!fieldRect) { + return; + } + + const highlightAttrs = { + x: 0, + y: 0, + width: fieldRect.width(), + height: fieldRect.height(), + stroke: '#f59e0b', + strokeWidth: 2, + dash: [6, 4], + cornerRadius: 2, + strokeScaleEnabled: false, + listening: false, + } satisfies Partial; + + if (existingHighlight instanceof Konva.Rect) { + existingHighlight.setAttrs(highlightAttrs); + existingHighlight.moveToTop(); + return; + } + + const highlight = new Konva.Rect({ + name: 'field-overlap-highlight', + ...highlightAttrs, + }); + + fieldGroup.add(highlight); + highlight.moveToTop(); + }; + const unsafeRenderFieldOnLayer = (field: TLocalField) => { if (!pageLayer.current) { return; @@ -139,6 +227,8 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR mode: 'edit', }); + syncOverlapHighlight(fieldGroup, overlappingFieldFormIds.has(field.formId)); + if (!isFieldEditable) { return; } @@ -435,7 +525,7 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR interactiveTransformer.current?.forceUpdate(); pageLayer.current.batchDraw(); - }, [localPageFields, selectedKonvaFieldGroups]); + }, [localPageFields, selectedKonvaFieldGroups, overlappingFieldFormIds, isFieldChanging]); const setSelectedFields = (nodes: Konva.Node[]) => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx index a5ec4ed16..945d3c308 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx @@ -1,3 +1,4 @@ +import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider'; import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n'; @@ -17,6 +18,7 @@ import { type TTextFieldMeta, } from '@documenso/lib/types/field-meta'; import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope'; +import { getOverlappingFieldPairs } from '@documenso/lib/utils/fields-overlap'; import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients'; import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; import { cn } from '@documenso/ui/lib/utils'; @@ -28,7 +30,7 @@ import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client'; -import { FileTextIcon, PencilIcon, SparklesIcon } from 'lucide-react'; +import { AlertTriangleIcon, FileTextIcon, PencilIcon, SparklesIcon } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useRevalidator, useSearchParams } from 'react-router'; import { isDeepEqual } from 'remeda'; @@ -78,7 +80,7 @@ export const EnvelopeEditorFieldsPage = () => { const { envelope, editorFields, navigateToStep, editorConfig } = useCurrentEnvelopeEditor(); - const { currentEnvelopeItem } = useCurrentEnvelopeRender(); + const { currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender(); const { _ } = useLingui(); @@ -93,6 +95,53 @@ export const EnvelopeEditorFieldsPage = () => { const selectedField = useMemo(() => structuredClone(editorFields.selectedField), [editorFields.selectedField]); + /** + * Debounce the fields used for overlap detection so we don't recompute on every + * small drag/resize movement, which is expensive on large field counts and can + * bog down lower-end devices. + */ + const debouncedLocalFields = useDebouncedValue(editorFields.localFields, 300); + + /** + * Fields that significantly overlap each other. Overlapping fields render poorly in + * the editor and can behave unexpectedly during signing, so we warn the author here. + */ + const overlappingFieldPairs = useMemo( + () => + getOverlappingFieldPairs( + debouncedLocalFields.map((field) => ({ + id: field.formId, + envelopeItemId: field.envelopeItemId, + page: field.page, + positionX: field.positionX, + positionY: field.positionY, + width: field.width, + height: field.height, + })), + ), + [debouncedLocalFields], + ); + + const handleReviewOverlappingField = () => { + const firstPair = overlappingFieldPairs[0]; + + if (!firstPair) { + return; + } + + const targetField = editorFields.localFields.find((field) => field.formId === firstPair.fieldA.id); + + if (!targetField) { + return; + } + + if (targetField.envelopeItemId !== currentEnvelopeItem?.id) { + setCurrentEnvelopeItem(targetField.envelopeItemId); + } + + editorFields.setSelectedField(targetField.formId); + }; + const updateSelectedFieldMeta = (fieldMeta: TFieldMetaSchema) => { if (!selectedField) { return; @@ -211,6 +260,29 @@ export const EnvelopeEditorFieldsPage = () => { )} + {overlappingFieldPairs.length > 0 && ( + +
+ + +
+ + Overlapping fields detected + + + + Some fields are placed on top of each other. This may complicate the signing process or cause + fields to not work as expected. + + +
+
+
+ )} + {currentEnvelopeItem !== null ? ( = { + fieldA: T; + fieldB: T; + /** + * The proportion (0-1) of the smaller field's area covered by the intersection. + */ + overlapRatio: number; +}; + +/** + * Returns the area of the intersection between two fields, in squared percentage units. + * + * Returns 0 when the fields do not intersect. + */ +const getIntersectionArea = (fieldA: OverlapFieldInput, fieldB: OverlapFieldInput): number => { + const overlapX = Math.max( + 0, + Math.min(fieldA.positionX + fieldA.width, fieldB.positionX + fieldB.width) - + Math.max(fieldA.positionX, fieldB.positionX), + ); + + const overlapY = Math.max( + 0, + Math.min(fieldA.positionY + fieldA.height, fieldB.positionY + fieldB.height) - + Math.max(fieldA.positionY, fieldB.positionY), + ); + + return overlapX * overlapY; +}; + +/** + * Detects pairs of fields that overlap by at least the given threshold. + * + * Two fields are only compared when they share the same envelope item and page. + * The overlap ratio is measured against the smaller of the two fields, so a small + * field that is mostly covered by a large field is still flagged. + * + * @param fields The fields to check. Positional values must be percentages (0-100). + * @param threshold The minimum overlap ratio (0-1) to flag. Defaults to {@link FIELD_OVERLAP_THRESHOLD}. + */ +export const getOverlappingFieldPairs = ( + fields: T[], + threshold: number = FIELD_OVERLAP_THRESHOLD, +): TFieldOverlapPair[] => { + const pairs: TFieldOverlapPair[] = []; + + for (let i = 0; i < fields.length; i++) { + for (let j = i + 1; j < fields.length; j++) { + const fieldA = fields[i]; + const fieldB = fields[j]; + + if (fieldA.envelopeItemId !== fieldB.envelopeItemId || fieldA.page !== fieldB.page) { + continue; + } + + const fieldAArea = fieldA.width * fieldA.height; + const fieldBArea = fieldB.width * fieldB.height; + + if (fieldAArea <= 0 || fieldBArea <= 0) { + continue; + } + + const intersectionArea = getIntersectionArea(fieldA, fieldB); + + if (intersectionArea <= 0) { + continue; + } + + const overlapRatio = intersectionArea / Math.min(fieldAArea, fieldBArea); + + if (overlapRatio >= threshold) { + pairs.push({ fieldA, fieldB, overlapRatio }); + } + } + } + + return pairs; +}; + +/** + * Returns true if any pair of fields overlaps by at least the given threshold. + */ +export const hasOverlappingFields = ( + fields: T[], + threshold: number = FIELD_OVERLAP_THRESHOLD, +): boolean => { + return getOverlappingFieldPairs(fields, threshold).length > 0; +};