import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import type { DocumentData, FieldType } from '@prisma/client'; import { ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client'; import { base64 } from '@scure/base'; import { ChevronsUpDown } from 'lucide-react'; import { useFieldArray, useForm } from 'react-hook-form'; import { useHotkeys } from 'react-hotkeys-hook'; import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect'; import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { type TFieldMetaSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta'; import { nanoid } from '@documenso/lib/universal/id'; import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers'; import { useRecipientColors } from '@documenso/ui/lib/recipient-colors'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { FieldItem } from '@documenso/ui/primitives/document-flow/field-item'; import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types'; import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { FieldSelector } from '@documenso/ui/primitives/field-selector'; import { Form } from '@documenso/ui/primitives/form/form'; import PDFViewer from '@documenso/ui/primitives/pdf-viewer'; import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector'; import { Sheet, SheetContent, SheetTrigger } from '@documenso/ui/primitives/sheet'; import { useToast } from '@documenso/ui/primitives/use-toast'; import type { TConfigureEmbedFormSchema } from './configure-document-view.types'; import type { TConfigureFieldsFormSchema } from './configure-fields-view.types'; import { FieldAdvancedSettingsDrawer } from './field-advanced-settings-drawer'; const MIN_HEIGHT_PX = 12; const MIN_WIDTH_PX = 36; const DEFAULT_HEIGHT_PX = MIN_HEIGHT_PX * 2.5; const DEFAULT_WIDTH_PX = MIN_WIDTH_PX * 2.5; export type ConfigureFieldsViewProps = { configData: TConfigureEmbedFormSchema; documentData?: DocumentData; defaultValues?: Partial; onBack: (data: TConfigureFieldsFormSchema) => void; onSubmit: (data: TConfigureFieldsFormSchema) => void; }; export const ConfigureFieldsView = ({ configData, documentData, defaultValues, onBack, onSubmit, }: ConfigureFieldsViewProps) => { const { _ } = useLingui(); const { toast } = useToast(); const { isWithinPageBounds, getFieldPosition, getPage } = useDocumentElement(); // Track if we're on a mobile device const [isMobile, setIsMobile] = useState(false); // State for managing the mobile drawer const [isDrawerOpen, setIsDrawerOpen] = useState(false); // Check for mobile viewport on component mount and resize useEffect(() => { const checkIfMobile = () => { setIsMobile(window.innerWidth < 768); }; // Initial check checkIfMobile(); // Add resize listener window.addEventListener('resize', checkIfMobile); // Cleanup return () => { window.removeEventListener('resize', checkIfMobile); }; }, []); const normalizedDocumentData = useMemo(() => { if (documentData) { return documentData.data; } if (!configData.documentData) { return null; } return base64.encode(configData.documentData.data); }, [configData.documentData]); const recipients = useMemo(() => { return configData.signers.map((signer, index) => ({ id: signer.nativeId || index, name: signer.name || '', email: signer.email || '', role: signer.role, signingOrder: signer.signingOrder || null, documentId: null, templateId: null, token: '', documentDeletedAt: null, expired: null, signedAt: null, authOptions: null, rejectionReason: null, sendStatus: signer.disabled ? SendStatus.SENT : SendStatus.NOT_SENT, readStatus: signer.disabled ? ReadStatus.OPENED : ReadStatus.NOT_OPENED, signingStatus: signer.disabled ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED, envelopeId: '', })); }, [configData.signers]); const [selectedRecipient, setSelectedRecipient] = useState( () => recipients.find((r) => r.signingStatus === SigningStatus.NOT_SIGNED) || null, ); const [selectedField, setSelectedField] = useState(null); const [isFieldWithinBounds, setIsFieldWithinBounds] = useState(false); const [coords, setCoords] = useState({ x: 0, y: 0, }); const [activeFieldId, setActiveFieldId] = useState(null); const [lastActiveField, setLastActiveField] = useState< TConfigureFieldsFormSchema['fields'][0] | null >(null); const [fieldClipboard, setFieldClipboard] = useState< TConfigureFieldsFormSchema['fields'][0] | null >(null); const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); const [currentField, setCurrentField] = useState( null, ); const fieldBounds = useRef({ height: DEFAULT_HEIGHT_PX, width: DEFAULT_WIDTH_PX, }); const selectedRecipientIndex = recipients.findIndex((r) => r.id === selectedRecipient?.id); const selectedRecipientStyles = useRecipientColors( selectedRecipientIndex === -1 ? 0 : selectedRecipientIndex, ); const form = useForm({ defaultValues: { fields: defaultValues?.fields ?? [], }, }); const { control, handleSubmit } = form; const onFormSubmit = handleSubmit(onSubmit); const { append, remove, update, fields: localFields, } = useFieldArray({ control: control, name: 'fields', }); const highestPageNumber = Math.max(...localFields.map((field) => field.pageNumber)); const onFieldCopy = useCallback( (event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => { const { duplicate = false, duplicateAll = false } = options ?? {}; if (lastActiveField) { event?.preventDefault(); if (duplicate) { const newField: TConfigureFieldsFormSchema['fields'][0] = { ...structuredClone(lastActiveField), nativeId: undefined, formId: nanoid(12), signerEmail: selectedRecipient?.email ?? lastActiveField.signerEmail, recipientId: selectedRecipient?.id ?? lastActiveField.recipientId, pageX: lastActiveField.pageX + 3, pageY: lastActiveField.pageY + 3, }; append(newField); return; } if (duplicateAll) { const pages = Array.from(document.querySelectorAll(PDF_VIEWER_PAGE_SELECTOR)); pages.forEach((_, index) => { const pageNumber = index + 1; if (pageNumber === lastActiveField.pageNumber) { return; } const newField: TConfigureFieldsFormSchema['fields'][0] = { ...structuredClone(lastActiveField), nativeId: undefined, formId: nanoid(12), signerEmail: selectedRecipient?.email ?? lastActiveField.signerEmail, recipientId: selectedRecipient?.id ?? lastActiveField.recipientId, pageNumber, }; append(newField); }); return; } setFieldClipboard(lastActiveField); toast({ title: 'Copied field', description: 'Copied field to clipboard', }); } }, [append, lastActiveField, selectedRecipient?.email, selectedRecipient?.id, toast], ); const onFieldPaste = useCallback( (event: KeyboardEvent) => { if (fieldClipboard) { event.preventDefault(); const copiedField = structuredClone(fieldClipboard); append({ ...copiedField, nativeId: undefined, formId: nanoid(12), signerEmail: selectedRecipient?.email ?? copiedField.signerEmail, recipientId: selectedRecipient?.id ?? copiedField.recipientId, pageX: copiedField.pageX + 3, pageY: copiedField.pageY + 3, }); } }, [append, fieldClipboard, selectedRecipient?.email, selectedRecipient?.id], ); useHotkeys(['ctrl+c', 'meta+c'], (evt) => onFieldCopy(evt)); useHotkeys(['ctrl+v', 'meta+v'], (evt) => onFieldPaste(evt)); useHotkeys(['ctrl+d', 'meta+d'], (evt) => onFieldCopy(evt, { duplicate: true })); const onMouseMove = useCallback( (event: MouseEvent) => { if (!selectedField) return; setIsFieldWithinBounds( isWithinPageBounds( event, PDF_VIEWER_PAGE_SELECTOR, fieldBounds.current.width, fieldBounds.current.height, ), ); setCoords({ x: event.clientX - fieldBounds.current.width / 2, y: event.clientY - fieldBounds.current.height / 2, }); }, [isWithinPageBounds, selectedField], ); const onMouseClick = useCallback( (event: MouseEvent) => { if (!selectedField || !selectedRecipient) { return; } const $page = getPage(event, PDF_VIEWER_PAGE_SELECTOR); if ( !$page || !isWithinPageBounds( event, PDF_VIEWER_PAGE_SELECTOR, fieldBounds.current.width, fieldBounds.current.height, ) ) { return; } const { top, left, height, width } = getBoundingClientRect($page); const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10); // Calculate x and y as a percentage of the page width and height let pageX = ((event.pageX - left) / width) * 100; let pageY = ((event.pageY - top) / height) * 100; // Get the bounds as a percentage of the page width and height const fieldPageWidth = (fieldBounds.current.width / width) * 100; const fieldPageHeight = (fieldBounds.current.height / height) * 100; // And center it based on the bounds pageX -= fieldPageWidth / 2; pageY -= fieldPageHeight / 2; const field = { formId: nanoid(12), type: selectedField, pageNumber, pageX, pageY, pageWidth: fieldPageWidth, pageHeight: fieldPageHeight, recipientId: selectedRecipient.id, signerEmail: selectedRecipient.email, fieldMeta: undefined, }; append(field); // Automatically open advanced settings for field types that need configuration if (ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING.includes(selectedField)) { setCurrentField(field); setShowAdvancedSettings(true); } setSelectedField(null); }, [append, getPage, isWithinPageBounds, selectedField, selectedRecipient], ); const onFieldResize = useCallback( (node: HTMLElement, index: number) => { const field = localFields[index]; const $page = window.document.querySelector( `${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`, ); if (!$page) { return; } const { x: pageX, y: pageY, width: pageWidth, height: pageHeight, } = getFieldPosition($page, node); update(index, { ...field, pageX, pageY, pageWidth, pageHeight, }); }, [getFieldPosition, localFields, update], ); const onFieldMove = useCallback( (node: HTMLElement, index: number) => { const field = localFields[index]; const $page = window.document.querySelector( `${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`, ); if (!$page) { return; } const { x: pageX, y: pageY } = getFieldPosition($page, node); update(index, { ...field, pageX, pageY, }); }, [getFieldPosition, localFields, update], ); const handleUpdateFieldMeta = useCallback( (formId: string, fieldMeta: TFieldMetaSchema) => { const fieldIndex = localFields.findIndex((field) => field.formId === formId); if (fieldIndex !== -1) { const parsedFieldMeta = ZFieldMetaSchema.parse(fieldMeta); update(fieldIndex, { ...localFields[fieldIndex], fieldMeta: parsedFieldMeta, }); } }, [localFields, update], ); useEffect(() => { if (selectedField) { window.addEventListener('mousemove', onMouseMove); window.addEventListener('mouseup', onMouseClick); } return () => { window.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mouseup', onMouseClick); }; }, [onMouseClick, onMouseMove, selectedField]); useEffect(() => { const observer = new MutationObserver((_mutations) => { const $page = document.querySelector(PDF_VIEWER_PAGE_SELECTOR); if (!$page) { return; } fieldBounds.current = { height: Math.max(DEFAULT_HEIGHT_PX), width: Math.max(DEFAULT_WIDTH_PX), }; }); observer.observe(document.body, { childList: true, subtree: true, }); return () => { observer.disconnect(); }; }, []); // Close drawer when a field is selected on mobile useEffect(() => { if (isMobile && selectedField) { setIsDrawerOpen(false); } }, [isMobile, selectedField]); return ( <>
{/* Desktop sidebar */} {!isMobile && (

Configure Fields

Configure the fields you want to place on the document.


)}
{selectedField && (
{_(FRIENDLY_FIELD_TYPE[selectedField])}
)}
{normalizedDocumentData && (
{localFields.map((field, index) => { const recipientIndex = recipients.findIndex( (r) => r.id === field.recipientId, ); return ( onFieldResize(node, index)} onMove={(node) => onFieldMove(node, index)} onRemove={() => remove(index)} onDuplicate={() => onFieldCopy(null, { duplicate: true })} onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })} onFocus={() => setLastActiveField(field)} onBlur={() => setLastActiveField(null)} onAdvancedSettings={() => { setCurrentField(field); setShowAdvancedSettings(true); }} recipientIndex={recipientIndex} active={activeFieldId === field.formId} onFieldActivate={() => setActiveFieldId(field.formId)} onFieldDeactivate={() => setActiveFieldId(null)} disabled={selectedRecipient?.id !== field.recipientId} /> ); })}
)}
{/* Mobile Floating Action Bar and Drawer */} {isMobile && (
Configure Fields

Configure Fields

Configure the fields you want to place on the document.


{ setSelectedField(field); if (field) { setIsDrawerOpen(false); } }} className="w-full" disabled={!selectedRecipient} />
)} ); };