feat: add envelopes (#2025)

This PR is handles the changes required to support envelopes. The new
envelope editor/signing page will be hidden during release.

The core changes here is to migrate the documents and templates model to
a centralized envelopes model.

Even though Documents and Templates are removed, from the user
perspective they will still exist as we remap envelopes to documents and
templates.
This commit is contained in:
David Nguyen
2025-10-14 21:56:36 +11:00
committed by GitHub
parent 7b17156e56
commit 7f09ba72f4
447 changed files with 33467 additions and 9622 deletions

View File

@ -0,0 +1,119 @@
import Konva from 'konva';
import {
DEFAULT_RECT_BACKGROUND,
RECIPIENT_COLOR_STYLES,
} from '@documenso/ui/lib/recipient-colors';
import type { FieldToRender, RenderFieldElementOptions } from './field-renderer';
import { calculateFieldPosition } from './field-renderer';
export const upsertFieldGroup = (
field: FieldToRender,
options: RenderFieldElementOptions,
): Konva.Group => {
const { pageWidth, pageHeight, pageLayer, editable } = options;
const { fieldX, fieldY, fieldWidth, fieldHeight } = calculateFieldPosition(
field,
pageWidth,
pageHeight,
);
const fieldGroup: Konva.Group =
pageLayer.findOne(`#${field.renderId}`) ||
new Konva.Group({
id: field.renderId,
name: 'field-group',
});
fieldGroup.setAttrs({
scaleX: 1,
scaleY: 1,
x: fieldX,
y: fieldY,
draggable: editable,
dragBoundFunc: (pos) => {
const newX = Math.max(0, Math.min(pageWidth - fieldWidth, pos.x));
const newY = Math.max(0, Math.min(pageHeight - fieldHeight, pos.y));
return { x: newX, y: newY };
},
} satisfies Partial<Konva.GroupConfig>);
return fieldGroup;
};
export const upsertFieldRect = (
field: FieldToRender,
options: RenderFieldElementOptions,
): Konva.Rect => {
const { pageWidth, pageHeight, mode, pageLayer, color } = options;
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
const fieldRect: Konva.Rect =
pageLayer.findOne(`#${field.renderId}-rect`) ||
new Konva.Rect({
id: `${field.renderId}-rect`,
name: 'field-rect',
});
fieldRect.setAttrs({
width: fieldWidth,
height: fieldHeight,
fill: DEFAULT_RECT_BACKGROUND,
stroke: color ? RECIPIENT_COLOR_STYLES[color].baseRing : '#e5e7eb',
strokeWidth: 2,
cornerRadius: 2,
strokeScaleEnabled: false,
visible: mode !== 'export',
} satisfies Partial<Konva.RectConfig>);
return fieldRect;
};
export const createSpinner = ({
fieldWidth,
fieldHeight,
}: {
fieldWidth: number;
fieldHeight: number;
}) => {
const loadingGroup = new Konva.Group({
name: 'loading-spinner-group',
});
const rect = new Konva.Rect({
x: 4,
y: 4,
width: fieldWidth - 8,
height: fieldHeight - 8,
fill: 'white',
opacity: 1,
});
const spinner = new Konva.Arc({
x: fieldWidth / 2,
y: fieldHeight / 2,
innerRadius: fieldWidth / 10,
outerRadius: fieldHeight / 10,
angle: 270,
rotation: 0,
fill: 'rgba(122, 195, 85, 1)',
lineCap: 'round',
});
rect.moveToTop();
spinner.moveToTop();
loadingGroup.add(rect);
loadingGroup.add(spinner);
const anim = new Konva.Animation((frame) => {
spinner.rotate(180 * (frame.timeDiff / 500));
});
anim.start();
return loadingGroup;
};

View File

@ -0,0 +1,158 @@
import type { Signature } from '@prisma/client';
import { type Field } from '@prisma/client';
import type Konva from 'konva';
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
import type { TFieldMetaSchema } from '../../types/field-meta';
export const MIN_FIELD_HEIGHT_PX = 12;
export const MIN_FIELD_WIDTH_PX = 36;
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?: Signature | null;
};
export type RenderFieldElementOptions = {
pageLayer: Konva.Layer;
pageWidth: number;
pageHeight: number;
mode?: 'edit' | 'sign' | 'export';
editable?: boolean;
color?: TRecipientColor;
};
/**
* Converts a fields percentage based values to pixel based values.
*/
export const calculateFieldPosition = (
field: Pick<FieldToRender, 'width' | 'height' | 'positionX' | 'positionY'>,
pageWidth: number,
pageHeight: number,
) => {
const fieldWidth = pageWidth * (Number(field.width) / 100);
const fieldHeight = pageHeight * (Number(field.height) / 100);
const fieldX = pageWidth * (Number(field.positionX) / 100);
const fieldY = pageHeight * (Number(field.positionY) / 100);
return { fieldX, fieldY, fieldWidth, fieldHeight };
};
type ConvertPixelToPercentageOptions = {
positionX: number;
positionY: number;
width: number;
height: number;
pageWidth: number;
pageHeight: number;
};
export const convertPixelToPercentage = (options: ConvertPixelToPercentageOptions) => {
const { positionX, positionY, width, height, pageWidth, pageHeight } = options;
const fieldX = (positionX / pageWidth) * 100;
const fieldY = (positionY / pageHeight) * 100;
const fieldWidth = (width / pageWidth) * 100;
const fieldHeight = (height / pageHeight) * 100;
return { fieldX, fieldY, fieldWidth, fieldHeight };
};
type CalculateMultiItemPositionOptions = {
/**
* The field width in pixels.
*/
fieldWidth: number;
/**
* The field height in pixels.
*/
fieldHeight: number;
/**
* Total amount of items that will be rendered.
*/
itemCount: number;
/**
* The position of the item in the list.
*
* Starts from 0
*/
itemIndex: number;
/**
* The size of the item input, example checkbox box, radio button, etc.
*/
itemSize: number;
/**
* The spacing between the item and text.
*/
spacingBetweenItemAndText: number;
/**
* The inner padding of the field.
*/
fieldPadding: number;
type: 'checkbox' | 'radio';
};
/**
* Calculate the position of a field item such as Checkbox, Radio.
*/
export const calculateMultiItemPosition = (options: CalculateMultiItemPositionOptions) => {
const {
fieldWidth,
fieldHeight,
itemCount,
itemIndex,
itemSize,
spacingBetweenItemAndText,
fieldPadding,
type,
} = options;
const innerFieldHeight = fieldHeight - fieldPadding * 2;
const innerFieldWidth = fieldWidth - fieldPadding; // This is purposefully not using fullPadding to allow flush text.
const innerFieldX = fieldPadding;
const innerFieldY = fieldPadding;
const itemHeight = innerFieldHeight / itemCount;
const y = itemIndex * itemHeight + innerFieldY;
let itemInputY = y + itemHeight / 2 - itemSize / 2;
let itemInputX = innerFieldX;
if (type === 'radio') {
itemInputX = innerFieldX + itemSize / 2;
itemInputY = y + itemHeight / 2;
}
const textX = innerFieldX + itemSize + spacingBetweenItemAndText;
const textY = y;
const textWidth = innerFieldWidth - itemSize - spacingBetweenItemAndText;
const textHeight = itemHeight;
return {
itemInputX,
itemInputY,
textX,
textY,
textWidth,
textHeight,
};
};

