mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +10:00
feat: enhance document signing page with field canvas style integration (#2876)
This commit is contained in:
+22
-9
@@ -9,6 +9,10 @@ import { isBase64Image } from '@documenso/lib/constants/signatures';
|
|||||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||||
import { ZFullFieldSchema } from '@documenso/lib/types/field';
|
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 { createSpinner } from '@documenso/lib/universal/field-renderer/field-generic-items';
|
||||||
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
|
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
|
||||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
@@ -135,7 +139,10 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
|||||||
});
|
});
|
||||||
}, [envelope.recipients, pageNumber, currentEnvelopeItem?.id]);
|
}, [envelope.recipients, pageNumber, currentEnvelopeItem?.id]);
|
||||||
|
|
||||||
const unsafeRenderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
|
const unsafeRenderFieldOnLayer = (
|
||||||
|
unparsedField: Field & { signature?: Signature | null },
|
||||||
|
fieldCanvasStyleCache: FieldCanvasStyleCache,
|
||||||
|
) => {
|
||||||
if (!pageLayer.current) {
|
if (!pageLayer.current) {
|
||||||
console.error('Layer not loaded yet');
|
console.error('Layer not loaded yet');
|
||||||
return;
|
return;
|
||||||
@@ -143,11 +150,9 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
|||||||
|
|
||||||
const fieldToRender = ZFullFieldSchema.parse(unparsedField);
|
const fieldToRender = ZFullFieldSchema.parse(unparsedField);
|
||||||
|
|
||||||
const color = fieldToRender.fieldMeta?.readOnly
|
const isValidating = showPendingFieldTooltip && isFieldUnsignedAndRequired(fieldToRender);
|
||||||
? 'readOnly'
|
|
||||||
: showPendingFieldTooltip && isFieldUnsignedAndRequired(fieldToRender)
|
const color = fieldToRender.fieldMeta?.readOnly ? 'readOnly' : isValidating ? 'orange' : 'green';
|
||||||
? 'orange'
|
|
||||||
: 'green';
|
|
||||||
|
|
||||||
const { fieldGroup } = renderField({
|
const { fieldGroup } = renderField({
|
||||||
scale,
|
scale,
|
||||||
@@ -159,6 +164,7 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
|||||||
height: Number(fieldToRender.height),
|
height: Number(fieldToRender.height),
|
||||||
positionX: Number(fieldToRender.positionX),
|
positionX: Number(fieldToRender.positionX),
|
||||||
positionY: Number(fieldToRender.positionY),
|
positionY: Number(fieldToRender.positionY),
|
||||||
|
isValidating,
|
||||||
signature: unparsedField.signature,
|
signature: unparsedField.signature,
|
||||||
},
|
},
|
||||||
translations: getClientSideFieldTranslations(i18n),
|
translations: getClientSideFieldTranslations(i18n),
|
||||||
@@ -166,6 +172,7 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
|||||||
pageHeight: unscaledViewport.height,
|
pageHeight: unscaledViewport.height,
|
||||||
color,
|
color,
|
||||||
mode: 'sign',
|
mode: 'sign',
|
||||||
|
fieldCanvasStyleCache,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleFieldGroupClick = (e: KonvaEventObject<Event>) => {
|
const handleFieldGroupClick = (e: KonvaEventObject<Event>) => {
|
||||||
@@ -411,9 +418,12 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
|||||||
fieldGroup.on('pointerdown', handleFieldGroupClick);
|
fieldGroup.on('pointerdown', handleFieldGroupClick);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
|
const renderFieldOnLayer = (
|
||||||
|
unparsedField: Field & { signature?: Signature | null },
|
||||||
|
fieldCanvasStyleCache: FieldCanvasStyleCache,
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
unsafeRenderFieldOnLayer(unparsedField);
|
unsafeRenderFieldOnLayer(unparsedField, fieldCanvasStyleCache);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
setRenderError(true);
|
setRenderError(true);
|
||||||
@@ -426,6 +436,8 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fieldCanvasStyleCache = createFieldCanvasStyleCache();
|
||||||
|
|
||||||
// Render current recipient fields which have changed or are not currently rendered.
|
// Render current recipient fields which have changed or are not currently rendered.
|
||||||
for (const field of localPageFields) {
|
for (const field of localPageFields) {
|
||||||
const existingCachedField = cachedRenderFields.current.get(field.id);
|
const existingCachedField = cachedRenderFields.current.get(field.id);
|
||||||
@@ -437,7 +449,7 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
|||||||
existingCachedField.inserted !== field.inserted ||
|
existingCachedField.inserted !== field.inserted ||
|
||||||
existingCachedField.customText !== field.customText
|
existingCachedField.customText !== field.customText
|
||||||
) {
|
) {
|
||||||
renderFieldOnLayer(field);
|
renderFieldOnLayer(field, fieldCanvasStyleCache);
|
||||||
cachedRenderFields.current.set(field.id, field);
|
cachedRenderFields.current.set(field.id, field);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -463,6 +475,7 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
|||||||
color: 'readOnly',
|
color: 'readOnly',
|
||||||
editable: false,
|
editable: false,
|
||||||
mode: 'sign',
|
mode: 'sign',
|
||||||
|
fieldCanvasStyleCache,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Other-recipient fields are display-only — they have no click handlers
|
// Other-recipient fields are display-only — they have no click handlers
|
||||||
|
|||||||
Generated
+2
@@ -30941,6 +30941,7 @@
|
|||||||
"@vvo/tzdb": "^6.196.0",
|
"@vvo/tzdb": "^6.196.0",
|
||||||
"ai": "^5.0.104",
|
"ai": "^5.0.104",
|
||||||
"bullmq": "^5.71.1",
|
"bullmq": "^5.71.1",
|
||||||
|
"colord": "^2.9.3",
|
||||||
"csv-parse": "^6.1.0",
|
"csv-parse": "^6.1.0",
|
||||||
"inngest": "^3.54.0",
|
"inngest": "^3.54.0",
|
||||||
"ioredis": "^5.10.1",
|
"ioredis": "^5.10.1",
|
||||||
@@ -31123,6 +31124,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^1.2.1",
|
"clsx": "^1.2.1",
|
||||||
"cmdk": "^0.2.1",
|
"cmdk": "^0.2.1",
|
||||||
|
"colord": "^2.9.3",
|
||||||
"framer-motion": "^12.23.24",
|
"framer-motion": "^12.23.24",
|
||||||
"lucide-react": "^0.554.0",
|
"lucide-react": "^0.554.0",
|
||||||
"luxon": "^3.7.2",
|
"luxon": "^3.7.2",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type React from 'react';
|
|||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import type { TEnvelope } from '../../types/envelope';
|
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.
|
* The signature data for an inserted signature field.
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
"@vvo/tzdb": "^6.196.0",
|
"@vvo/tzdb": "^6.196.0",
|
||||||
"ai": "^5.0.104",
|
"ai": "^5.0.104",
|
||||||
"bullmq": "^5.71.1",
|
"bullmq": "^5.71.1",
|
||||||
|
"colord": "^2.9.3",
|
||||||
"csv-parse": "^6.1.0",
|
"csv-parse": "^6.1.0",
|
||||||
"inngest": "^3.54.0",
|
"inngest": "^3.54.0",
|
||||||
"ioredis": "^5.10.1",
|
"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,
|
x: fieldX,
|
||||||
y: fieldY,
|
y: fieldY,
|
||||||
draggable: editable,
|
draggable: editable,
|
||||||
|
opacity: options.fieldCanvasStyle?.opacity ?? 1,
|
||||||
dragBoundFunc: (pos) => {
|
dragBoundFunc: (pos) => {
|
||||||
const newX = Math.max(0, Math.min(maxXPosition, pos.x));
|
const newX = Math.max(0, Math.min(maxXPosition, pos.x));
|
||||||
const newY = Math.max(0, Math.min(maxYPosition, pos.y));
|
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 => {
|
export const upsertFieldRect = (field: FieldToRender, options: RenderFieldElementOptions): Konva.Rect => {
|
||||||
const { pageWidth, pageHeight, mode, pageLayer, color } = options;
|
const { pageWidth, pageHeight, mode, pageLayer, color } = options;
|
||||||
|
const { fieldCanvasStyle } = options;
|
||||||
|
|
||||||
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
|
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
|
||||||
|
|
||||||
@@ -55,10 +57,10 @@ export const upsertFieldRect = (field: FieldToRender, options: RenderFieldElemen
|
|||||||
fieldRect.setAttrs({
|
fieldRect.setAttrs({
|
||||||
width: fieldWidth,
|
width: fieldWidth,
|
||||||
height: fieldHeight,
|
height: fieldHeight,
|
||||||
fill: DEFAULT_RECT_BACKGROUND,
|
fill: fieldCanvasStyle?.backgroundColor ?? DEFAULT_RECT_BACKGROUND,
|
||||||
stroke: color ? getRecipientColorStyles(color).baseRing : '#e5e7eb',
|
stroke: fieldCanvasStyle?.borderColor ?? (color ? getRecipientColorStyles(color).baseRing : '#e5e7eb'),
|
||||||
strokeWidth: 2,
|
strokeWidth: fieldCanvasStyle?.borderWidth ?? 2,
|
||||||
cornerRadius: 2,
|
cornerRadius: fieldCanvasStyle?.borderRadius ?? 2,
|
||||||
strokeScaleEnabled: false,
|
strokeScaleEnabled: false,
|
||||||
visible: mode !== 'export',
|
visible: mode !== 'export',
|
||||||
} satisfies Partial<Konva.RectConfig>);
|
} satisfies Partial<Konva.RectConfig>);
|
||||||
@@ -127,6 +129,10 @@ export const createFieldHoverInteraction = ({ options, fieldGroup, fieldRect }:
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.fieldCanvasStyle?.backgroundColor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const hoverColor = getRecipientColorStyles(options.color).baseRingHover;
|
const hoverColor = getRecipientColorStyles(options.color).baseRingHover;
|
||||||
|
|
||||||
fieldGroup.on('mouseover', () => {
|
fieldGroup.on('mouseover', () => {
|
||||||
|
|||||||
@@ -16,21 +16,42 @@ export type FieldToRender = Pick<
|
|||||||
height: number;
|
height: number;
|
||||||
positionX: number;
|
positionX: number;
|
||||||
positionY: number;
|
positionY: number;
|
||||||
|
isValidating?: boolean;
|
||||||
fieldMeta?: TFieldMetaSchema | null;
|
fieldMeta?: TFieldMetaSchema | null;
|
||||||
signature?: Pick<Signature, 'signatureImageAsBase64' | 'typedSignature'> | 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 = {
|
export type RenderFieldElementOptions = {
|
||||||
pageLayer: Konva.Layer;
|
pageLayer: Konva.Layer;
|
||||||
pageWidth: number;
|
pageWidth: number;
|
||||||
pageHeight: number;
|
pageHeight: number;
|
||||||
mode: 'edit' | 'sign' | 'export';
|
mode: FieldRenderMode;
|
||||||
editable?: boolean;
|
editable?: boolean;
|
||||||
scale: number;
|
scale: number;
|
||||||
color?: TRecipientColor;
|
color?: TRecipientColor;
|
||||||
|
fieldCanvasStyle?: FieldCanvasStyle;
|
||||||
translations: Record<FieldType, string> | null;
|
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.
|
* 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 { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
|
||||||
import type { Signature } from '@prisma/client';
|
import { FieldType } from '@prisma/client';
|
||||||
import { type Field, FieldType } from '@prisma/client';
|
|
||||||
import type Konva from 'konva';
|
import type Konva from 'konva';
|
||||||
import { match } from 'ts-pattern';
|
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 { renderCheckboxFieldElement } from './render-checkbox-field';
|
||||||
import { renderDropdownFieldElement } from './render-dropdown-field';
|
import { renderDropdownFieldElement } from './render-dropdown-field';
|
||||||
import { renderGenericTextFieldElement } from './render-generic-text-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_HEIGHT_PX = 12;
|
||||||
export const MIN_FIELD_WIDTH_PX = 36;
|
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 = {
|
type RenderFieldOptions = {
|
||||||
field: FieldToRender;
|
field: FieldToRender;
|
||||||
pageLayer: Konva.Layer;
|
pageLayer: Konva.Layer;
|
||||||
@@ -52,6 +29,7 @@ type RenderFieldOptions = {
|
|||||||
|
|
||||||
scale: number;
|
scale: number;
|
||||||
editable?: boolean;
|
editable?: boolean;
|
||||||
|
fieldCanvasStyleCache?: FieldCanvasStyleCache;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const renderField = ({
|
export const renderField = ({
|
||||||
@@ -64,6 +42,7 @@ export const renderField = ({
|
|||||||
scale,
|
scale,
|
||||||
editable,
|
editable,
|
||||||
color,
|
color,
|
||||||
|
fieldCanvasStyleCache,
|
||||||
}: RenderFieldOptions) => {
|
}: RenderFieldOptions) => {
|
||||||
const options = {
|
const options = {
|
||||||
pageLayer,
|
pageLayer,
|
||||||
@@ -74,6 +53,7 @@ export const renderField = ({
|
|||||||
color,
|
color,
|
||||||
editable,
|
editable,
|
||||||
scale,
|
scale,
|
||||||
|
fieldCanvasStyle: resolveFieldCanvasStyle(field, mode, fieldCanvasStyleCache),
|
||||||
};
|
};
|
||||||
|
|
||||||
// If the generic text field element array changes, update the `GenericTextFieldTypeMetas` type
|
// If the generic text field element array changes, update the `GenericTextFieldTypeMetas` type
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { type Field, FieldType } from '@prisma/client';
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
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 type { RecipientColorStyles } from '../../lib/recipient-colors';
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
@@ -117,7 +118,7 @@ export function FieldRootContainer({ field, children, color, className, readonly
|
|||||||
data-inserted={field.inserted ? 'true' : 'false'}
|
data-inserted={field.inserted ? 'true' : 'false'}
|
||||||
data-readonly={readonly ? 'true' : 'false'}
|
data-readonly={readonly ? 'true' : 'false'}
|
||||||
className={cn(
|
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,
|
color?.base,
|
||||||
{
|
{
|
||||||
'px-2': field.type !== FieldType.SIGNATURE && field.type !== FieldType.FREE_SIGNATURE,
|
'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';
|
||||||
@@ -61,6 +61,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^1.2.1",
|
"clsx": "^1.2.1",
|
||||||
"cmdk": "^0.2.1",
|
"cmdk": "^0.2.1",
|
||||||
|
"colord": "^2.9.3",
|
||||||
"framer-motion": "^12.23.24",
|
"framer-motion": "^12.23.24",
|
||||||
"lucide-react": "^0.554.0",
|
"lucide-react": "^0.554.0",
|
||||||
"luxon": "^3.7.2",
|
"luxon": "^3.7.2",
|
||||||
|
|||||||
Reference in New Issue
Block a user