From 1592fbd3690ab1ccec804ffa842c1334a0289530 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 6 Nov 2025 15:43:36 +1100 Subject: [PATCH] fix: field hover --- .../document-signing-page-view-v2.tsx | 2 +- .../envelope-editor-preview-page.tsx | 2 +- .../envelope-generic-page-renderer.tsx | 67 +++++-- .../envelope-signer-page-renderer.tsx | 1 - .../t.$teamUrl+/documents.$id._index.tsx | 8 +- .../t.$teamUrl+/documents.$id.edit.tsx | 2 +- .../t.$teamUrl+/templates.$id._index.tsx | 5 +- .../providers/envelope-render-provider.tsx | 25 ++- .../field-renderer/field-renderer.ts | 2 +- .../universal/field-renderer/render-field.ts | 24 ++- .../envelope-recipient-field-tooltip.tsx | 189 ++++++++++++++++++ 11 files changed, 290 insertions(+), 37 deletions(-) create mode 100644 packages/ui/components/document/envelope-recipient-field-tooltip.tsx diff --git a/apps/remix/app/components/general/document-signing/document-signing-page-view-v2.tsx b/apps/remix/app/components/general/document-signing/document-signing-page-view-v2.tsx index ee23842f3..6b1a695ff 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-page-view-v2.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-page-view-v2.tsx @@ -218,7 +218,7 @@ export const DocumentSigningPageViewV2 = () => { )} {/* Mobile widget - Additional padding to allow users to scroll */} -
+
diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx index 43d40d41c..1232573ac 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx @@ -201,7 +201,7 @@ export const EnvelopeEditorPreviewPage = () => { envelope={envelope} token={undefined} fields={fieldsWithPlaceholders} - recipientIds={envelope.recipients.map((recipient) => recipient.id)} + recipients={envelope.recipients} overrideSettings={{ mode: 'export', }} diff --git a/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx b/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx index 37f7cd5b6..48f13127e 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo } from 'react'; import { useLingui } from '@lingui/react/macro'; +import { type Recipient, SigningStatus } from '@prisma/client'; import type Konva from 'konva'; import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer'; @@ -8,12 +9,23 @@ import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/e import type { TEnvelope } from '@documenso/lib/types/envelope'; import { renderField } from '@documenso/lib/universal/field-renderer/render-field'; import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields'; +import { EnvelopeRecipientFieldTooltip } from '@documenso/ui/components/document/envelope-recipient-field-tooltip'; + +type GenericLocalField = TEnvelope['fields'][number] & { + recipient: Pick; +}; export default function EnvelopeGenericPageRenderer() { const { i18n } = useLingui(); - const { currentEnvelopeItem, fields, getRecipientColorKey, setRenderError, overrideSettings } = - useCurrentEnvelopeRender(); + const { + currentEnvelopeItem, + fields, + recipients, + getRecipientColorKey, + setRenderError, + overrideSettings, + } = useCurrentEnvelopeRender(); const { stage, @@ -29,21 +41,38 @@ export default function EnvelopeGenericPageRenderer() { const { _className, scale } = pageContext; - const localPageFields = useMemo( - () => - fields.filter( + const localPageFields = useMemo((): GenericLocalField[] => { + return fields + .filter( (field) => field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id, - ), - [fields, pageContext.pageNumber], - ); + ) + .map((field) => { + const recipient = recipients.find((recipient) => recipient.id === field.recipientId); - const unsafeRenderFieldOnLayer = (field: TEnvelope['fields'][number]) => { + if (!recipient) { + throw new Error(`Recipient not found for field ${field.id}`); + } + + return { + ...field, + recipient, + }; + }); + }, [fields, pageContext.pageNumber, currentEnvelopeItem?.id, recipients]); + + const unsafeRenderFieldOnLayer = (field: GenericLocalField) => { if (!pageLayer.current) { console.error('Layer not loaded yet'); return; } + const { recipient } = field; + + const fieldTranslations = getClientSideFieldTranslations(i18n); + + const isInserted = recipient.signingStatus === SigningStatus.SIGNED && field.inserted; + renderField({ scale, pageLayer: pageLayer.current, @@ -54,10 +83,14 @@ export default function EnvelopeGenericPageRenderer() { height: Number(field.height), positionX: Number(field.positionX), positionY: Number(field.positionY), - customText: field.inserted ? field.customText : '', + customText: isInserted ? field.customText : '', fieldMeta: field.fieldMeta, + signature: { + signatureImageAsBase64: '', + typedSignature: fieldTranslations.SIGNATURE, + }, }, - translations: getClientSideFieldTranslations(i18n), + translations: fieldTranslations, pageWidth: unscaledViewport.width, pageHeight: unscaledViewport.height, color: getRecipientColorKey(field.recipientId), @@ -66,7 +99,7 @@ export default function EnvelopeGenericPageRenderer() { }); }; - const renderFieldOnLayer = (field: TEnvelope['fields'][number]) => { + const renderFieldOnLayer = (field: GenericLocalField) => { try { unsafeRenderFieldOnLayer(field); } catch (err) { @@ -122,6 +155,16 @@ export default function EnvelopeGenericPageRenderer() { className="relative w-full" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`} > + {overrideSettings?.showRecipientTooltip && + localPageFields.map((field) => ( + + ))} + {/* The element Konva will inject it's canvas into. */}
diff --git a/apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx b/apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx index 29a768112..e0fb3e665 100644 --- a/apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx +++ b/apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx @@ -413,7 +413,6 @@ export default function EnvelopeSignerPageRenderer() { } localPageFields.forEach((field) => { - console.log('Field changed/inserted, rendering on canvas'); renderFieldOnLayer(field); }); diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx index 147655a65..4228ec209 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx @@ -148,8 +148,12 @@ export default function DocumentPage({ params }: Route.ComponentProps) { recipient.id)} + fields={envelope.fields} + recipients={envelope.recipients} + overrideSettings={{ + showRecipientSigningStatus: true, + showRecipientTooltip: true, + }} > {isMultiEnvelopeItem && ( diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.edit.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.edit.tsx index e86a51ca7..056b0e08a 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.edit.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.edit.tsx @@ -103,7 +103,7 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) { envelope={envelope} token={undefined} fields={envelope.fields} - recipientIds={envelope.recipients.map((recipient) => recipient.id)} + recipients={envelope.recipients} > diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx index 87e8a4aca..7a497a389 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx @@ -172,7 +172,10 @@ export default function TemplatePage({ params }: Route.ComponentProps) { envelope={envelope} token={undefined} fields={envelope.fields} - recipientIds={envelope.recipients.map((recipient) => recipient.id)} + recipients={envelope.recipients} + overrideSettings={{ + showRecipientTooltip: true, + }} > {isMultiEnvelopeItem && ( diff --git a/packages/lib/client-only/providers/envelope-render-provider.tsx b/packages/lib/client-only/providers/envelope-render-provider.tsx index fdb8aeb72..3682f311a 100644 --- a/packages/lib/client-only/providers/envelope-render-provider.tsx +++ b/packages/lib/client-only/providers/envelope-render-provider.tsx @@ -1,10 +1,13 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import React from 'react'; +import type { Field, Recipient } from '@prisma/client'; + import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors'; import { AVAILABLE_RECIPIENT_COLORS } from '@documenso/ui/lib/recipient-colors'; import type { TEnvelope } from '../../types/envelope'; +import type { FieldRenderMode } from '../../universal/field-renderer/render-field'; import { getEnvelopeDownloadUrl } from '../../utils/envelope-download'; type FileData = @@ -17,7 +20,9 @@ type FileData = }; type EnvelopeRenderOverrideSettings = { - mode: 'edit' | 'sign' | 'export'; + mode?: FieldRenderMode; + showRecipientTooltip?: boolean; + showRecipientSigningStatus?: boolean; }; type EnvelopeRenderItem = TEnvelope['envelopeItems'][number]; @@ -27,7 +32,8 @@ type EnvelopeRenderProviderValue = { envelopeItems: EnvelopeRenderItem[]; currentEnvelopeItem: EnvelopeRenderItem | null; setCurrentEnvelopeItem: (envelopeItemId: string) => void; - fields: TEnvelope['fields']; + fields: Field[]; + recipients: Pick[]; getRecipientColorKey: (recipientId: number) => TRecipientColor; renderError: boolean; @@ -45,14 +51,15 @@ interface EnvelopeRenderProviderProps { * * Only pass if the CustomRenderer you are passing in wants fields. */ - fields?: TEnvelope['fields']; + fields?: Field[]; /** - * Optional recipient IDs used to determine the color of the fields. + * Optional recipient used to determine the color of the fields and hover + * previews. * * Only required for generic page renderers. */ - recipientIds?: number[]; + recipients?: Pick[]; /** * The token to access the envelope. @@ -87,7 +94,7 @@ export const EnvelopeRenderProvider = ({ envelope, fields, token, - recipientIds = [], + recipients = [], overrideSettings, }: EnvelopeRenderProviderProps) => { // Indexed by documentDataId. @@ -175,6 +182,11 @@ export const EnvelopeRenderProvider = ({ } }, [envelope.envelopeItems]); + const recipientIds = useMemo( + () => recipients.map((recipient) => recipient.id).sort(), + [recipients], + ); + const getRecipientColorKey = useCallback( (recipientId: number) => { const recipientIndex = recipientIds.findIndex((id) => id === recipientId); @@ -194,6 +206,7 @@ export const EnvelopeRenderProvider = ({ currentEnvelopeItem: currentItem, setCurrentEnvelopeItem, fields: fields ?? [], + recipients, getRecipientColorKey, renderError, setRenderError, diff --git a/packages/lib/universal/field-renderer/field-renderer.ts b/packages/lib/universal/field-renderer/field-renderer.ts index e925cb0f9..8f66084e2 100644 --- a/packages/lib/universal/field-renderer/field-renderer.ts +++ b/packages/lib/universal/field-renderer/field-renderer.ts @@ -19,7 +19,7 @@ export type FieldToRender = Pick< positionX: number; positionY: number; fieldMeta?: TFieldMetaSchema | null; - signature?: Signature | null; + signature?: Pick | null; }; export type RenderFieldElementOptions = { diff --git a/packages/lib/universal/field-renderer/render-field.ts b/packages/lib/universal/field-renderer/render-field.ts index a96d0dd68..2d657f06c 100644 --- a/packages/lib/universal/field-renderer/render-field.ts +++ b/packages/lib/universal/field-renderer/render-field.ts @@ -15,6 +15,17 @@ import { renderSignatureFieldElement } from './render-signature-field'; export const MIN_FIELD_HEIGHT_PX = 12; export const MIN_FIELD_WIDTH_PX = 36; +/** + * The render type. + * + * @default 'edit' + * + * - `edit` - The field is rendered in editor page. + * - `sign` - The field is rendered for the signing page. + * - `export` - The field is rendered for exporting and sealing into the PDF. No backgrounds, interactive elements, etc. + */ +export type FieldRenderMode = 'edit' | 'sign' | 'export'; + export type FieldToRender = Pick< Field, 'envelopeItemId' | 'recipientId' | 'type' | 'page' | 'customText' | 'inserted' | 'recipientId' @@ -25,7 +36,7 @@ export type FieldToRender = Pick< positionX: number; positionY: number; fieldMeta?: TFieldMetaSchema | null; - signature?: Signature | null; + signature?: Pick | null; }; type RenderFieldOptions = { @@ -38,16 +49,7 @@ type RenderFieldOptions = { translations: Record | null; - /** - * The render type. - * - * @default 'edit' - * - * - `edit` - The field is rendered in editor page. - * - `sign` - The field is rendered for the signing page. - * - `export` - The field is rendered for exporting and sealing into the PDF. No backgrounds, interactive elements, etc. - */ - mode: 'edit' | 'sign' | 'export'; + mode: FieldRenderMode; scale: number; editable?: boolean; diff --git a/packages/ui/components/document/envelope-recipient-field-tooltip.tsx b/packages/ui/components/document/envelope-recipient-field-tooltip.tsx new file mode 100644 index 000000000..323f83e03 --- /dev/null +++ b/packages/ui/components/document/envelope-recipient-field-tooltip.tsx @@ -0,0 +1,189 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { Trans, useLingui } from '@lingui/react/macro'; +import { SigningStatus } from '@prisma/client'; +import type { Field, Recipient } from '@prisma/client'; +import { ClockIcon, EyeOffIcon } from 'lucide-react'; + +import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect'; +import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; + +import { isTemplateRecipientEmailPlaceholder } from '../../../lib/constants/template'; +import { extractInitials } from '../../../lib/utils/recipient-formatter'; +import { SignatureIcon } from '../../icons/signature'; +import { cn } from '../../lib/utils'; +import { Avatar, AvatarFallback } from '../../primitives/avatar'; +import { Badge } from '../../primitives/badge'; +import { FRIENDLY_FIELD_TYPE } from '../../primitives/document-flow/types'; +import { PopoverHover } from '../../primitives/popover'; + +interface EnvelopeRecipientFieldTooltipProps { + field: Pick< + Field, + 'id' | 'inserted' | 'positionX' | 'positionY' | 'width' | 'height' | 'page' | 'type' + > & { + recipient: Pick; + }; + showFieldStatus?: boolean; + showRecipientTooltip?: boolean; + showRecipientColors?: boolean; +} + +const getRecipientDisplayText = (recipient: { name: string; email: string }) => { + if (recipient.name && !isTemplateRecipientEmailPlaceholder(recipient.email)) { + return `${recipient.name} (${recipient.email})`; + } + + if (recipient.name && isTemplateRecipientEmailPlaceholder(recipient.email)) { + return recipient.name; + } + + return recipient.email; +}; + +/** + * Renders a tooltip for a given field. + */ +export function EnvelopeRecipientFieldTooltip({ + field, + showFieldStatus = false, + showRecipientTooltip = false, + showRecipientColors = false, +}: EnvelopeRecipientFieldTooltipProps) { + const { t } = useLingui(); + + const [hideField, setHideField] = useState(!showRecipientTooltip); + + const [coords, setCoords] = useState({ + x: 0, + y: 0, + height: 0, + width: 0, + }); + + const calculateCoords = useCallback(() => { + const $page = document.querySelector( + `${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`, + ); + + if (!$page) { + return; + } + + const { height, width } = getBoundingClientRect($page); + + const fieldHeight = (Number(field.height) / 100) * height; + const fieldWidth = (Number(field.width) / 100) * width; + + const fieldX = (Number(field.positionX) / 100) * width + Number(fieldWidth); + const fieldY = (Number(field.positionY) / 100) * height; + + setCoords({ + x: fieldX, + y: fieldY, + height: fieldHeight, + width: fieldWidth, + }); + }, [field.height, field.page, field.positionX, field.positionY, field.width]); + + useEffect(() => { + calculateCoords(); + }, [calculateCoords]); + + useEffect(() => { + const onResize = () => { + calculateCoords(); + }; + + window.addEventListener('resize', onResize); + + return () => { + window.removeEventListener('resize', onResize); + }; + }, [calculateCoords]); + + useEffect(() => { + const $page = document.querySelector( + `${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`, + ); + + if (!$page) { + return; + } + + const observer = new ResizeObserver(() => { + calculateCoords(); + }); + + observer.observe($page); + + return () => { + observer.disconnect(); + }; + }, [calculateCoords, field.page]); + + if (hideField) { + return null; + } + + return ( +
+ + + {extractInitials(field.recipient.name || field.recipient.email)} + + + } + contentProps={{ + className: 'relative flex mb-4 w-fit flex-col p-4 text-sm', + }} + > + {showFieldStatus && ( + + {field.recipient.signingStatus === SigningStatus.SIGNED ? ( + <> + + Signed + + ) : ( + <> + + Pending + + )} + + )} + +

+ {t(FRIENDLY_FIELD_TYPE[field.type])} field +

+ +

+ {getRecipientDisplayText(field.recipient)} +

+ + +
+
+ ); +}