From ecc98fbd41f062ef312313c3d29339a5e1334dd6 Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Tue, 9 Jun 2026 08:05:22 +0300 Subject: [PATCH] feat: enhance document signing page with field canvas style integration (#2876) --- .../envelope-signer-page-renderer.tsx | 31 +++- package-lock.json | 2 + .../providers/envelope-render-provider.tsx | 2 +- packages/lib/package.json | 1 + .../field-renderer/field-canvas-style.test.ts | 114 ++++++++++++ .../field-renderer/field-canvas-style.ts | 168 ++++++++++++++++++ .../field-renderer/field-generic-items.ts | 14 +- .../field-renderer/field-renderer.ts | 23 ++- .../universal/field-renderer/render-field.ts | 34 +--- packages/ui/components/field/field.tsx | 3 +- .../ui/lib/field-root-container-classes.ts | 14 ++ packages/ui/package.json | 1 + 12 files changed, 364 insertions(+), 43 deletions(-) create mode 100644 packages/lib/universal/field-renderer/field-canvas-style.test.ts create mode 100644 packages/lib/universal/field-renderer/field-canvas-style.ts create mode 100644 packages/ui/lib/field-root-container-classes.ts 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 10928fefc..8caab7ac0 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 @@ -9,6 +9,10 @@ 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'; @@ -135,7 +139,10 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD }); }, [envelope.recipients, pageNumber, currentEnvelopeItem?.id]); - const unsafeRenderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => { + const unsafeRenderFieldOnLayer = ( + unparsedField: Field & { signature?: Signature | null }, + fieldCanvasStyleCache: FieldCanvasStyleCache, + ) => { if (!pageLayer.current) { console.error('Layer not loaded yet'); return; @@ -143,11 +150,9 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD const fieldToRender = ZFullFieldSchema.parse(unparsedField); - const color = fieldToRender.fieldMeta?.readOnly - ? 'readOnly' - : showPendingFieldTooltip && isFieldUnsignedAndRequired(fieldToRender) - ? 'orange' - : 'green'; + const isValidating = showPendingFieldTooltip && isFieldUnsignedAndRequired(fieldToRender); + + const color = fieldToRender.fieldMeta?.readOnly ? 'readOnly' : isValidating ? 'orange' : 'green'; const { fieldGroup } = renderField({ scale, @@ -159,6 +164,7 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD height: Number(fieldToRender.height), positionX: Number(fieldToRender.positionX), positionY: Number(fieldToRender.positionY), + isValidating, signature: unparsedField.signature, }, translations: getClientSideFieldTranslations(i18n), @@ -166,6 +172,7 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD pageHeight: unscaledViewport.height, color, mode: 'sign', + fieldCanvasStyleCache, }); const handleFieldGroupClick = (e: KonvaEventObject) => { @@ -411,9 +418,12 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD fieldGroup.on('pointerdown', handleFieldGroupClick); }; - const renderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => { + const renderFieldOnLayer = ( + unparsedField: Field & { signature?: Signature | null }, + fieldCanvasStyleCache: FieldCanvasStyleCache, + ) => { try { - unsafeRenderFieldOnLayer(unparsedField); + unsafeRenderFieldOnLayer(unparsedField, fieldCanvasStyleCache); } catch (err) { console.error(err); setRenderError(true); @@ -426,6 +436,8 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD 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); @@ -437,7 +449,7 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD existingCachedField.inserted !== field.inserted || existingCachedField.customText !== field.customText ) { - renderFieldOnLayer(field); + renderFieldOnLayer(field, fieldCanvasStyleCache); cachedRenderFields.current.set(field.id, field); } } @@ -463,6 +475,7 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD color: 'readOnly', editable: false, mode: 'sign', + fieldCanvasStyleCache, }); // Other-recipient fields are display-only — they have no click handlers diff --git a/package-lock.json b/package-lock.json index 7f51b8942..5f65374b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30941,6 +30941,7 @@ "@vvo/tzdb": "^6.196.0", "ai": "^5.0.104", "bullmq": "^5.71.1", + "colord": "^2.9.3", "csv-parse": "^6.1.0", "inngest": "^3.54.0", "ioredis": "^5.10.1", @@ -31123,6 +31124,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^1.2.1", "cmdk": "^0.2.1", + "colord": "^2.9.3", "framer-motion": "^12.23.24", "lucide-react": "^0.554.0", "luxon": "^3.7.2", diff --git a/packages/lib/client-only/providers/envelope-render-provider.tsx b/packages/lib/client-only/providers/envelope-render-provider.tsx index b72484ef3..67f8d1405 100644 --- a/packages/lib/client-only/providers/envelope-render-provider.tsx +++ b/packages/lib/client-only/providers/envelope-render-provider.tsx @@ -7,7 +7,7 @@ import type React from 'react'; import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import type { TEnvelope } from '../../types/envelope'; -import type { FieldRenderMode } from '../../universal/field-renderer/render-field'; +import type { FieldRenderMode } from '../../universal/field-renderer/field-renderer'; /** * The signature data for an inserted signature field. diff --git a/packages/lib/package.json b/packages/lib/package.json index 288e07241..450f39172 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -44,6 +44,7 @@ "@vvo/tzdb": "^6.196.0", "ai": "^5.0.104", "bullmq": "^5.71.1", + "colord": "^2.9.3", "csv-parse": "^6.1.0", "inngest": "^3.54.0", "ioredis": "^5.10.1", diff --git a/packages/lib/universal/field-renderer/field-canvas-style.test.ts b/packages/lib/universal/field-renderer/field-canvas-style.test.ts new file mode 100644 index 000000000..5f57ed379 --- /dev/null +++ b/packages/lib/universal/field-renderer/field-canvas-style.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest'; + +import { + getFieldCanvasStyleCacheKey, + getOpacityValue, + getPixelValue, + getRenderableColor, + TRANSPARENT_COLOR, +} from './field-canvas-style'; +import type { FieldToRender } from './field-renderer'; + +const createField = (overrides: Partial = {}) => + ({ + type: 'SIGNATURE', + inserted: false, + fieldMeta: null, + ...overrides, + }) as FieldToRender; + +describe('getPixelValue', () => { + it('parses pixel values', () => { + expect(getPixelValue('12px')).toBe(12); + expect(getPixelValue('0px')).toBe(0); + expect(getPixelValue('1.5px')).toBe(1.5); + }); + + it('parses unitless numeric strings', () => { + expect(getPixelValue('42')).toBe(42); + }); + + it('returns undefined for non-finite or unparseable values', () => { + expect(getPixelValue('')).toBeUndefined(); + expect(getPixelValue('auto')).toBeUndefined(); + expect(getPixelValue('inherit')).toBeUndefined(); + expect(getPixelValue('NaN')).toBeUndefined(); + }); +}); + +describe('getOpacityValue', () => { + it('returns undefined for fully opaque (the default) so callers fall back', () => { + expect(getOpacityValue('1')).toBeUndefined(); + }); + + it('keeps values inside the [0, 1) range as-is', () => { + expect(getOpacityValue('0')).toBe(0); + expect(getOpacityValue('0.5')).toBe(0.5); + expect(getOpacityValue('0.999')).toBe(0.999); + }); + + it('clamps out-of-range values into [0, 1]', () => { + expect(getOpacityValue('-0.5')).toBe(0); + expect(getOpacityValue('2')).toBe(1); + }); + + it('returns undefined for non-finite values', () => { + expect(getOpacityValue('')).toBeUndefined(); + expect(getOpacityValue('inherit')).toBeUndefined(); + expect(getOpacityValue('NaN')).toBeUndefined(); + }); +}); + +describe('getRenderableColor', () => { + it('passes non-transparent colors through unchanged', () => { + expect(getRenderableColor('rgb(255, 0, 0)')).toBe('rgb(255, 0, 0)'); + expect(getRenderableColor('rgba(0, 128, 0, 0.5)')).toBe('rgba(0, 128, 0, 0.5)'); + expect(getRenderableColor('rgba(255, 255, 255, 0.9)')).toBe('rgba(255, 255, 255, 0.9)'); + expect(getRenderableColor('#abcdef')).toBe('#abcdef'); + }); + + it('returns undefined for falsy input', () => { + expect(getRenderableColor(undefined)).toBeUndefined(); + expect(getRenderableColor('')).toBeUndefined(); + }); + + it('normalizes the `transparent` keyword to a renderable transparent color, regardless of case or whitespace', () => { + expect(getRenderableColor('transparent')).toBe(TRANSPARENT_COLOR); + expect(getRenderableColor('TRANSPARENT')).toBe(TRANSPARENT_COLOR); + expect(getRenderableColor(' Transparent ')).toBe(TRANSPARENT_COLOR); + }); + + it('normalizes fully transparent rgba() colors to a renderable transparent color', () => { + expect(getRenderableColor('rgba(0, 0, 0, 0)')).toBe(TRANSPARENT_COLOR); + expect(getRenderableColor('rgba(255, 0, 0, 0)')).toBe(TRANSPARENT_COLOR); + expect(getRenderableColor('rgba(255, 0, 0, 0.0)')).toBe(TRANSPARENT_COLOR); + expect(getRenderableColor('rgba(255, 0, 0, 0.00)')).toBe(TRANSPARENT_COLOR); + }); + + it('normalizes space-separated (CSS Color 4) fully transparent colors to a renderable transparent color', () => { + expect(getRenderableColor('rgb(0 128 0 / 0)')).toBe(TRANSPARENT_COLOR); + expect(getRenderableColor('rgba(255 0 0 / 0)')).toBe(TRANSPARENT_COLOR); + }); + + it('passes space-separated (CSS Color 4) colors through unchanged', () => { + expect(getRenderableColor('rgb(0 128 0 / 0.5)')).toBe('rgb(0 128 0 / 0.5)'); + }); + + it('returns undefined for unparseable color values', () => { + expect(getRenderableColor('none')).toBeUndefined(); + expect(getRenderableColor('not-a-color')).toBeUndefined(); + }); + + it('does not strip non-zero alpha colors', () => { + expect(getRenderableColor('rgba(255, 0, 0, 0.5)')).toBe('rgba(255, 0, 0, 0.5)'); + expect(getRenderableColor('rgba(255, 0, 0, 1)')).toBe('rgba(255, 0, 0, 1)'); + expect(getRenderableColor('rgba(255, 0, 0, 0.01)')).toBe('rgba(255, 0, 0, 0.01)'); + }); +}); + +describe('getFieldCanvasStyleCacheKey', () => { + it('includes validation state', () => { + expect(getFieldCanvasStyleCacheKey(createField({ isValidating: false }))).toBe('SIGNATURE:false:false:false'); + expect(getFieldCanvasStyleCacheKey(createField({ isValidating: true }))).toBe('SIGNATURE:false:false:true'); + }); +}); diff --git a/packages/lib/universal/field-renderer/field-canvas-style.ts b/packages/lib/universal/field-renderer/field-canvas-style.ts new file mode 100644 index 000000000..316185607 --- /dev/null +++ b/packages/lib/universal/field-renderer/field-canvas-style.ts @@ -0,0 +1,168 @@ +import { + FIELD_PROBE_ANCHOR_SELECTOR, + FIELD_ROOT_CONTAINER_PROBE_CLASS_NAME, +} from '@documenso/ui/lib/field-root-container-classes'; +import { colord } from 'colord'; + +import type { FieldCanvasStyle, FieldRenderMode, FieldToRender } from './field-renderer'; + +export type FieldCanvasStyleCache = Map; + +export const createFieldCanvasStyleCache = (): FieldCanvasStyleCache => new Map(); + +export const getFieldCanvasStyleCacheKey = (field: FieldToRender) => + `${field.type}:${field.inserted}:${field.fieldMeta?.readOnly ?? false}:${field.isValidating ?? false}`; + +export const getPixelValue = (value: string) => { + const parsedValue = Number.parseFloat(value); + + if (!Number.isFinite(parsedValue)) { + return undefined; + } + + return parsedValue; +}; + +export const getOpacityValue = (value: string) => { + const parsedValue = Number.parseFloat(value); + + if (!Number.isFinite(parsedValue) || parsedValue === 1) { + return undefined; + } + + return Math.max(0, Math.min(parsedValue, 1)); +}; + +// Canonical value Konva paints as fully transparent. We normalize transparent +// inputs to this so the renderer can tell "customer asked for transparent" +// (honored — paint nothing) apart from "no custom style" (undefined — fall back +// to the renderer default). +export const TRANSPARENT_COLOR = 'rgba(0, 0, 0, 0)'; + +export const getRenderableColor = (value: string | undefined) => { + if (!value) { + return undefined; + } + + const color = colord(value); + + // Unparseable input (e.g. `none`) has no canvas meaning, so fall back to the + // renderer defaults. Inputs come from `getComputedStyle`, which normalizes to + // `rgb()`/`rgba()`, so the base colord parser (no named-colors plugin) is + // sufficient here. The `transparent` keyword is the one exception colord + // reports as invalid; we treat it as an explicit transparent request. + if (!color.isValid()) { + return value.trim().toLowerCase() === 'transparent' ? TRANSPARENT_COLOR : undefined; + } + + // A fully transparent color is a deliberate choice — honor it by painting + // nothing rather than falling back to the default background/border. + if (color.alpha() === 0) { + return TRANSPARENT_COLOR; + } + + return value; +}; + +/** + * Build a throwaway field container that mirrors the real `FieldRootContainer` + * (same classes + data attributes) so we can read whatever the active embed CSS + * resolves for this field's state. + * + * `transition` is disabled because we read the computed style synchronously right + * after attaching — leaving transitions on would surface mid-animation values. + * `visibility: hidden` + zero size keep it invisible and out of layout flow + * without using `display: none`, which would prevent border/background resolution. + */ +const createFieldProbeElement = (field: FieldToRender): HTMLElement => { + const $probe = document.createElement('div'); + + $probe.className = FIELD_ROOT_CONTAINER_PROBE_CLASS_NAME; + $probe.setAttribute('aria-hidden', 'true'); + + $probe.dataset.fieldType = field.type; + $probe.dataset.inserted = field.inserted ? 'true' : 'false'; + $probe.dataset.validate = field.isValidating ? 'true' : 'false'; + $probe.dataset.readonly = field.fieldMeta?.readOnly ? 'true' : 'false'; + + Object.assign($probe.style, { + position: 'absolute', + width: '0', + height: '0', + overflow: 'hidden', + pointerEvents: 'none', + visibility: 'hidden', + transition: 'none', + } satisfies Partial); + + return $probe; +}; + +const computeFieldCanvasStyleFromProbe = (field: FieldToRender): FieldCanvasStyle | undefined => { + if (typeof document === 'undefined' || typeof window === 'undefined') { + return undefined; + } + + // The probe must be appended inside the same subtree as the real fields so it + // inherits the identical CSS cascade. Custom embed CSS is typically scoped + // under `.embed--DocumentContainer`; appending to `document.body` would resolve + // a different (wrong) cascade. If the anchor is absent (non-embed contexts), + // there is no custom field CSS to read, so we skip the probe entirely. + const $anchor = document.querySelector(FIELD_PROBE_ANCHOR_SELECTOR); + + if (!$anchor) { + return undefined; + } + + const $probe = createFieldProbeElement(field); + + $anchor.appendChild($probe); + + try { + const computedStyle = window.getComputedStyle($probe); + const borderWidth = getPixelValue(computedStyle.borderTopWidth); + const borderColor = getRenderableColor(computedStyle.borderTopColor); + const hasBorderStyle = computedStyle.borderTopStyle !== 'none' && Boolean(borderWidth); + const borderRadius = getPixelValue(computedStyle.borderTopLeftRadius); + + return { + backgroundColor: getRenderableColor(computedStyle.backgroundColor), + borderColor: hasBorderStyle ? borderColor : undefined, + borderRadius, + borderWidth: hasBorderStyle ? borderWidth : undefined, + opacity: getOpacityValue(computedStyle.opacity), + }; + } finally { + $probe.remove(); + } +}; + +/** + * Resolve the canvas style for a field by reading a throwaway probe element's + * computed CSS. + * + * Sign-mode only — the editor and export views intentionally use the renderer + * defaults. Reads are cache-gated, so the probe is created/removed at most once + * per unique field state per render pass. + */ +export const resolveFieldCanvasStyle = ( + field: FieldToRender, + mode: FieldRenderMode, + cache?: FieldCanvasStyleCache, +): FieldCanvasStyle | undefined => { + if (mode !== 'sign') { + return undefined; + } + + const cacheKey = getFieldCanvasStyleCacheKey(field); + + if (cache?.has(cacheKey)) { + return cache.get(cacheKey); + } + + const style = computeFieldCanvasStyleFromProbe(field); + + cache?.set(cacheKey, style); + + return style; +}; diff --git a/packages/lib/universal/field-renderer/field-generic-items.ts b/packages/lib/universal/field-renderer/field-generic-items.ts index 8c5cce5d7..d37d0234b 100644 --- a/packages/lib/universal/field-renderer/field-generic-items.ts +++ b/packages/lib/universal/field-renderer/field-generic-items.ts @@ -29,6 +29,7 @@ export const upsertFieldGroup = (field: FieldToRender, options: RenderFieldEleme x: fieldX, y: fieldY, draggable: editable, + opacity: options.fieldCanvasStyle?.opacity ?? 1, dragBoundFunc: (pos) => { const newX = Math.max(0, Math.min(maxXPosition, pos.x)); const newY = Math.max(0, Math.min(maxYPosition, pos.y)); @@ -42,6 +43,7 @@ export const upsertFieldGroup = (field: FieldToRender, options: RenderFieldEleme export const upsertFieldRect = (field: FieldToRender, options: RenderFieldElementOptions): Konva.Rect => { const { pageWidth, pageHeight, mode, pageLayer, color } = options; + const { fieldCanvasStyle } = options; const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight); @@ -55,10 +57,10 @@ export const upsertFieldRect = (field: FieldToRender, options: RenderFieldElemen fieldRect.setAttrs({ width: fieldWidth, height: fieldHeight, - fill: DEFAULT_RECT_BACKGROUND, - stroke: color ? getRecipientColorStyles(color).baseRing : '#e5e7eb', - strokeWidth: 2, - cornerRadius: 2, + fill: fieldCanvasStyle?.backgroundColor ?? DEFAULT_RECT_BACKGROUND, + stroke: fieldCanvasStyle?.borderColor ?? (color ? getRecipientColorStyles(color).baseRing : '#e5e7eb'), + strokeWidth: fieldCanvasStyle?.borderWidth ?? 2, + cornerRadius: fieldCanvasStyle?.borderRadius ?? 2, strokeScaleEnabled: false, visible: mode !== 'export', } satisfies Partial); @@ -127,6 +129,10 @@ export const createFieldHoverInteraction = ({ options, fieldGroup, fieldRect }: return; } + if (options.fieldCanvasStyle?.backgroundColor) { + return; + } + const hoverColor = getRecipientColorStyles(options.color).baseRingHover; fieldGroup.on('mouseover', () => { diff --git a/packages/lib/universal/field-renderer/field-renderer.ts b/packages/lib/universal/field-renderer/field-renderer.ts index fbb8de848..5b8169e8c 100644 --- a/packages/lib/universal/field-renderer/field-renderer.ts +++ b/packages/lib/universal/field-renderer/field-renderer.ts @@ -16,21 +16,42 @@ export type FieldToRender = Pick< height: number; positionX: number; positionY: number; + isValidating?: boolean; fieldMeta?: TFieldMetaSchema | null; signature?: Pick | 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. + */ +export type FieldRenderMode = 'edit' | 'sign' | 'export'; + export type RenderFieldElementOptions = { pageLayer: Konva.Layer; pageWidth: number; pageHeight: number; - mode: 'edit' | 'sign' | 'export'; + mode: FieldRenderMode; editable?: boolean; scale: number; color?: TRecipientColor; + fieldCanvasStyle?: FieldCanvasStyle; translations: Record | null; }; +export type FieldCanvasStyle = { + backgroundColor?: string; + borderColor?: string; + borderRadius?: number; + borderWidth?: number; + opacity?: number; +}; + /** * Converts a fields percentage based values to pixel based values. */ diff --git a/packages/lib/universal/field-renderer/render-field.ts b/packages/lib/universal/field-renderer/render-field.ts index f4221c9d7..46b82bb97 100644 --- a/packages/lib/universal/field-renderer/render-field.ts +++ b/packages/lib/universal/field-renderer/render-field.ts @@ -1,10 +1,11 @@ import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors'; -import type { Signature } from '@prisma/client'; -import { type Field, FieldType } from '@prisma/client'; +import { FieldType } from '@prisma/client'; import type Konva from 'konva'; import { match } from 'ts-pattern'; -import type { TFieldMetaSchema } from '../../types/field-meta'; +import type { FieldCanvasStyleCache } from './field-canvas-style'; +import { resolveFieldCanvasStyle } from './field-canvas-style'; +import type { FieldRenderMode, FieldToRender } from './field-renderer'; import { renderCheckboxFieldElement } from './render-checkbox-field'; import { renderDropdownFieldElement } from './render-dropdown-field'; import { renderGenericTextFieldElement } from './render-generic-text-field'; @@ -14,30 +15,6 @@ 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' -> & { - renderId: string; // A unique ID for the field in the render. - width: number; - height: number; - positionX: number; - positionY: number; - fieldMeta?: TFieldMetaSchema | null; - signature?: Pick | null; -}; - type RenderFieldOptions = { field: FieldToRender; pageLayer: Konva.Layer; @@ -52,6 +29,7 @@ type RenderFieldOptions = { scale: number; editable?: boolean; + fieldCanvasStyleCache?: FieldCanvasStyleCache; }; export const renderField = ({ @@ -64,6 +42,7 @@ export const renderField = ({ scale, editable, color, + fieldCanvasStyleCache, }: RenderFieldOptions) => { const options = { pageLayer, @@ -74,6 +53,7 @@ export const renderField = ({ color, editable, scale, + fieldCanvasStyle: resolveFieldCanvasStyle(field, mode, fieldCanvasStyleCache), }; // If the generic text field element array changes, update the `GenericTextFieldTypeMetas` type diff --git a/packages/ui/components/field/field.tsx b/packages/ui/components/field/field.tsx index 59ff42f84..aa3337aa5 100644 --- a/packages/ui/components/field/field.tsx +++ b/packages/ui/components/field/field.tsx @@ -7,6 +7,7 @@ import { type Field, FieldType } from '@prisma/client'; import React, { useEffect, useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; +import { FIELD_ROOT_CONTAINER_CLASS_NAME } from '../../lib/field-root-container-classes'; import type { RecipientColorStyles } from '../../lib/recipient-colors'; import { cn } from '../../lib/utils'; @@ -117,7 +118,7 @@ export function FieldRootContainer({ field, children, color, className, readonly data-inserted={field.inserted ? 'true' : 'false'} data-readonly={readonly ? 'true' : 'false'} className={cn( - 'field--FieldRootContainer field-card-container dark-mode-disabled group relative z-20 flex h-full w-full items-center rounded-[2px] bg-white/90 ring-2 ring-gray-200 transition-all', + FIELD_ROOT_CONTAINER_CLASS_NAME, color?.base, { 'px-2': field.type !== FieldType.SIGNATURE && field.type !== FieldType.FREE_SIGNATURE, diff --git a/packages/ui/lib/field-root-container-classes.ts b/packages/ui/lib/field-root-container-classes.ts new file mode 100644 index 000000000..8eabc695f --- /dev/null +++ b/packages/ui/lib/field-root-container-classes.ts @@ -0,0 +1,14 @@ +const FIELD_ROOT_CONTAINER_SHARED_CLASS_NAME = + 'field--FieldRootContainer field-card-container dark-mode-disabled group rounded-[2px] bg-white/90 ring-2 ring-gray-200 transition-all'; + +export const FIELD_ROOT_CONTAINER_CLASS_NAME = `${FIELD_ROOT_CONTAINER_SHARED_CLASS_NAME} relative z-20 flex h-full w-full items-center`; + +export const FIELD_ROOT_CONTAINER_PROBE_CLASS_NAME = `field--FieldRootContainerProbe ${FIELD_ROOT_CONTAINER_SHARED_CLASS_NAME}`; + +/** + * Selector for the element the probe is appended into when reading computed + * field styles. It must be an ancestor of where real fields render so the probe + * inherits the same CSS cascade (custom embed CSS is commonly scoped under + * `.embed--Root` / `.embed--DocumentContainer`). + */ +export const FIELD_PROBE_ANCHOR_SELECTOR = '.embed--DocumentContainer'; diff --git a/packages/ui/package.json b/packages/ui/package.json index 58883b199..d39030cbc 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -61,6 +61,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^1.2.1", "cmdk": "^0.2.1", + "colord": "^2.9.3", "framer-motion": "^12.23.24", "lucide-react": "^0.554.0", "luxon": "^3.7.2",