View File

@ -0,0 +1,187 @@
import Konva from 'konva';
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
import type { TCheckboxFieldMeta } from '../../types/field-meta';
import { upsertFieldGroup, upsertFieldRect } from './field-generic-items';
import { calculateFieldPosition, calculateMultiItemPosition } from './field-renderer';
import type { FieldToRender, RenderFieldElementOptions } from './field-renderer';
// Do not change any of these values without consulting with the team.
const checkboxFieldPadding = 8;
const checkboxSize = 16;
const spacingBetweenCheckboxAndText = 8;
export const renderCheckboxFieldElement = (
field: FieldToRender,
options: RenderFieldElementOptions,
) => {
const { pageWidth, pageHeight, pageLayer, mode } = options;
const isFirstRender = !pageLayer.findOne(`#${field.renderId}`);
const fieldGroup = upsertFieldGroup(field, options);
// Clear previous children to re-render fresh
fieldGroup.removeChildren();
fieldGroup.add(upsertFieldRect(field, options));
if (isFirstRender) {
pageLayer.add(fieldGroup);
// Handle rescaling items during transforms.
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
const fieldRect = fieldGroup.findOne('.field-rect');
if (!fieldRect) {
return;
}
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
// Todo: Envelopes - check sorting more than 10
// arr.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
const squares = fieldGroup
.find('.checkbox-square')
.sort((a, b) => a.id().localeCompare(b.id()));
const checkmarks = fieldGroup
.find('.checkbox-checkmark')
.sort((a, b) => a.id().localeCompare(b.id()));
const text = fieldGroup.find('.checkbox-text').sort((a, b) => a.id().localeCompare(b.id()));
const groupedItems = squares.map((square, i) => ({
squareElement: square,
checkmarkElement: checkmarks[i],
textElement: text[i],
}));
groupedItems.forEach((item, i) => {
const { squareElement, checkmarkElement, textElement } = item;
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
calculateMultiItemPosition({
fieldWidth: rectWidth,
fieldHeight: rectHeight,
itemCount: checkboxValues.length,
itemIndex: i,
itemSize: checkboxSize,
spacingBetweenItemAndText: spacingBetweenCheckboxAndText,
fieldPadding: checkboxFieldPadding,
type: 'checkbox',
});
squareElement.setAttrs({
x: itemInputX,
y: itemInputY,
scaleX: 1,
scaleY: 1,
});
checkmarkElement.setAttrs({
x: itemInputX,
y: itemInputY,
scaleX: 1,
scaleY: 1,
});
textElement.setAttrs({
x: textX,
y: textY,
scaleX: 1,
scaleY: 1,
width: textWidth,
height: textHeight,
});
});
fieldRect.setAttrs({
width: rectWidth,
height: rectHeight,
});
fieldGroup.scale({
x: 1,
y: 1,
});
pageLayer.batchDraw();
});
}
const checkboxMeta: TCheckboxFieldMeta | null = (field.fieldMeta as TCheckboxFieldMeta) || null;
const checkboxValues = checkboxMeta?.values || [];
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
checkboxValues.forEach(({ id, value, checked }, index) => {
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
calculateMultiItemPosition({
fieldWidth,
fieldHeight,
itemCount: checkboxValues.length,
itemIndex: index,
itemSize: checkboxSize,
spacingBetweenItemAndText: spacingBetweenCheckboxAndText,
fieldPadding: checkboxFieldPadding,
type: 'checkbox',
});
const square = new Konva.Rect({
internalCheckboxId: id,
internalCheckboxValue: value,
id: `checkbox-square-${index}`,
name: 'checkbox-square',
x: itemInputX,
y: itemInputY,
width: checkboxSize,
height: checkboxSize,
stroke: '#374151',
strokeWidth: 2,
cornerRadius: 2,
fill: 'white',
});
const checkmark = new Konva.Line({
internalCheckboxId: id,
internalCheckboxValue: value,
id: `checkbox-checkmark-${index}`,
name: 'checkbox-checkmark',
x: itemInputX,
y: itemInputY,
strokeWidth: 2,
stroke: '#111827',
points: [3, 8, 7, 12, 13, 4],
visible: checked,
});
const text = new Konva.Text({
internalCheckboxId: id,
internalCheckboxValue: value,
id: `checkbox-text-${index}`,
name: 'checkbox-text',
x: textX,
y: textY,
text: value,
width: textWidth,
height: textHeight,
fontSize: DEFAULT_STANDARD_FONT_SIZE,
fontFamily: 'Inter, system-ui, sans-serif',
verticalAlign: 'middle',
fill: '#111827', // Todo: Envelopes - Sort colours
});
fieldGroup.add(square);
fieldGroup.add(checkmark);
fieldGroup.add(text);
});
return {
fieldGroup,
isFirstRender,
};
};

