feat: enhance document signing page with field canvas style integration (#2876)

This commit is contained in:
Catalin Pit
2026-06-09 08:05:22 +03:00
committed by GitHub
parent 58f0f5da43
commit ecc98fbd41
12 changed files with 364 additions and 43 deletions
@@ -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<Event>) => {
@@ -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
+2
View File
@@ -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",
@@ -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.
+1
View File
@@ -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",
@@ -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<FieldToRender> = {}) =>
({
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');
});
});
@@ -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<string, FieldCanvasStyle | undefined>;
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<CSSStyleDeclaration>);
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;
};
@@ -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<Konva.RectConfig>);
@@ -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', () => {
@@ -16,21 +16,42 @@ export type FieldToRender = Pick<
height: number;
positionX: number;
positionY: number;
isValidating?: boolean;
fieldMeta?: TFieldMetaSchema | null;
signature?: Pick<Signature, 'signatureImageAsBase64' | 'typedSignature'> | 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<FieldType, string> | null;
};
export type FieldCanvasStyle = {
backgroundColor?: string;
borderColor?: string;
borderRadius?: number;
borderWidth?: number;
opacity?: number;
};
/**
* Converts a fields percentage based values to pixel based values.
*/
@@ -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<Signature, 'signatureImageAsBase64' | 'typedSignature'> | 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
+2 -1
View File
@@ -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,
@@ -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';
+1
View File
@@ -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",