mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 08:13:56 +10:00
feat: add envelopes (#2025)
This PR is handles the changes required to support envelopes. The new envelope editor/signing page will be hidden during release. The core changes here is to migrate the documents and templates model to a centralized envelopes model. Even though Documents and Templates are removed, from the user perspective they will still exist as we remap envelopes to documents and templates.
This commit is contained in:
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,
|
||||
};
|
||||
};
|
||||
@ -1,5 +1,9 @@
|
||||
import type { Envelope } from '@prisma/client';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import { customAlphabet } from 'nanoid';
|
||||
|
||||
import { mapSecondaryIdToDocumentId, mapSecondaryIdToTemplateId } from '../utils/envelope';
|
||||
|
||||
export const alphaid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 21);
|
||||
|
||||
export { nanoid } from 'nanoid';
|
||||
@ -11,6 +15,10 @@ export const prefixedId = (prefix: string, length = 16) => {
|
||||
};
|
||||
|
||||
type DatabaseIdPrefix =
|
||||
| 'document'
|
||||
| 'template'
|
||||
| 'envelope'
|
||||
| 'envelope_item'
|
||||
| 'email_domain'
|
||||
| 'org'
|
||||
| 'org_email'
|
||||
@ -25,3 +33,16 @@ type DatabaseIdPrefix =
|
||||
| 'team_setting';
|
||||
|
||||
export const generateDatabaseId = (prefix: DatabaseIdPrefix) => prefixedId(prefix, 16);
|
||||
|
||||
export const extractLegacyIds = (envelope: Pick<Envelope, 'type' | 'secondaryId'>) => {
|
||||
return {
|
||||
documentId:
|
||||
envelope.type === EnvelopeType.DOCUMENT
|
||||
? mapSecondaryIdToDocumentId(envelope.secondaryId)
|
||||
: null,
|
||||
templateId:
|
||||
envelope.type === EnvelopeType.TEMPLATE
|
||||
? mapSecondaryIdToTemplateId(envelope.secondaryId)
|
||||
: null,
|
||||
};
|
||||
};
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { PDFDocument } from '@cantoo/pdf-lib';
|
||||
import { DocumentDataType } from '@prisma/client';
|
||||
import { base64 } from '@scure/base';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
|
||||
Reference in New Issue
Block a user