View File

@ -0,0 +1,179 @@
import Konva from 'konva';
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
import type { TDropdownFieldMeta } from '../../types/field-meta';
import { upsertFieldGroup, upsertFieldRect } from './field-generic-items';
import { calculateFieldPosition } from './field-renderer';
import type { FieldToRender, RenderFieldElementOptions } from './field-renderer';
type CalculateDropdownPositionOptions = {
fieldWidth: number;
fieldHeight: number;
};
/**
* Calculate the position of a field item such as Checkbox, Radio.
*/
const calculateDropdownPosition = (options: CalculateDropdownPositionOptions) => {
const { fieldWidth, fieldHeight } = options;
const fieldPadding = 8;
const arrowSize = 12;
const textHeight = fieldHeight - fieldPadding * 2;
const textWidth = fieldWidth - fieldPadding * 2;
const textX = fieldPadding;
const textY = fieldPadding;
const arrowX = fieldWidth - arrowSize - fieldPadding;
const arrowY = fieldHeight / 2;
return {
arrowX,
arrowY,
arrowSize,
textX,
textY,
textWidth,
textHeight,
};
};
export const renderDropdownFieldElement = (
field: FieldToRender,
options: RenderFieldElementOptions,
) => {
const { pageWidth, pageHeight, pageLayer, mode } = options;
const isFirstRender = !pageLayer.findOne(`#${field.renderId}`);
const fieldGroup = upsertFieldGroup(field, options);
// Clear previous children to re-render fresh.
fieldGroup.removeChildren();
fieldGroup.add(upsertFieldRect(field, options));
if (isFirstRender) {
pageLayer.add(fieldGroup);
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
const fieldRect = fieldGroup.findOne('.field-rect');
const text = fieldGroup.findOne('.dropdown-selected-text');
const arrow = fieldGroup.findOne('.dropdown-arrow');
if (!fieldRect || !text || !arrow) {
console.log('fieldRect or text or arrow not found');
return;
}
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
const { arrowX, arrowY, textX, textY, textWidth, textHeight } = calculateDropdownPosition({
fieldWidth: rectWidth,
fieldHeight: rectHeight,
});
arrow.setAttrs({
x: arrowX,
y: arrowY,
scaleX: 1,
scaleY: 1,
});
text.setAttrs({
scaleX: 1,
scaleY: 1,
x: textX,
y: textY,
width: textWidth,
height: textHeight,
});
fieldRect.setAttrs({
width: rectWidth,
height: rectHeight,
});
fieldGroup.scale({
x: 1,
y: 1,
});
pageLayer.batchDraw();
});
}
const dropdownMeta: TDropdownFieldMeta | null = (field.fieldMeta as TDropdownFieldMeta) || null;
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
// Todo: Envelopes - Translations
let selectedValue = 'Select Option';
if (field.inserted) {
selectedValue = field.customText;
}
const { arrowX, arrowY, arrowSize, textX, textY, textWidth, textHeight } =
calculateDropdownPosition({
fieldWidth,
fieldHeight,
});
// Selected value text
const selectedText = new Konva.Text({
name: 'dropdown-selected-text',
x: textX,
y: textY,
width: textWidth,
height: textHeight,
text: selectedValue,
fontSize: DEFAULT_STANDARD_FONT_SIZE,
fontFamily: 'Inter, system-ui, sans-serif',
fill: '#111827',
verticalAlign: 'middle',
});
const arrow = new Konva.Line({
name: 'dropdown-arrow',
x: arrowX,
y: arrowY,
points: [0, 0, arrowSize / 2, arrowSize / 2, arrowSize, 0],
stroke: '#6B7280',
strokeWidth: 2,
lineCap: 'round',
lineJoin: 'round',
closed: false,
visible: mode !== 'export',
});
// Add hover state for dropdown
fieldGroup.on('mouseenter', () => {
// dropdownContainer.stroke('#2563EB');
// dropdownContainer.strokeWidth(2);
document.body.style.cursor = 'pointer';
pageLayer.batchDraw();
});
fieldGroup.on('mouseleave', () => {
// dropdownContainer.stroke('#374151');
// dropdownContainer.strokeWidth(2);
document.body.style.cursor = 'default';
pageLayer.batchDraw();
});
fieldGroup.add(selectedText);
if (!field.inserted || mode === 'export') {
fieldGroup.add(arrow);
}
return {
fieldGroup,
isFirstRender,
};
};

View File

