mirror of
https://github.com/documenso/documenso.git
synced 2025-11-15 01:01:49 +10:00
feat: add envelope editor
This commit is contained in:
119
packages/lib/universal/field-renderer/field-generic-items.ts
Normal file
119
packages/lib/universal/field-renderer/field-generic-items.ts
Normal 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;
|
||||
};
|
||||
158
packages/lib/universal/field-renderer/field-renderer.ts
Normal file
158
packages/lib/universal/field-renderer/field-renderer.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
187
packages/lib/universal/field-renderer/render-checkbox-field.ts
Normal file
187
packages/lib/universal/field-renderer/render-checkbox-field.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
179
packages/lib/universal/field-renderer/render-dropdown-field.ts
Normal file
179
packages/lib/universal/field-renderer/render-dropdown-field.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
78
packages/lib/universal/field-renderer/render-field.ts
Normal file
78
packages/lib/universal/field-renderer/render-field.ts
Normal 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
|
||||
};
|
||||
326
packages/lib/universal/field-renderer/render-grid-lines.ts
Normal file
326
packages/lib/universal/field-renderer/render-grid-lines.ts
Normal 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();
|
||||
}
|
||||
176
packages/lib/universal/field-renderer/render-radio-field.ts
Normal file
176
packages/lib/universal/field-renderer/render-radio-field.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
209
packages/lib/universal/field-renderer/render-signature-field.ts
Normal file
209
packages/lib/universal/field-renderer/render-signature-field.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
234
packages/lib/universal/field-renderer/render-text-field.ts
Normal file
234
packages/lib/universal/field-renderer/render-text-field.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user