feat: add envelope editor

This commit is contained in:
David Nguyen
2025-10-12 23:35:54 +11:00
parent bf89bc781b
commit 0da8e7dbc6
307 changed files with 24657 additions and 3681 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,
};
};