@ -0,0 +1,78 @@
import type { Signature } from '@prisma/client';
import { type Field, FieldType } from '@prisma/client';
import type Konva from 'konva';
import { match } from 'ts-pattern';
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
import type { TFieldMetaSchema } from '../../types/field-meta';
import { renderCheckboxFieldElement } from './render-checkbox-field';
import { renderDropdownFieldElement } from './render-dropdown-field';
import { renderRadioFieldElement } from './render-radio-field';
import { renderSignatureFieldElement } from './render-signature-field';
import { renderTextFieldElement } from './render-text-field';
export const MIN_FIELD_HEIGHT_PX = 12;
export const MIN_FIELD_WIDTH_PX = 36;
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?: Signature | null;
};
type RenderFieldOptions = {
field: FieldToRender;
pageLayer: Konva.Layer;
pageWidth: number;
pageHeight: number;
color?: TRecipientColor;
/**
* The render type.
*
* @default 'edit'
*
* - `edit` - The field is rendered in edit mode.
* - `sign` - The field is rendered in sign mode. No interactive elements.
* - `export` - The field is rendered in export mode. No backgrounds, interactive elements, etc.
*/
mode: 'edit' | 'sign' | 'export';
editable?: boolean;
};
export const renderField = ({
field,
pageLayer,
pageWidth,
pageHeight,
mode,
editable,
color,
}: RenderFieldOptions) => {
const options = {
pageLayer,
pageWidth,
pageHeight,
mode,
color,
editable,
};
return match(field.type)
.with(FieldType.TEXT, () => renderTextFieldElement(field, options))
.with(FieldType.CHECKBOX, () => renderCheckboxFieldElement(field, options))
.with(FieldType.RADIO, () => renderRadioFieldElement(field, options))
.with(FieldType.DROPDOWN, () => renderDropdownFieldElement(field, options))
.with(FieldType.SIGNATURE, () => renderSignatureFieldElement(field, options))
.otherwise(() => renderTextFieldElement(field, options)); // Todo
};

View File

