import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer'; import { type PageRenderData, useCurrentEnvelopeRender, } from '@documenso/lib/client-only/providers/envelope-render-provider'; import { useOptionalSession } from '@documenso/lib/client-only/providers/session'; import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates'; import { isBase64Image } from '@documenso/lib/constants/signatures'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import type { TEnvelope } from '@documenso/lib/types/envelope'; import { ZFullFieldSchema } from '@documenso/lib/types/field'; import { createFieldCanvasStyleCache, type FieldCanvasStyleCache, } from '@documenso/lib/universal/field-renderer/field-canvas-style'; import { createSpinner } from '@documenso/lib/universal/field-renderer/field-generic-items'; import { renderField } from '@documenso/lib/universal/field-renderer/render-field'; import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers'; import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields'; import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; import type { TSignEnvelopeFieldValue } from '@documenso/trpc/server/envelope-router/sign-envelope-field.types'; import { EnvelopeRecipientFieldTooltip } from '@documenso/ui/components/document/envelope-recipient-field-tooltip'; import { EnvelopeFieldToolTip } from '@documenso/ui/components/field/envelope-field-tooltip'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { Trans, useLingui } from '@lingui/react/macro'; import { type Field, FieldType, type Recipient, RecipientRole, type Signature, SigningStatus } from '@prisma/client'; import type Konva from 'konva'; import type { KonvaEventObject } from 'konva/lib/Node'; import { useEffect, useMemo, useRef } from 'react'; import { match } from 'ts-pattern'; import { useEmbedSigningContext } from '~/components/embed/embed-signing-context'; import { handleCheckboxFieldClick } from '~/utils/field-signing/checkbox-field'; import { handleDropdownFieldClick } from '~/utils/field-signing/dropdown-field'; import { handleEmailFieldClick } from '~/utils/field-signing/email-field'; import { handleInitialsFieldClick } from '~/utils/field-signing/initial-field'; import { handleNameFieldClick } from '~/utils/field-signing/name-field'; import { handleNumberFieldClick } from '~/utils/field-signing/number-field'; import { handleSignatureFieldClick } from '~/utils/field-signing/signature-field'; import { handleTextFieldClick } from '~/utils/field-signing/text-field'; import { useRequiredDocumentSigningAuthContext } from '../document-signing/document-signing-auth-provider'; import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider'; type GenericLocalField = TEnvelope['fields'][number] & { recipient: Pick; }; export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderData }) => { const { t, i18n } = useLingui(); const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender(); const { sessionData } = useOptionalSession(); const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext(); const { toast } = useToast(); const { envelopeData, recipient, recipientFields, recipientFieldsRemaining, showPendingFieldTooltip, signField: signFieldInternal, email: emailState, setEmail, fullName: fullNameState, setFullName, signature: signatureState, setSignature, selectedAssistantRecipientFields, selectedAssistantRecipient, isDirectTemplate, } = useRequiredEnvelopeSigningContext(); // Note: We're using refs here due to the closure within the signField function. const fullName = useRef(fullNameState); const email = useRef(emailState); const signature = useRef(signatureState); useEffect(() => { fullName.current = fullNameState; email.current = emailState; signature.current = signatureState; }, [fullNameState, emailState, signatureState]); const cachedRenderFields = useRef>(new Map()); const prevShowPendingFieldTooltip = useRef(showPendingFieldTooltip); const { onFieldSigned, onFieldUnsigned } = useEmbedSigningContext() || {}; const { stage, pageLayer, konvaContainer, unscaledViewport } = usePageRenderer( ({ stage, pageLayer }) => createPageCanvas(stage, pageLayer), pageData, ); const { scale, pageNumber } = pageData; const { envelope } = envelopeData; const localPageFields = useMemo(() => { let fieldsToRender = recipientFields; if (recipient.role === RecipientRole.ASSISTANT) { fieldsToRender = selectedAssistantRecipientFields; } return fieldsToRender.filter( (field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id, ); }, [recipientFields, selectedAssistantRecipientFields, pageNumber, currentEnvelopeItem?.id]); /** * Returns fields that have been fully signed by other recipients for this specific * page. */ const localPageOtherRecipientFields = useMemo((): GenericLocalField[] => { const signedRecipients = envelope.recipients.filter( (recipient) => recipient.signingStatus === SigningStatus.SIGNED, ); return signedRecipients.flatMap((recipient) => { return recipient.fields .filter( (field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id && (field.inserted || field.fieldMeta?.readOnly), ) .map((field) => ({ ...field, recipient: { id: recipient.id, name: recipient.name, email: recipient.email, signingStatus: recipient.signingStatus, role: recipient.role, }, })); }); }, [envelope.recipients, pageNumber, currentEnvelopeItem?.id]); const unsafeRenderFieldOnLayer = ( unparsedField: Field & { signature?: Signature | null }, fieldCanvasStyleCache: FieldCanvasStyleCache, ) => { if (!pageLayer.current) { console.error('Layer not loaded yet'); return; } const fieldToRender = ZFullFieldSchema.parse(unparsedField); const isValidating = showPendingFieldTooltip && isFieldUnsignedAndRequired(fieldToRender); const color = fieldToRender.fieldMeta?.readOnly ? 'readOnly' : isValidating ? 'orange' : 'green'; const { fieldGroup } = renderField({ scale, pageLayer: pageLayer.current, field: { renderId: fieldToRender.id.toString(), ...fieldToRender, width: Number(fieldToRender.width), height: Number(fieldToRender.height), positionX: Number(fieldToRender.positionX), positionY: Number(fieldToRender.positionY), isValidating, signature: unparsedField.signature, }, translations: getClientSideFieldTranslations(i18n), pageWidth: unscaledViewport.width, pageHeight: unscaledViewport.height, color, mode: 'sign', fieldCanvasStyleCache, }); const handleFieldGroupClick = (e: KonvaEventObject) => { const currentTarget = e.currentTarget as Konva.Group; const target = e.target as Konva.Shape; const fieldRect = fieldGroup.findOne('.field-rect'); const fieldWidth = fieldRect ? fieldRect.width() : fieldGroup.width(); const fieldHeight = fieldRect ? fieldRect.height() : fieldGroup.height(); const foundField = localPageFields.find((f) => f.id === unparsedField.id); const foundLoadingGroup = currentTarget.findOne('.loading-spinner-group'); if (!foundField || foundLoadingGroup || foundField.fieldMeta?.readOnly) { return; } let localEmail: string | null = email.current; let localFullName: string | null = fullName.current; let placeholderEmail: string | null = null; if (recipient.role === RecipientRole.ASSISTANT) { localEmail = selectedAssistantRecipient?.email || null; localFullName = selectedAssistantRecipient?.name || null; } // Allows us let the user set a different email than their current logged in email. if (isDirectTemplate) { placeholderEmail = sessionData?.user?.email || email.current || recipient.email; if (!placeholderEmail || placeholderEmail === DIRECT_TEMPLATE_RECIPIENT_EMAIL) { placeholderEmail = null; } } const loadingSpinnerGroup = createSpinner({ fieldWidth, fieldHeight, }); const parsedFoundField = ZFullFieldSchema.parse(foundField); match(parsedFoundField) /** * CHECKBOX FIELD. */ .with({ type: FieldType.CHECKBOX }, (field) => { const clickedCheckboxIndex = Number(target.getAttr('internalCheckboxIndex')); if (Number.isNaN(clickedCheckboxIndex)) { return; } void handleCheckboxFieldClick({ field, clickedCheckboxIndex }) .then(async (payload) => { if (payload) { fieldGroup.add(loadingSpinnerGroup); await signField(field.id, payload); } }) .finally(() => { loadingSpinnerGroup.destroy(); }); }) /** * RADIO FIELD. */ .with({ type: FieldType.RADIO }, (field) => { const selectedRadioIndex = Number(target.getAttr('internalRadioIndex')); const fieldCustomText = Number(field.customText); if (Number.isNaN(selectedRadioIndex)) { return; } fieldGroup.add(loadingSpinnerGroup); // Uncheck the value if it's already pressed. const value = field.inserted && selectedRadioIndex === fieldCustomText ? null : selectedRadioIndex; void signField(field.id, { type: FieldType.RADIO, value, }).finally(() => { loadingSpinnerGroup.destroy(); }); }) /** * NUMBER FIELD. */ .with({ type: FieldType.NUMBER }, (field) => { void handleNumberFieldClick({ field, number: null }) .then(async (payload) => { if (payload) { fieldGroup.add(loadingSpinnerGroup); await signField(field.id, payload); } }) .finally(() => { loadingSpinnerGroup.destroy(); }); }) /** * TEXT FIELD. */ .with({ type: FieldType.TEXT }, (field) => { void handleTextFieldClick({ field, text: null }) .then(async (payload) => { if (payload) { fieldGroup.add(loadingSpinnerGroup); await signField(field.id, payload); } }) .finally(() => { loadingSpinnerGroup.destroy(); }); }) /** * EMAIL FIELD. */ .with({ type: FieldType.EMAIL }, (field) => { void handleEmailFieldClick({ field, email: localEmail, placeholderEmail }) .then(async (payload) => { if (payload) { fieldGroup.add(loadingSpinnerGroup); await signField(field.id, payload); } if (payload?.value) { setEmail(payload.value); } }) .finally(() => { loadingSpinnerGroup.destroy(); }); }) /** * INITIALS FIELD. */ .with({ type: FieldType.INITIALS }, (field) => { const initials = localFullName ? extractInitials(localFullName) : null; void handleInitialsFieldClick({ field, initials }) .then(async (payload) => { if (payload) { fieldGroup.add(loadingSpinnerGroup); await signField(field.id, payload); } }) .finally(() => { loadingSpinnerGroup.destroy(); }); }) /** * NAME FIELD. */ .with({ type: FieldType.NAME }, (field) => { void handleNameFieldClick({ field, name: localFullName }) .then(async (payload) => { if (payload) { fieldGroup.add(loadingSpinnerGroup); await signField(field.id, payload); } if (payload?.value) { setFullName(payload.value); } }) .finally(() => { loadingSpinnerGroup.destroy(); }); }) /** * DROPDOWN FIELD. */ .with({ type: FieldType.DROPDOWN }, (field) => { void handleDropdownFieldClick({ field, text: null }) .then(async (payload) => { if (payload) { fieldGroup.add(loadingSpinnerGroup); await signField(field.id, payload); } }) .finally(() => { loadingSpinnerGroup.destroy(); }); }) /** * DATE FIELD. */ .with({ type: FieldType.DATE }, (field) => { fieldGroup.add(loadingSpinnerGroup); void signField(field.id, { type: FieldType.DATE, value: !field.inserted, }).finally(() => { loadingSpinnerGroup.destroy(); }); }) /** * SIGNATURE FIELD. */ .with({ type: FieldType.SIGNATURE }, (field) => { void handleSignatureFieldClick({ field, fullName: fullName.current, signature: signature.current, typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled, uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled, drawSignatureEnabled: envelope.documentMeta.drawSignatureEnabled, }) .then(async (payload) => { if (!payload) { return; } fieldGroup.add(loadingSpinnerGroup); if (payload.value) { await executeActionAuthProcedure({ onReauthFormSubmit: async (authOptions) => { await signField(field.id, payload, authOptions); loadingSpinnerGroup.destroy(); }, actionTarget: field.type, }); setSignature(payload.value); } else { await signField(field.id, payload); } }) .finally(() => { loadingSpinnerGroup.destroy(); }); }) .exhaustive(); }; fieldGroup.off('pointerdown'); fieldGroup.on('pointerdown', handleFieldGroupClick); }; const renderFieldOnLayer = ( unparsedField: Field & { signature?: Signature | null }, fieldCanvasStyleCache: FieldCanvasStyleCache, ) => { try { unsafeRenderFieldOnLayer(unparsedField, fieldCanvasStyleCache); } catch (err) { console.error(err); setRenderError(true); } }; const renderFields = () => { if (!pageLayer.current) { console.error('Layer not loaded yet'); return; } const fieldCanvasStyleCache = createFieldCanvasStyleCache(); // Render current recipient fields which have changed or are not currently rendered. for (const field of localPageFields) { const existingCachedField = cachedRenderFields.current.get(field.id); const isFieldCurrentlyRendered = pageLayer.current.findOne(`#${field.id}`); if ( !isFieldCurrentlyRendered || !existingCachedField || existingCachedField.inserted !== field.inserted || existingCachedField.customText !== field.customText ) { renderFieldOnLayer(field, fieldCanvasStyleCache); cachedRenderFields.current.set(field.id, field); } } // Render other recipient signed and inserted fields. for (const field of localPageOtherRecipientFields) { try { const { fieldGroup } = renderField({ scale, pageLayer: pageLayer.current, field: { renderId: field.id.toString(), ...field, width: Number(field.width), height: Number(field.height), positionX: Number(field.positionX), positionY: Number(field.positionY), fieldMeta: field.fieldMeta, }, translations: getClientSideFieldTranslations(i18n), pageWidth: unscaledViewport.width, pageHeight: unscaledViewport.height, color: 'readOnly', editable: false, mode: 'sign', fieldCanvasStyleCache, }); // Other-recipient fields are display-only — they have no click handlers // and shouldn't intercept events meant for the current recipient's // fields. Disable hit detection on the entire group. fieldGroup.listening(false); } catch (err) { console.error('Unable to render one or more fields belonging to other recipients.'); console.error(err); } } }; const signField = async (fieldId: number, payload: TSignEnvelopeFieldValue, authOptions?: TRecipientActionAuth) => { try { const { inserted } = await signFieldInternal(fieldId, payload, authOptions); // ?: The two callbacks below are used within the embedding context if (inserted && onFieldSigned) { const value = payload.value ? JSON.stringify(payload.value) : undefined; const isBase64 = value ? isBase64Image(value) : undefined; onFieldSigned({ fieldId, value, isBase64 }); } if (!inserted && onFieldUnsigned) { onFieldUnsigned({ fieldId }); } } catch (err) { console.error(err); toast({ title: t`Error`, description: t`An error occurred while signing the field.`, variant: 'destructive', }); throw err; } }; /** * Initialize the Konva page canvas and all fields and interactions. */ const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => { renderFields(); currentPageLayer.batchDraw(); }; /** * Render fields when they are changed or inserted. */ useEffect(() => { if (!pageLayer.current || !stage.current) { return; } // When the pending-field tooltip toggles, all unsigned required fields need to // be re-rendered so their stroke color updates (green <-> orange). Field-level // properties like `inserted` and `customText` haven't changed, so the cache // would otherwise skip them — clear it to force a fresh render. if (prevShowPendingFieldTooltip.current !== showPendingFieldTooltip) { cachedRenderFields.current.clear(); prevShowPendingFieldTooltip.current = showPendingFieldTooltip; } renderFields(); pageLayer.current.batchDraw(); }, [localPageFields, showPendingFieldTooltip]); /** * Rerender the whole page if the selected assistant recipient changes. */ useEffect(() => { if (!pageLayer.current || !stage.current) { return; } // Rerender the whole page. pageLayer.current.destroyChildren(); cachedRenderFields.current.clear(); renderFields(); pageLayer.current.batchDraw(); }, [selectedAssistantRecipient]); if (!currentEnvelopeItem) { return null; } return ( <> {showPendingFieldTooltip && recipientFieldsRemaining.length > 0 && recipientFieldsRemaining[0]?.envelopeItemId === currentEnvelopeItem?.id && recipientFieldsRemaining[0]?.page === pageNumber && ( Click to insert field )} {localPageOtherRecipientFields.map((field) => ( ))} {/* The element Konva will inject it's canvas into. */}
); };