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'; import { type PageRenderData, useCurrentEnvelopeRender, } from '@documenso/lib/client-only/providers/envelope-render-provider'; import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta'; import { convertPixelToPercentage, MIN_FIELD_HEIGHT_PX, MIN_FIELD_WIDTH_PX, } 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, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from '@documenso/ui/primitives/command'; import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types'; import { useLingui } from '@lingui/react/macro'; import type { FieldType } from '@prisma/client'; import Konva from 'konva'; import type { KonvaEventObject } from 'konva/lib/Node'; import type { Transformer } from 'konva/lib/shapes/Transformer'; import { CopyPlusIcon, ShapesIcon, SquareStackIcon, TrashIcon, UserCircleIcon } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; import { fieldButtonList } from './envelope-editor-fields-drag-drop'; import { EnvelopeRecipientSelectorCommand } from './envelope-recipient-selector'; export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageRenderData }) => { const { t, i18n } = useLingui(); const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor(); const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender(); const interactiveTransformer = useRef(null); const [selectedKonvaFieldGroups, setSelectedKonvaFieldGroups] = useState([]); const [isFieldChanging, setIsFieldChanging] = useState(false); const [pendingFieldCreation, setPendingFieldCreation] = useState(null); /** * Whether the field was automatically selected on creation (drag-drop or marquee). * * We purposefully supress the floating toolbar for newly created fields. */ const [isAutoSelectedField, setIsAutoSelectedField] = useState(false); const { stage, pageLayer, konvaContainer, scaledViewport, unscaledViewport } = usePageRenderer( ({ stage, pageLayer }) => createPageCanvas(stage, pageLayer), pageData, ); const { scale, pageNumber } = pageData; const localPageFields = useMemo( () => editorFields.localFields.filter( (field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id, ), [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'; const fieldGroup = event.target as Konva.Group; const fieldFormId = fieldGroup.id(); // Note: This values are scaled. const { width: fieldPixelWidth, height: fieldPixelHeight, x: fieldX, y: fieldY, } = fieldGroup.getClientRect({ skipStroke: true, skipShadow: true, }); const pageHeight = scaledViewport.height; const pageWidth = scaledViewport.width; // Calculate x and y as a percentage of the page width and height const positionPercentX = (fieldX / pageWidth) * 100; const positionPercentY = (fieldY / pageHeight) * 100; // Get the bounds as a percentage of the page width and height const fieldPageWidth = (fieldPixelWidth / pageWidth) * 100; const fieldPageHeight = (fieldPixelHeight / pageHeight) * 100; const fieldUpdates: Partial = { positionX: positionPercentX, positionY: positionPercentY, }; // Do not update the width/height unless the field has actually been resized. // This is because our calculations will shift the width/height slightly // due to the way we convert between pixel and percentage. if (!isDragEvent) { fieldUpdates.width = fieldPageWidth; fieldUpdates.height = fieldPageHeight; } editorFields.updateFieldByFormId(fieldFormId, fieldUpdates); // Select the field if it is not already selected. if (isDragEvent && interactiveTransformer.current?.nodes().length === 0) { setSelectedFields([fieldGroup]); } 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; } const recipient = envelope.recipients.find((r) => r.id === field.recipientId); const isFieldEditable = recipient !== undefined && canRecipientFieldsBeModified(recipient, envelope.fields); const { fieldGroup } = renderField({ scale, pageLayer: pageLayer.current, field: { renderId: field.formId, ...field, customText: '', inserted: false, fieldMeta: field.fieldMeta, }, translations: getClientSideFieldTranslations(i18n), pageWidth: unscaledViewport.width, pageHeight: unscaledViewport.height, color: getRecipientColorKey(field.recipientId), editable: isFieldEditable, mode: 'edit', }); syncOverlapHighlight(fieldGroup, overlappingFieldFormIds.has(field.formId)); if (!isFieldEditable) { return; } fieldGroup.off('click'); fieldGroup.off('transformend'); fieldGroup.off('dragend'); // Set up field selection. Shift + click toggles this field in/out of the current // multi-selection, so fields can be added to a group by clicking them -- // complementing marquee drag-selection. A plain click (no modifier) selects just // this field. fieldGroup.on('click', (event) => { removePendingField(); const isMultiSelectModifier = event.evt.shiftKey; if (isMultiSelectModifier) { const currentNodes = interactiveTransformer.current?.nodes() ?? []; const isAlreadySelected = currentNodes.includes(fieldGroup); setSelectedFields( isAlreadySelected ? currentNodes.filter((node) => node !== fieldGroup) : [...currentNodes, fieldGroup], ); } else { setSelectedFields([fieldGroup]); } pageLayer.current?.batchDraw(); }); fieldGroup.on('transformend', handleResizeOrMove); fieldGroup.on('dragend', handleResizeOrMove); }; const renderFieldOnLayer = (field: TLocalField) => { try { unsafeRenderFieldOnLayer(field); } catch (err) { console.error(err); setRenderError(true); } }; /** * Initialize the Konva page canvas and all fields and interactions. */ const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => { // Initialize snap guides layer // snapGuideLayer.current = initializeSnapGuides(stage.current); // Add transformer for resizing and rotating. interactiveTransformer.current = createInteractiveTransformer(currentStage, currentPageLayer); // Render the fields. for (const field of localPageFields) { renderFieldOnLayer(field); } // Handle stage click to deselect. currentStage.on('mousedown', (e) => { removePendingField(); if (e.target === stage.current) { setSelectedFields([]); currentPageLayer.batchDraw(); } }); // When an item is dragged, select it automatically. const onDragStartOrEnd = (e: KonvaEventObject) => { removePendingField(); if (!e.target.hasName('field-group')) { return; } setIsFieldChanging(e.type === 'dragstart'); const itemAlreadySelected = (interactiveTransformer.current?.nodes() || []).includes(e.target); // Do nothing and allow the transformer to handle it. // Required so when multiple items are selected, this won't deselect them. if (itemAlreadySelected) { return; } setSelectedFields([e.target]); }; currentStage.on('dragstart', onDragStartOrEnd); currentStage.on('dragend', onDragStartOrEnd); currentStage.on('transformstart', () => setIsFieldChanging(true)); currentStage.on('transformend', () => setIsFieldChanging(false)); currentPageLayer.batchDraw(); }; /** * Creates an interactive transformer for the fields. * * Allows: * - Resizing * - Moving * - Selecting multiple fields * - Selecting empty area to create fields */ const createInteractiveTransformer = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => { const transformer = new Konva.Transformer({ rotateEnabled: false, keepRatio: false, shouldOverdrawWholeArea: true, ignoreStroke: true, flipEnabled: false, boundBoxFunc: (oldBox, newBox) => { // Enforce minimum size if (newBox.width < 30 || newBox.height < 20) { return oldBox; } return newBox; }, }); currentPageLayer.add(transformer); // Add selection rectangle. const selectionRectangle = new Konva.Rect({ fill: 'rgba(24, 160, 251, 0.3)', visible: false, }); currentPageLayer.add(selectionRectangle); let x1: number; let y1: number; let x2: number; let y2: number; currentStage.on('mousedown touchstart', (e) => { // do nothing if we mousedown on any shape if (e.target !== currentStage) { return; } const pointerPosition = currentStage.getPointerPosition(); if (!pointerPosition) { return; } x1 = pointerPosition.x / scale; y1 = pointerPosition.y / scale; x2 = pointerPosition.x / scale; y2 = pointerPosition.y / scale; selectionRectangle.setAttrs({ x: x1, y: y1, width: 0, height: 0, visible: true, }); }); currentStage.on('mousemove touchmove', () => { // do nothing if we didn't start selection if (!selectionRectangle.visible()) { return; } selectionRectangle.moveToTop(); const pointerPosition = currentStage.getPointerPosition(); if (!pointerPosition) { return; } x2 = pointerPosition.x / scale; y2 = pointerPosition.y / scale; selectionRectangle.setAttrs({ x: Math.min(x1, x2), y: Math.min(y1, y2), width: Math.abs(x2 - x1), height: Math.abs(y2 - y1), }); }); currentStage.on('mouseup touchend', () => { // do nothing if we didn't start selection if (!selectionRectangle.visible()) { return; } // Update visibility in timeout, so we can check it in click event setTimeout(() => { selectionRectangle.visible(false); }); const stageFieldGroups = currentStage.find('.field-group') || []; const box = selectionRectangle.getClientRect(); const selectedFieldGroups = stageFieldGroups.filter( (shape) => Konva.Util.haveIntersection(box, shape.getClientRect()) && shape.draggable(), ); setSelectedFields(selectedFieldGroups); const unscaledBoxWidth = box.width / scale; const unscaledBoxHeight = box.height / scale; // Create a field if no items are selected or the size is too small. if ( selectedFieldGroups.length === 0 && unscaledBoxWidth > MIN_FIELD_WIDTH_PX && unscaledBoxHeight > MIN_FIELD_HEIGHT_PX && editorFields.selectedRecipient && canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields) ) { const pendingFieldCreation = new Konva.Rect({ name: 'pending-field-creation', x: box.x / scale, y: box.y / scale, width: unscaledBoxWidth, height: unscaledBoxHeight, fill: 'rgba(24, 160, 251, 0.3)', }); currentPageLayer.add(pendingFieldCreation); setPendingFieldCreation(pendingFieldCreation); } }); // Clicking empty stage area clears the selection. Field clicks -- including // Shift+click multi-select -- are handled by each field group's own click // handler in `unsafeRenderFieldOnLayer`. currentStage.on('click tap', (e) => { // If we are selecting with the marquee rectangle, do nothing. if (selectionRectangle.visible() && selectionRectangle.width() > 0 && selectionRectangle.height() > 0) { return; } // If empty area clicked, remove all selections. if (e.target === stage.current) { setSelectedFields([]); } }); return transformer; }; /** * Render fields when they are added or removed from the localFields. */ useEffect(() => { if (!pageLayer.current || !stage.current) { return; } // If doesn't exist in localFields, destroy it since it's been deleted. pageLayer.current.find('Group').forEach((group) => { if (group.name() === 'field-group' && !localPageFields.some((field) => field.formId === group.id())) { group.destroy(); } }); // If it exists, rerender. localPageFields.forEach((field) => { renderFieldOnLayer(field); }); // Reconcile selection state with live field nodes after flush/sync updates. const liveSelectedFieldGroups = selectedKonvaFieldGroups.filter((fieldGroup) => { if (!fieldGroup.getStage() || !fieldGroup.getParent()) { return false; } return localPageFields.some((field) => field.formId === fieldGroup.id()); }); if (liveSelectedFieldGroups.length !== selectedKonvaFieldGroups.length) { setSelectedFields(liveSelectedFieldGroups); } // Mirror the editor's single selected field onto the canvas (Konva) selection. // // `addField` already marks a newly created field as the selected field, so this // makes a field placed via the palette (drag-drop) or marquee creation show its // resize handles immediately -- no second click needed. It also clears the canvas // selection when the selected field is cleared (e.g. when the author starts // placing another field), so the floating action toolbar can't intercept the next // placement click. Runs after the render loop above so the field's group exists. const selectedFormId = editorFields.selectedField?.formId ?? null; const isSingleCanvasSelection = selectedKonvaFieldGroups.length === 1; if (selectedFormId && localPageFields.some((field) => field.formId === selectedFormId)) { const isAlreadySelected = isSingleCanvasSelection && selectedKonvaFieldGroups[0].id() === selectedFormId; if (!isAlreadySelected) { const fieldGroupToSelect = pageLayer.current.findOne(`#${selectedFormId}`); if (fieldGroupToSelect instanceof Konva.Group) { setSelectedFields([fieldGroupToSelect], { isAutoSelect: true }); } } } else if (selectedFormId === null && isSingleCanvasSelection) { setSelectedFields([]); } // Rerender the transformer interactiveTransformer.current?.forceUpdate(); pageLayer.current.batchDraw(); }, [ localPageFields, selectedKonvaFieldGroups, overlappingFieldFormIds, isFieldChanging, editorFields.selectedField?.formId, ]); const setSelectedFields = (nodes: Konva.Node[], options?: { isAutoSelect?: boolean }) => { // Any explicit (user-driven) selection shows the action toolbar; only auto-selection // on field creation suppresses it. setIsAutoSelectedField(Boolean(options?.isAutoSelect)); // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const fieldGroups = nodes.filter( (node) => node.hasName('field-group') && Boolean(node.getStage()) && Boolean(node.getParent()), ) as Konva.Group[]; interactiveTransformer.current?.nodes(fieldGroups); setSelectedKonvaFieldGroups(fieldGroups); if (fieldGroups.length === 0 || fieldGroups.length > 1) { editorFields.setSelectedField(null); } // Handle single field selection. if (fieldGroups.length === 1) { const fieldGroup = fieldGroups[0]; editorFields.setSelectedField(fieldGroup.id()); fieldGroup.moveToTop(); } }; const deletedSelectedFields = () => { const fieldFormids = selectedKonvaFieldGroups.map((field) => field.id()).filter((field) => field !== undefined); editorFields.removeFieldsByFormId(fieldFormids); setSelectedFields([]); }; const changeSelectedFieldsRecipients = (recipientId: number) => { const fields = selectedKonvaFieldGroups .map((field) => editorFields.getFieldByFormId(field.id())) .filter((field) => field !== undefined); for (const field of fields) { if (field.recipientId !== recipientId) { editorFields.updateFieldByFormId(field.formId, { recipientId, id: undefined }); } } }; const changeSelectedFieldsType = (type: FieldType) => { const fields = selectedKonvaFieldGroups .map((field) => editorFields.getFieldByFormId(field.id())) .filter((field) => field !== undefined); for (const field of fields) { if (field.type !== type) { editorFields.updateFieldByFormId(field.formId, { type, fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[type]), id: undefined, }); } } }; const duplicatedSelectedFields = () => { const fields = selectedKonvaFieldGroups .map((field) => editorFields.getFieldByFormId(field.id())) .filter((field) => field !== undefined); for (const field of fields) { editorFields.duplicateField(field); } }; const duplicatedSelectedFieldsOnAllPages = () => { const fields = selectedKonvaFieldGroups .map((field) => editorFields.getFieldByFormId(field.id())) .filter((field) => field !== undefined); for (const field of fields) { editorFields.duplicateFieldToAllPages(field); } setSelectedFields([]); }; /** * Create a field from a pending field. */ const createFieldFromPendingTemplate = (pendingFieldCreation: Konva.Rect, type: FieldType) => { const pixelWidth = pendingFieldCreation.width(); const pixelHeight = pendingFieldCreation.height(); const pixelX = pendingFieldCreation.x(); const pixelY = pendingFieldCreation.y(); removePendingField(); if (!currentEnvelopeItem || !editorFields.selectedRecipient) { return; } const { fieldX, fieldY, fieldWidth, fieldHeight } = convertPixelToPercentage({ width: pixelWidth, height: pixelHeight, positionX: pixelX, positionY: pixelY, pageWidth: unscaledViewport.width, pageHeight: unscaledViewport.height, }); editorFields.addField({ envelopeItemId: currentEnvelopeItem.id, page: pageNumber, type, positionX: fieldX, positionY: fieldY, width: fieldWidth, height: fieldHeight, recipientId: editorFields.selectedRecipient.id, fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[type]), }); }; /** * Remove any pending fields or rectangle on the canvas. */ const removePendingField = () => { setPendingFieldCreation(null); const pendingFieldCreation = pageLayer.current?.find('.pending-field-creation') || []; for (const field of pendingFieldCreation) { field.destroy(); } }; if (!currentEnvelopeItem) { return null; } return ( <> {selectedKonvaFieldGroups.length > 0 && interactiveTransformer.current && !isFieldChanging && !isAutoSelectedField && ( field.id())} style={{ position: 'absolute', top: interactiveTransformer.current.y() + interactiveTransformer.current.getClientRect().height + 5 + 'px', left: interactiveTransformer.current.x() + interactiveTransformer.current.getClientRect().width / 2 + 'px', transform: 'translateX(-50%)', gap: '8px', pointerEvents: 'auto', zIndex: 50, }} /> )} {pendingFieldCreation && (
{fieldButtonList.map((field) => ( ))}
)} {/* The element Konva will inject it's canvas into. */}
); }; type FieldActionButtonsProps = React.HTMLAttributes & { handleDuplicateSelectedFields: () => void; handleDuplicateSelectedFieldsOnAllPages: () => void; handleDeleteSelectedFields: () => void; handleChangeRecipient: (recipientId: number) => void; handleChangeFieldType: (type: FieldType) => void; selectedFieldFormId: string[]; }; const FieldActionButtons = ({ handleDuplicateSelectedFields, handleDuplicateSelectedFieldsOnAllPages, handleDeleteSelectedFields, handleChangeRecipient, handleChangeFieldType, selectedFieldFormId, ...props }: FieldActionButtonsProps) => { const { t } = useLingui(); const [showRecipientSelector, setShowRecipientSelector] = useState(false); const [showFieldTypeSelector, setShowFieldTypeSelector] = useState(false); const { editorFields, envelope } = useCurrentEnvelopeEditor(); /** * Decide the preselected field type in the command input. * * If all fields share the same type, use that as the default selection. * Otherwise show no preselection. */ const preselectedFieldType = useMemo(() => { if (selectedFieldFormId.length === 0) { return null; } const fields = editorFields.localFields.filter((field) => selectedFieldFormId.includes(field.formId)); if (fields.length === 0) { return null; } const firstType = fields[0].type; const isTypesSame = fields.every((field) => field.type === firstType); return isTypesSame ? firstType : null; }, [editorFields.localFields, selectedFieldFormId]); /** * Decide the preselected recipient in the command input. * * If all fields belong to the same recipient then use that recipient as the default. * * Otherwise show the placeholder. */ const preselectedRecipient = useMemo(() => { if (selectedFieldFormId.length === 0) { return null; } const fields = editorFields.localFields.filter((field) => selectedFieldFormId.includes(field.formId)); if (fields.length === 0) { return null; } const recipient = envelope.recipients.find((recipient) => recipient.id === fields[0].recipientId); if (!recipient) { return null; } const isRecipientsSame = fields.every((field) => field.recipientId === recipient.id); if (isRecipientsSame) { return recipient; } return null; }, [editorFields.localFields, envelope.recipients, selectedFieldFormId]); return (
{ editorFields.setSelectedRecipient(recipient.id); handleChangeRecipient(recipient.id); setShowRecipientSelector(false); }} recipients={envelope.recipients} fields={envelope.fields} /> {t`No field type matching this description was found.`} {fieldButtonList.map((field) => { const FieldIcon = field.icon; const label = t(FRIENDLY_FIELD_TYPE[field.type]); return ( { handleChangeFieldType(field.type); setShowFieldTypeSelector(false); }} > {label} ); })}
); };