@ -0,0 +1,326 @@
import Konva from 'konva';
const SNAP_THRESHOLD = 10;
type SnapPoint = {
position: number;
type: 'edge' | 'center';
direction: 'horizontal' | 'vertical';
};
type SnapResult = {
x: number;
y: number;
horizontalGuide?: number;
verticalGuide?: number;
};
type ResizeSnapResult = {
x: number;
y: number;
width: number;
height: number;
horizontalGuides: number[];
verticalGuides: number[];
};
export function initializeSnapGuides(stage: Konva.Stage): Konva.Layer {
// Remove any existing snap guide layers from this stage
const existingSnapLayers = stage.find('.snap-guide-layer');
existingSnapLayers.forEach((layer) => layer.destroy());
const snapGuideLayer = new Konva.Layer({
name: 'snap-guide-layer',
});
stage.add(snapGuideLayer);
return snapGuideLayer;
}
export function calculateSnapPositions(
stage: Konva.Stage,
excludeId?: string,
): { horizontal: SnapPoint[]; vertical: SnapPoint[] } {
const fieldGroups = stage
.find('.field-group')
.filter((node): node is Konva.Group => node instanceof Konva.Group);
const horizontal: SnapPoint[] = [];
const vertical: SnapPoint[] = [];
fieldGroups.forEach((group) => {
if (excludeId && group.id() === excludeId) {
return;
}
const rect = group.getClientRect();
// Vertical snap points (for horizontal alignment)
horizontal.push(
{ position: rect.y, type: 'edge', direction: 'horizontal' },
{ position: rect.y + rect.height / 2, type: 'center', direction: 'horizontal' },
{ position: rect.y + rect.height, type: 'edge', direction: 'horizontal' },
);
// Horizontal snap points (for vertical alignment)
vertical.push(
{ position: rect.x, type: 'edge', direction: 'vertical' },
{ position: rect.x + rect.width / 2, type: 'center', direction: 'vertical' },
{ position: rect.x + rect.width, type: 'edge', direction: 'vertical' },
);
});
return { horizontal, vertical };
}
export function calculateSnapSizes(
stage: Konva.Stage,
excludeId?: string,
): { widths: number[]; heights: number[] } {
const fieldGroups = stage
.find('.field-group')
.filter((node): node is Konva.Group => node instanceof Konva.Group);
const widths: number[] = [];
const heights: number[] = [];
fieldGroups.forEach((group) => {
if (excludeId && group.id() === excludeId) {
return;
}
const rect = group.getClientRect();
widths.push(rect.width);
heights.push(rect.height);
});
return { widths, heights };
}
export function getSnappedPosition(
stage: Konva.Stage,
movingGroup: Konva.Group,
newX: number,
newY: number,
): SnapResult {
const { horizontal, vertical } = calculateSnapPositions(stage, movingGroup.id());
const rect = movingGroup.getClientRect();
let snappedX = newX;
let snappedY = newY;
let horizontalGuide: number | undefined;
let verticalGuide: number | undefined;
// Calculate the moving field's snap points
const movingTop = newY;
const movingBottom = newY + rect.height;
const movingCenterY = newY + rect.height / 2;
const movingLeft = newX;
const movingRight = newX + rect.width;
const movingCenterX = newX + rect.width / 2;
// Check horizontal snapping (Y position)
for (const snapPoint of horizontal) {
const distanceTop = Math.abs(movingTop - snapPoint.position);
const distanceBottom = Math.abs(movingBottom - snapPoint.position);
const distanceCenter = Math.abs(movingCenterY - snapPoint.position);
if (distanceTop <= SNAP_THRESHOLD) {
snappedY = snapPoint.position;
horizontalGuide = snapPoint.position;
break;
} else if (distanceBottom <= SNAP_THRESHOLD) {
snappedY = snapPoint.position - rect.height;
horizontalGuide = snapPoint.position;
break;
} else if (distanceCenter <= SNAP_THRESHOLD) {
snappedY = snapPoint.position - rect.height / 2;
horizontalGuide = snapPoint.position;
break;
}
}
// Check vertical snapping (X position)
for (const snapPoint of vertical) {
const distanceLeft = Math.abs(movingLeft - snapPoint.position);
const distanceRight = Math.abs(movingRight - snapPoint.position);
const distanceCenter = Math.abs(movingCenterX - snapPoint.position);
if (distanceLeft <= SNAP_THRESHOLD) {
snappedX = snapPoint.position;
verticalGuide = snapPoint.position;
break;
} else if (distanceRight <= SNAP_THRESHOLD) {
snappedX = snapPoint.position - rect.width;
verticalGuide = snapPoint.position;
break;
} else if (distanceCenter <= SNAP_THRESHOLD) {
snappedX = snapPoint.position - rect.width / 2;
verticalGuide = snapPoint.position;
break;
}
}
return {
x: snappedX,
y: snappedY,
horizontalGuide,
verticalGuide,
};
}
export function getSnappedResize(
stage: Konva.Stage,
resizingGroup: Konva.Group,
newX: number,
newY: number,
newWidth: number,
newHeight: number,
): ResizeSnapResult {
const { horizontal, vertical } = calculateSnapPositions(stage, resizingGroup.id());
const { widths, heights } = calculateSnapSizes(stage, resizingGroup.id());
const snappedX = newX;
const snappedY = newY;
let snappedWidth = newWidth;
let snappedHeight = newHeight;
const horizontalGuides: number[] = [];
const verticalGuides: number[] = [];
// Snap width to other field widths
for (const width of widths) {
if (Math.abs(newWidth - width) <= SNAP_THRESHOLD) {
snappedWidth = width;
break;
}
}
// Snap height to other field heights
for (const height of heights) {
if (Math.abs(newHeight - height) <= SNAP_THRESHOLD) {
snappedHeight = height;
break;
}
}
// Calculate field edges with new snapped dimensions
const movingTop = snappedY;
const movingBottom = snappedY + snappedHeight;
const movingLeft = snappedX;
const movingRight = snappedX + snappedWidth;
// Snap edges to alignment guides
for (const snapPoint of horizontal) {
if (Math.abs(movingTop - snapPoint.position) <= SNAP_THRESHOLD) {
horizontalGuides.push(snapPoint.position);
} else if (Math.abs(movingBottom - snapPoint.position) <= SNAP_THRESHOLD) {
horizontalGuides.push(snapPoint.position);
}
}
for (const snapPoint of vertical) {
if (Math.abs(movingLeft - snapPoint.position) <= SNAP_THRESHOLD) {
verticalGuides.push(snapPoint.position);
} else if (Math.abs(movingRight - snapPoint.position) <= SNAP_THRESHOLD) {
verticalGuides.push(snapPoint.position);
}
}
return {
x: snappedX,
y: snappedY,
width: snappedWidth,
height: snappedHeight,
horizontalGuides,
verticalGuides,
};
}
export function showSnapGuides(
snapGuideLayer: Konva.Layer,
horizontalGuide?: number,
verticalGuide?: number,
stageWidth?: number,
stageHeight?: number,
): void {
if (!snapGuideLayer) {
return;
}
hideSnapGuides(snapGuideLayer);
if (horizontalGuide !== undefined && stageWidth) {
const horizontalLine = new Konva.Line({
name: 'snap-guide-horizontal',
points: [0, horizontalGuide, stageWidth, horizontalGuide],
stroke: 'rgb(0, 161, 255)',
strokeWidth: 1,
dash: [5, 5],
listening: false,
});
snapGuideLayer.add(horizontalLine);
}
if (verticalGuide !== undefined && stageHeight) {
const verticalLine = new Konva.Line({
name: 'snap-guide-vertical',
points: [verticalGuide, 0, verticalGuide, stageHeight],
stroke: 'rgb(0, 161, 255)',
strokeWidth: 1,
dash: [5, 5],
listening: false,
});
snapGuideLayer.add(verticalLine);
}
snapGuideLayer.batchDraw();
}
export function showMultipleSnapGuides(
snapGuideLayer: Konva.Layer,
horizontalGuides: number[],
verticalGuides: number[],
stageWidth: number,
stageHeight: number,
): void {
if (!snapGuideLayer) {
return;
}
hideSnapGuides(snapGuideLayer);
// Show horizontal guides
horizontalGuides.forEach((guide) => {
const horizontalLine = new Konva.Line({
name: 'snap-guide-horizontal',
points: [0, guide, stageWidth, guide],
stroke: 'rgb(0, 161, 255)',
strokeWidth: 1,
dash: [5, 5],
listening: false,
});
snapGuideLayer.add(horizontalLine);
});
// Show vertical guides
verticalGuides.forEach((guide) => {
const verticalLine = new Konva.Line({
name: 'snap-guide-vertical',
points: [guide, 0, guide, stageHeight],
stroke: 'rgb(0, 161, 255)',
strokeWidth: 1,
dash: [5, 5],
listening: false,
});
snapGuideLayer.add(verticalLine);
});
snapGuideLayer.batchDraw();
}
export function hideSnapGuides(snapGuideLayer: Konva.Layer): void {
if (!snapGuideLayer) {
return;
}
const guides = snapGuideLayer.find('.snap-guide-horizontal, .snap-guide-vertical');
guides.forEach((guide: Konva.Node) => guide.destroy());
snapGuideLayer.batchDraw();
}

View File

@ -0,0 +1,176 @@
import Konva from 'konva';
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
import type { TRadioFieldMeta } from '../../types/field-meta';
import { upsertFieldGroup, upsertFieldRect } from './field-generic-items';
import { calculateFieldPosition, calculateMultiItemPosition } from './field-renderer';
import type { FieldToRender, RenderFieldElementOptions } from './field-renderer';
// Do not change any of these values without consulting with the team.
const radioFieldPadding = 8;
const radioSize = 16;
const spacingBetweenRadioAndText = 8;
export const renderRadioFieldElement = (
field: FieldToRender,
options: RenderFieldElementOptions,
) => {
const { pageWidth, pageHeight, pageLayer, mode } = options;
const isFirstRender = !pageLayer.findOne(`#${field.renderId}`);
const fieldGroup = upsertFieldGroup(field, options);
// Clear previous children to re-render fresh
fieldGroup.removeChildren();
fieldGroup.add(upsertFieldRect(field, options));
if (isFirstRender) {
pageLayer.add(fieldGroup);
// Handle rescaling items during transforms.
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
const fieldRect = fieldGroup.findOne('.field-rect');
if (!fieldRect) {
return;
}
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
const circles = fieldGroup.find('.radio-circle').sort((a, b) => a.id().localeCompare(b.id()));
const checkmarks = fieldGroup.find('.radio-dot').sort((a, b) => a.id().localeCompare(b.id()));
const text = fieldGroup.find('.radio-text').sort((a, b) => a.id().localeCompare(b.id()));
const groupedItems = circles.map((circle, i) => ({
circleElement: circle,
checkmarkElement: checkmarks[i],
textElement: text[i],
}));
groupedItems.forEach((item, i) => {
const { circleElement, checkmarkElement, textElement } = item;
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
calculateMultiItemPosition({
fieldWidth: rectWidth,
fieldHeight: rectHeight,
itemCount: radioValues.length,
itemIndex: i,
itemSize: radioSize,
spacingBetweenItemAndText: spacingBetweenRadioAndText,
fieldPadding: radioFieldPadding,
type: 'radio',
});
circleElement.setAttrs({
x: itemInputX,
y: itemInputY,
scaleX: 1,
scaleY: 1,
});
checkmarkElement.setAttrs({
x: itemInputX,
y: itemInputY,
scaleX: 1,
scaleY: 1,
});
textElement.setAttrs({
x: textX,
y: textY,
scaleX: 1,
scaleY: 1,
width: textWidth,
height: textHeight,
});
});
fieldRect.width(rectWidth);
fieldRect.height(rectHeight);
fieldGroup.scale({
x: 1,
y: 1,
});
pageLayer.batchDraw();
});
}
const radioMeta: TRadioFieldMeta | null = (field.fieldMeta as TRadioFieldMeta) || null;
const radioValues = radioMeta?.values || [];
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
radioValues.forEach(({ value, checked }, index) => {
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
calculateMultiItemPosition({
fieldWidth,
fieldHeight,
itemCount: radioValues.length,
itemIndex: index,
itemSize: radioSize,
spacingBetweenItemAndText: spacingBetweenRadioAndText,
fieldPadding: radioFieldPadding,
type: 'radio',
});
// Circle which represents the radio button.
const circle = new Konva.Circle({
internalRadioValue: value,
id: `radio-circle-${index}`,
name: 'radio-circle',
x: itemInputX,
y: itemInputY,
radius: radioSize / 2,
stroke: '#374151',
strokeWidth: 2,
fill: 'white',
});
// Dot which represents the selected state.
const dot = new Konva.Circle({
internalRadioValue: value,
id: `radio-dot-${index}`,
name: 'radio-dot',
x: itemInputX,
y: itemInputY,
radius: radioSize / 4,
fill: '#111827',
// Todo: Envelopes
visible: value === field.customText,
// visible: checked,
});
const text = new Konva.Text({
internalRadioValue: value,
id: `radio-text-${index}`,
name: 'radio-text',
x: textX,
y: textY,
text: value,
width: textWidth,
height: textHeight,
fontSize: DEFAULT_STANDARD_FONT_SIZE,
fontFamily: 'Inter, system-ui, sans-serif',
verticalAlign: 'middle',
fill: '#111827', // Todo: Envelopes - Sort colours
});
fieldGroup.add(circle);
fieldGroup.add(dot);
fieldGroup.add(text);
});
return {
fieldGroup,
isFirstRender,
};
};

View File

@ -0,0 +1,209 @@
import Konva from 'konva';
import {
DEFAULT_RECT_BACKGROUND,
RECIPIENT_COLOR_STYLES,
} from '@documenso/ui/lib/recipient-colors';
import {
DEFAULT_HANDWRITING_FONT_SIZE,
DEFAULT_STANDARD_FONT_SIZE,
MIN_HANDWRITING_FONT_SIZE,
} from '../../constants/pdf';
import { AppError } from '../../errors/app-error';
import { upsertFieldGroup, upsertFieldRect } from './field-generic-items';
import { calculateFieldPosition } from './field-renderer';
import type { FieldToRender, RenderFieldElementOptions } from './field-renderer';
const minFontSize = MIN_HANDWRITING_FONT_SIZE;
const maxFontSize = DEFAULT_HANDWRITING_FONT_SIZE;
const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOptions): Konva.Text => {
const { pageWidth, pageHeight, mode = 'edit', pageLayer } = options;
console.log({
pageWidth,
pageHeight,
});
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
const fieldText: Konva.Text =
pageLayer.findOne(`#${field.renderId}-text`) ||
new Konva.Text({
id: `${field.renderId}-text`,
name: 'field-text',
});
// Calculate text positioning based on alignment
const textX = 0;
const textY = 0;
const textAlign = 'center';
let textVerticalAlign: 'top' | 'middle' | 'bottom' = 'top';
let textFontSize = DEFAULT_STANDARD_FONT_SIZE;
const textPadding = 10;
let textToRender: string = field.type;
const signature = field.signature;
// Handle edit mode.
if (mode === 'edit') {
textToRender = field.type; // Todo: Envelope - Need translations
}
// Handle sign mode.
if (mode === 'sign' || mode === 'export') {
textToRender = field.type; // Todo: Envelope - Need translations
textFontSize = DEFAULT_STANDARD_FONT_SIZE;
textVerticalAlign = 'middle';
if (field.inserted && !signature) {
throw new AppError('MISSING_SIGNATURE');
}
if (signature?.typedSignature) {
textToRender = signature.typedSignature;
}
}
fieldText.setAttrs({
x: textX,
y: textY,
verticalAlign: textVerticalAlign,
wrap: 'word',
padding: textPadding,
text: textToRender,
fontSize: textFontSize,
fontFamily: 'Caveat, Inter', // Todo: Envelopes - Fix all fonts for sans
align: textAlign,
width: fieldWidth,
height: fieldHeight,
} satisfies Partial<Konva.TextConfig>);
return fieldText;
};
export const renderSignatureFieldElement = (
field: FieldToRender,
options: RenderFieldElementOptions,
) => {
const { mode = 'edit', pageLayer } = options;
const isFirstRender = !pageLayer.findOne(`#${field.renderId}`);
const fieldGroup = upsertFieldGroup(field, options);
// ABOVE IS GENERIC, EXTRACT IT.
// Render the field background and text.
const fieldRect = upsertFieldRect(field, options);
const fieldText = upsertFieldText(field, options);
// Assign elements to group and any listeners that should only be run on initialization.
if (isFirstRender) {
fieldGroup.add(fieldRect);
fieldGroup.add(fieldText);
pageLayer.add(fieldGroup);
// This is to keep the text inside the field at the same size
// when the field is resized. Without this the text would be stretched.
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
// Adjust text scale so it doesn't change while group is resized.
fieldText.scaleX(1 / groupScaleX);
fieldText.scaleY(1 / groupScaleY);
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
// // Update text group position and clipping
// fieldGroup.clipFunc(function (ctx) {
// ctx.rect(0, 0, rectWidth, rectHeight);
// });
// Update text dimensions
fieldText.width(rectWidth); // Account for padding
fieldText.height(rectHeight);
console.log({
rectWidth,
});
// Force Konva to recalculate text layout
// textInsideField.getTextHeight(); // This forces recalculation
fieldText.height(); // This forces recalculation
// fieldGroup.draw();
fieldGroup.getLayer()?.batchDraw();
});
// Reset the text after transform has ended.
fieldGroup.on('transformend', () => {
fieldText.scaleX(1);
fieldText.scaleY(1);
const rectWidth = fieldRect.width();
const rectHeight = fieldRect.height();
// // Update text group position and clipping
// fieldGroup.clipFunc(function (ctx) {
// ctx.rect(0, 0, rectWidth, rectHeight);
// });
// Update text dimensions
fieldText.width(rectWidth); // Account for padding
fieldText.height(rectHeight);
// Force Konva to recalculate text layout
// textInsideField.getTextHeight(); // This forces recalculation
fieldText.height(); // This forces recalculation
// fieldGroup.draw();
fieldGroup.getLayer()?.batchDraw();
});
}
// Handle export mode.
if (mode === 'export') {
// Hide the rectangle.
fieldRect.opacity(0);
}
// Todo: Doesn't work.
if (mode !== 'export') {
const hoverColor = options.color
? RECIPIENT_COLOR_STYLES[options.color].baseRingHover
: '#e5e7eb';
// Todo: Envelopes - On hover add text color
// Add smooth transition-like behavior for hover effects
fieldGroup.on('mouseover', () => {
new Konva.Tween({
node: fieldRect,
duration: 0.3,
fill: hoverColor,
}).play();
});
fieldGroup.on('mouseout', () => {
new Konva.Tween({
node: fieldRect,
duration: 0.3,
fill: DEFAULT_RECT_BACKGROUND,
}).play();
});
fieldGroup.add(fieldRect);
}
return {
fieldGroup,
isFirstRender,
};
};

View File

@ -0,0 +1,234 @@
import Konva from 'konva';
import {
DEFAULT_RECT_BACKGROUND,
RECIPIENT_COLOR_STYLES,
} from '@documenso/ui/lib/recipient-colors';
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
import type { TTextFieldMeta } from '../../types/field-meta';
import { upsertFieldGroup, upsertFieldRect } from './field-generic-items';
import type { FieldToRender, RenderFieldElementOptions } from './field-renderer';
import { calculateFieldPosition } from './field-renderer';
const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOptions): Konva.Text => {
const { pageWidth, pageHeight, mode = 'edit', pageLayer } = options;
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
const textMeta = field.fieldMeta as TTextFieldMeta | undefined;
const fieldText: Konva.Text =
pageLayer.findOne(`#${field.renderId}-text`) ||
new Konva.Text({
id: `${field.renderId}-text`,
name: 'field-text',
});
// Calculate text positioning based on alignment
const textX = 0;
const textY = 0;
let textAlign: 'left' | 'center' | 'right' = textMeta?.textAlign || 'left';
let textVerticalAlign: 'top' | 'middle' | 'bottom' = 'top';
let textFontSize = textMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE;
const textPadding = 10;
let textToRender: string = field.type;
// Handle edit mode.
if (mode === 'edit') {
textToRender = field.type; // Todo: Envelope - Need translations
textAlign = 'center';
textFontSize = DEFAULT_STANDARD_FONT_SIZE;
textVerticalAlign = 'middle';
if (textMeta?.label) {
textToRender = textMeta.label;
textFontSize = textMeta.fontSize || DEFAULT_STANDARD_FONT_SIZE;
} else if (textMeta?.text) {
textToRender = textMeta.text;
textFontSize = textMeta.fontSize || DEFAULT_STANDARD_FONT_SIZE;
textAlign = textMeta.textAlign || 'center'; // Todo: Envelopes - What is the default
// Todo: Envelopes - Handle this on signatures
if (textMeta.characterLimit) {
textToRender = textToRender.slice(0, textMeta.characterLimit);
}
}
}
// Handle sign mode.
if (mode === 'sign' || mode === 'export') {
textToRender = field.type; // Todo: Envelope - Need translations
textAlign = 'center';
textFontSize = DEFAULT_STANDARD_FONT_SIZE;
textVerticalAlign = 'middle';
if (textMeta?.label) {
textToRender = textMeta.label;
textFontSize = textMeta.fontSize || DEFAULT_STANDARD_FONT_SIZE;
}
if (textMeta?.text) {
textToRender = textMeta.text;
textFontSize = textMeta.fontSize || DEFAULT_STANDARD_FONT_SIZE;
textAlign = textMeta.textAlign || 'center'; // Todo: Envelopes - What is the default
// Todo: Envelopes - Handle this on signatures
if (textMeta.characterLimit) {
textToRender = textToRender.slice(0, textMeta.characterLimit);
}
}
if (field.inserted) {
textToRender = field.customText;
textFontSize = textMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE;
textAlign = textMeta?.textAlign || 'center'; // Todo: Envelopes - What is the default
// Todo: Envelopes - Handle this on signatures
if (textMeta?.characterLimit) {
textToRender = textToRender.slice(0, textMeta.characterLimit);
}
}
}
fieldText.setAttrs({
x: textX,
y: textY,
verticalAlign: textVerticalAlign,
wrap: 'word',
padding: textPadding,
text: textToRender,
fontSize: textFontSize,
fontFamily: 'Inter, system-ui, sans-serif',
align: textAlign,
width: fieldWidth,
height: fieldHeight,
} satisfies Partial<Konva.TextConfig>);
return fieldText;
};
export const renderTextFieldElement = (
field: FieldToRender,
options: RenderFieldElementOptions,
) => {
const { mode = 'edit', pageLayer } = options;
const isFirstRender = !pageLayer.findOne(`#${field.renderId}`);
const fieldGroup = upsertFieldGroup(field, options);
// ABOVE IS GENERIC, EXTRACT IT.
// Render the field background and text.
const fieldRect = upsertFieldRect(field, options);
const fieldText = upsertFieldText(field, options);
// Assign elements to group and any listeners that should only be run on initialization.
if (isFirstRender) {
fieldGroup.add(fieldRect);
fieldGroup.add(fieldText);
pageLayer.add(fieldGroup);
// This is to keep the text inside the field at the same size
// when the field is resized. Without this the text would be stretched.
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
// Adjust text scale so it doesn't change while group is resized.
fieldText.scaleX(1 / groupScaleX);
fieldText.scaleY(1 / groupScaleY);
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
// // Update text group position and clipping
// fieldGroup.clipFunc(function (ctx) {
// ctx.rect(0, 0, rectWidth, rectHeight);
// });
// Update text dimensions
fieldText.width(rectWidth); // Account for padding
fieldText.height(rectHeight);
console.log({
rectWidth,
});
// Force Konva to recalculate text layout
// textInsideField.getTextHeight(); // This forces recalculation
fieldText.height(); // This forces recalculation
// fieldGroup.draw();
fieldGroup.getLayer()?.batchDraw();
});
// Reset the text after transform has ended.
fieldGroup.on('transformend', () => {
fieldText.scaleX(1);
fieldText.scaleY(1);
const rectWidth = fieldRect.width();
const rectHeight = fieldRect.height();
// // Update text group position and clipping
// fieldGroup.clipFunc(function (ctx) {
// ctx.rect(0, 0, rectWidth, rectHeight);
// });
// Update text dimensions
fieldText.width(rectWidth); // Account for padding
fieldText.height(rectHeight);
// Force Konva to recalculate text layout
// textInsideField.getTextHeight(); // This forces recalculation
fieldText.height(); // This forces recalculation
// fieldGroup.draw();
fieldGroup.getLayer()?.batchDraw();
});
}
// Handle export mode.
if (mode === 'export') {
// Hide the rectangle.
fieldRect.opacity(0);
}
// Todo: Doesn't work.
if (mode !== 'export') {
const hoverColor = options.color
? RECIPIENT_COLOR_STYLES[options.color].baseRingHover
: '#e5e7eb';
// Todo: Envelopes - On hover add text color
// Add smooth transition-like behavior for hover effects
fieldGroup.on('mouseover', () => {
new Konva.Tween({
node: fieldRect,
duration: 0.3,
fill: hoverColor,
}).play();
});
fieldGroup.on('mouseout', () => {
new Konva.Tween({
node: fieldRect,
duration: 0.3,
fill: DEFAULT_RECT_BACKGROUND,
}).play();
});
fieldGroup.add(fieldRect);
}
return {
fieldGroup,
isFirstRender,
};
};