feat: polish envelopes (#2090)

## Description

The rest of the owl
This commit is contained in:
David Nguyen
2025-10-24 16:22:06 +11:00
committed by GitHub
parent 88836404d1
commit 03eb6af69a
141 changed files with 5171 additions and 2402 deletions

View File

@ -8,11 +8,15 @@ import {
import type { FieldToRender, RenderFieldElementOptions } from './field-renderer';
import { calculateFieldPosition } from './field-renderer';
export const konvaTextFontFamily =
'Noto Sans, Noto Sans Japanese, Noto Sans Chinese, Noto Sans Korean, sans-serif';
export const konvaTextFill = 'black';
export const upsertFieldGroup = (
field: FieldToRender,
options: RenderFieldElementOptions,
): Konva.Group => {
const { pageWidth, pageHeight, pageLayer, editable } = options;
const { pageWidth, pageHeight, pageLayer, editable, scale } = options;
const { fieldX, fieldY, fieldWidth, fieldHeight } = calculateFieldPosition(
field,
@ -27,6 +31,9 @@ export const upsertFieldGroup = (
name: 'field-group',
});
const maxXPosition = (pageWidth - fieldWidth) * scale;
const maxYPosition = (pageHeight - fieldHeight) * scale;
fieldGroup.setAttrs({
scaleX: 1,
scaleY: 1,
@ -34,8 +41,9 @@ export const upsertFieldGroup = (
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));
const newX = Math.max(0, Math.min(maxXPosition, pos.x));
const newY = Math.max(0, Math.min(maxYPosition, pos.y));
return { x: newX, y: newY };
},
} satisfies Partial<Konva.GroupConfig>);
@ -89,14 +97,18 @@ export const createSpinner = ({
width: fieldWidth - 8,
height: fieldHeight - 8,
fill: 'white',
opacity: 1,
opacity: 0.8,
});
const maxSpinnerSize = 10;
const smallerDimension = Math.min(fieldWidth, fieldHeight);
const spinnerSize = Math.min(smallerDimension, maxSpinnerSize);
const spinner = new Konva.Arc({
x: fieldWidth / 2,
y: fieldHeight / 2,
innerRadius: fieldWidth / 10,
outerRadius: fieldHeight / 10,
innerRadius: spinnerSize,
outerRadius: spinnerSize / 2,
angle: 270,
rotation: 0,
fill: 'rgba(122, 195, 85, 1)',

View File

@ -1,4 +1,4 @@
import type { Signature } from '@prisma/client';
import type { FieldType, Signature } from '@prisma/client';
import { type Field } from '@prisma/client';
import type Konva from 'konva';
@ -26,9 +26,11 @@ export type RenderFieldElementOptions = {
pageLayer: Konva.Layer;
pageWidth: number;
pageHeight: number;
mode?: 'edit' | 'sign' | 'export';
mode: 'edit' | 'sign' | 'export';
editable?: boolean;
scale: number;
color?: TRecipientColor;
translations: Record<FieldType, string> | null;
};
/**
@ -107,6 +109,11 @@ type CalculateMultiItemPositionOptions = {
*/
fieldPadding: number;
/**
* The direction of the items.
*/
direction: 'horizontal' | 'vertical';
type: 'checkbox' | 'radio';
};
@ -122,6 +129,7 @@ export const calculateMultiItemPosition = (options: CalculateMultiItemPositionOp
itemSize,
spacingBetweenItemAndText,
fieldPadding,
direction,
type,
} = options;
@ -130,6 +138,39 @@ export const calculateMultiItemPosition = (options: CalculateMultiItemPositionOp
const innerFieldX = fieldPadding;
const innerFieldY = fieldPadding;
if (direction === 'horizontal') {
const itemHeight = innerFieldHeight;
const itemWidth = innerFieldWidth / itemCount;
const y = innerFieldY;
const x = itemIndex * itemWidth + innerFieldX;
let itemInputY = y + itemHeight / 2 - itemSize / 2;
let itemInputX = x;
// We need a little different logic to center the radio circle icon.
if (type === 'radio') {
itemInputX = x + itemSize / 2;
itemInputY = y + itemHeight / 2;
}
const textX = x + itemSize + spacingBetweenItemAndText;
const textY = y;
// Multiplied by 2 for extra padding on the right hand side of the text and the next item.
const textWidth = itemWidth - itemSize - spacingBetweenItemAndText * 2;
const textHeight = itemHeight;
return {
itemInputX,
itemInputY,
textX,
textY,
textWidth,
textHeight,
};
}
const itemHeight = innerFieldHeight / itemCount;
const y = itemIndex * itemHeight + innerFieldY;
@ -137,6 +178,7 @@ export const calculateMultiItemPosition = (options: CalculateMultiItemPositionOp
let itemInputY = y + itemHeight / 2 - itemSize / 2;
let itemInputX = innerFieldX;
// We need a little different logic to center the radio circle icon.
if (type === 'radio') {
itemInputX = innerFieldX + itemSize / 2;
itemInputY = y + itemHeight / 2;

View File

@ -1,16 +1,25 @@
import Konva from 'konva';
import { match } from 'ts-pattern';
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
import type { TCheckboxFieldMeta } from '../../types/field-meta';
import { upsertFieldGroup, upsertFieldRect } from './field-generic-items';
import {
konvaTextFill,
konvaTextFontFamily,
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;
const calculateCheckboxSize = (fontSize: number) => {
return fontSize;
};
export const renderCheckboxFieldElement = (
field: FieldToRender,
options: RenderFieldElementOptions,
@ -21,134 +30,156 @@ export const renderCheckboxFieldElement = (
const fieldGroup = upsertFieldGroup(field, options);
// Clear previous children to re-render fresh
// Clear previous children and listeners to re-render fresh.
fieldGroup.removeChildren();
fieldGroup.off('transform');
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 fontSize = checkboxMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE;
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: calculateCheckboxSize(fontSize),
spacingBetweenItemAndText: spacingBetweenCheckboxAndText,
fieldPadding: checkboxFieldPadding,
direction: checkboxMeta?.direction || 'vertical',
type: 'checkbox',
});
squareElement.setAttrs({
x: itemInputX,
y: itemInputY,
scaleX: 1,
scaleY: 1,
});
checkmarkElement.setAttrs({
x: itemInputX,
y: itemInputY,
});
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 { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
const checkedValues: number[] = field.customText ? JSON.parse(field.customText) : [];
checkboxValues.forEach(({ id, value, checked }, index) => {
const isCheckboxChecked = match(mode)
.with('edit', () => checked)
.with('sign', () => checkedValues.includes(index))
.with('export', () => {
// If it's read-only, check the originally checked state.
if (checkboxMeta.readOnly) {
return checked;
}
return checkedValues.includes(index);
})
.exhaustive();
console.log('wtf?');
const itemSize = calculateCheckboxSize(fontSize);
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
calculateMultiItemPosition({
fieldWidth,
fieldHeight,
itemCount: checkboxValues.length,
itemIndex: index,
itemSize: checkboxSize,
itemSize,
spacingBetweenItemAndText: spacingBetweenCheckboxAndText,
fieldPadding: checkboxFieldPadding,
direction: checkboxMeta?.direction || 'vertical',
type: 'checkbox',
});
const square = new Konva.Rect({
internalCheckboxId: id,
internalCheckboxValue: value,
internalCheckboxIndex: index,
id: `checkbox-square-${index}`,
name: 'checkbox-square',
x: itemInputX,
y: itemInputY,
width: checkboxSize,
height: checkboxSize,
width: itemSize,
height: itemSize,
stroke: '#374151',
strokeWidth: 2,
cornerRadius: 2,
fill: 'white',
});
const checkboxScale = itemSize / 16;
const checkmark = new Konva.Line({
internalCheckboxId: id,
internalCheckboxValue: value,
internalCheckboxIndex: index,
id: `checkbox-checkmark-${index}`,
name: 'checkbox-checkmark',
x: itemInputX,
@ -156,12 +187,12 @@ export const renderCheckboxFieldElement = (
strokeWidth: 2,
stroke: '#111827',
points: [3, 8, 7, 12, 13, 4],
visible: checked,
scale: { x: checkboxScale, y: checkboxScale },
visible: isCheckboxChecked,
});
const text = new Konva.Text({
internalCheckboxId: id,
internalCheckboxValue: value,
internalCheckboxIndex: index,
id: `checkbox-text-${index}`,
name: 'checkbox-text',
x: textX,
@ -169,10 +200,10 @@ export const renderCheckboxFieldElement = (
text: value,
width: textWidth,
height: textHeight,
fontSize: DEFAULT_STANDARD_FONT_SIZE,
fontFamily: 'Inter, system-ui, sans-serif',
fontSize,
fontFamily: konvaTextFontFamily,
fill: konvaTextFill,
verticalAlign: 'middle',
fill: '#111827', // Todo: Envelopes - Sort colours
});
fieldGroup.add(square);

View File

@ -2,7 +2,12 @@ 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 {
konvaTextFill,
konvaTextFontFamily,
upsertFieldGroup,
upsertFieldRect,
} from './field-generic-items';
import { calculateFieldPosition } from './field-renderer';
import type { FieldToRender, RenderFieldElementOptions } from './field-renderer';
@ -26,7 +31,7 @@ const calculateDropdownPosition = (options: CalculateDropdownPositionOptions) =>
const textY = fieldPadding;
const arrowX = fieldWidth - arrowSize - fieldPadding;
const arrowY = fieldHeight / 2;
const arrowY = fieldHeight / 2 - arrowSize / 4;
return {
arrowX,
@ -111,6 +116,8 @@ export const renderDropdownFieldElement = (
const dropdownMeta: TDropdownFieldMeta | null = (field.fieldMeta as TDropdownFieldMeta) || null;
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
const fontSize = dropdownMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE;
// Todo: Envelopes - Translations
let selectedValue = 'Select Option';
@ -132,9 +139,9 @@ export const renderDropdownFieldElement = (
width: textWidth,
height: textHeight,
text: selectedValue,
fontSize: DEFAULT_STANDARD_FONT_SIZE,
fontFamily: 'Inter, system-ui, sans-serif',
fill: '#111827',
fontSize,
fontFamily: konvaTextFontFamily,
fill: konvaTextFill,
verticalAlign: 'middle',
});

View File

@ -36,6 +36,8 @@ type RenderFieldOptions = {
color?: TRecipientColor;
translations: Record<FieldType, string> | null;
/**
* The render type.
*
@ -47,15 +49,18 @@ type RenderFieldOptions = {
*/
mode: 'edit' | 'sign' | 'export';
scale: number;
editable?: boolean;
};
export const renderField = ({
field,
translations,
pageLayer,
pageWidth,
pageHeight,
mode,
scale,
editable,
color,
}: RenderFieldOptions) => {
@ -63,9 +68,11 @@ export const renderField = ({
pageLayer,
pageWidth,
pageHeight,
translations,
mode,
color,
editable,
scale,
};
return match(field.type)
@ -74,5 +81,5 @@ export const renderField = ({
.with(FieldType.RADIO, () => renderRadioFieldElement(field, options))
.with(FieldType.DROPDOWN, () => renderDropdownFieldElement(field, options))
.with(FieldType.SIGNATURE, () => renderSignatureFieldElement(field, options))
.otherwise(() => renderTextFieldElement(field, options)); // Todo
.otherwise(() => renderTextFieldElement(field, options)); // Todo: Envelopes
};

View File

@ -1,16 +1,25 @@
import Konva from 'konva';
import { match } from 'ts-pattern';
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
import type { TRadioFieldMeta } from '../../types/field-meta';
import { upsertFieldGroup, upsertFieldRect } from './field-generic-items';
import {
konvaTextFill,
konvaTextFontFamily,
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;
const calculateRadioSize = (fontSize: number) => {
return fontSize;
};
export const renderRadioFieldElement = (
field: FieldToRender,
options: RenderFieldElementOptions,
@ -26,110 +35,129 @@ export const renderRadioFieldElement = (
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 fontSize = radioMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE;
if (isFirstRender) {
pageLayer.add(fieldGroup);
}
fieldGroup.off('transform');
// 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: calculateRadioSize(fontSize),
spacingBetweenItemAndText: spacingBetweenRadioAndText,
fieldPadding: radioFieldPadding,
type: 'radio',
direction: radioMeta?.direction || 'vertical',
});
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 { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
radioValues.forEach(({ value, checked }, index) => {
const isRadioValueChecked = match(mode)
.with('edit', () => checked)
.with('sign', () => index.toString() === field.customText)
.with('export', () => {
// If it's read-only, check the originally checked state.
if (radioMeta.readOnly) {
return checked;
}
return index.toString() === field.customText;
})
.exhaustive();
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
calculateMultiItemPosition({
fieldWidth,
fieldHeight,
itemCount: radioValues.length,
itemIndex: index,
itemSize: radioSize,
itemSize: calculateRadioSize(fontSize),
spacingBetweenItemAndText: spacingBetweenRadioAndText,
fieldPadding: radioFieldPadding,
type: 'radio',
direction: radioMeta?.direction || 'vertical',
});
// Circle which represents the radio button.
const circle = new Konva.Circle({
internalRadioValue: value,
internalRadioIndex: index,
id: `radio-circle-${index}`,
name: 'radio-circle',
x: itemInputX,
y: itemInputY,
radius: radioSize / 2,
radius: calculateRadioSize(fontSize) / 2,
stroke: '#374151',
strokeWidth: 2,
fill: 'white',
@ -137,20 +165,18 @@ export const renderRadioFieldElement = (
// Dot which represents the selected state.
const dot = new Konva.Circle({
internalRadioValue: value,
internalRadioIndex: index,
id: `radio-dot-${index}`,
name: 'radio-dot',
x: itemInputX,
y: itemInputY,
radius: radioSize / 4,
radius: calculateRadioSize(fontSize) / 4,
fill: '#111827',
// Todo: Envelopes
visible: value === field.customText,
// visible: checked,
visible: isRadioValueChecked,
});
const text = new Konva.Text({
internalRadioValue: value,
internalRadioIndex: index,
id: `radio-text-${index}`,
name: 'radio-text',
x: textX,
@ -158,10 +184,10 @@ export const renderRadioFieldElement = (
text: value,
width: textWidth,
height: textHeight,
fontSize: DEFAULT_STANDARD_FONT_SIZE,
fontFamily: 'Inter, system-ui, sans-serif',
fontSize,
fontFamily: konvaTextFontFamily,
fill: konvaTextFill,
verticalAlign: 'middle',
fill: '#111827', // Todo: Envelopes - Sort colours
});
fieldGroup.add(circle);

View File

@ -5,58 +5,74 @@ import {
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 { DEFAULT_SIGNATURE_TEXT_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;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let SkiaImage: any = undefined;
const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOptions): Konva.Text => {
const { pageWidth, pageHeight, mode = 'edit', pageLayer } = options;
void (async () => {
if (typeof window === 'undefined') {
const mod = await import('skia-canvas');
SkiaImage = mod.Image;
}
})();
console.log({
pageWidth,
pageHeight,
});
const getImageDimensions = (img: HTMLImageElement, fieldWidth: number, fieldHeight: number) => {
let imageWidth = img.width;
let imageHeight = img.height;
const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight, 1);
imageWidth = imageWidth * scalingFactor;
imageHeight = imageHeight * scalingFactor;
const imageX = (fieldWidth - imageWidth) / 2;
const imageY = (fieldHeight - imageHeight) / 2;
return {
width: imageWidth,
height: imageHeight,
x: imageX,
y: imageY,
};
};
const createFieldSignature = (
field: FieldToRender,
options: RenderFieldElementOptions,
): Konva.Text | Konva.Image => {
const { pageWidth, pageHeight, mode = 'edit', translations } = options;
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
const fontSize = field.fieldMeta?.fontSize || DEFAULT_SIGNATURE_TEXT_FONT_SIZE;
const fieldText: Konva.Text =
pageLayer.findOne(`#${field.renderId}-text`) ||
new Konva.Text({
id: `${field.renderId}-text`,
name: 'field-text',
});
const fieldText = new Konva.Text({
id: `${field.renderId}-text`,
name: 'field-text',
});
const fieldTypeName = translations?.[field.type] || field.type;
// 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;
let textToRender: string = fieldTypeName;
const signature = field.signature;
// Handle edit mode.
if (mode === 'edit') {
textToRender = field.type; // Todo: Envelope - Need translations
textToRender = fieldTypeName;
}
// Handle sign mode.
if (mode === 'sign' || mode === 'export') {
textToRender = field.type; // Todo: Envelope - Need translations
textFontSize = DEFAULT_STANDARD_FONT_SIZE;
textVerticalAlign = 'middle';
textToRender = fieldTypeName;
if (field.inserted && !signature) {
throw new AppError('MISSING_SIGNATURE');
@ -65,20 +81,57 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
if (signature?.typedSignature) {
textToRender = signature.typedSignature;
}
if (signature?.signatureImageAsBase64) {
if (typeof window !== 'undefined') {
// Create a new HTML Image element
const img = new Image();
const image = new Konva.Image({
image: img,
x: 0,
y: 0,
width: fieldWidth,
height: fieldHeight,
});
img.onload = () => {
image.setAttrs({
...getImageDimensions(img, fieldWidth, fieldHeight),
});
};
img.src = signature.signatureImageAsBase64;
return image;
} else {
// Node.js with skia-canvas
if (!SkiaImage) {
throw new Error('Skia image not found');
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const img = new SkiaImage(signature?.signatureImageAsBase64) as unknown as HTMLImageElement;
const image = new Konva.Image({
image: img,
...getImageDimensions(img, fieldWidth, fieldHeight),
});
return image;
}
}
}
fieldText.setAttrs({
x: textX,
y: textY,
verticalAlign: textVerticalAlign,
wrap: 'word',
padding: textPadding,
verticalAlign: 'middle',
wrap: 'char',
text: textToRender,
fontSize: textFontSize,
fontFamily: 'Caveat, Inter', // Todo: Envelopes - Fix all fonts for sans
align: textAlign,
fontSize,
fontFamily: 'Caveat, sans-serif',
align: 'center',
width: fieldWidth,
height: fieldHeight,
} satisfies Partial<Konva.TextConfig>);
@ -96,78 +149,63 @@ export const renderSignatureFieldElement = (
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);
// Clear previous children and listeners to re-render fresh.
fieldGroup.removeChildren();
fieldGroup.off('transform');
// 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();
});
}
// Render the field background and text.
const fieldRect = upsertFieldRect(field, options);
const fieldSignature = createFieldSignature(field, options);
fieldGroup.add(fieldRect);
fieldGroup.add(fieldSignature);
// 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.
fieldSignature.scaleX(1 / groupScaleX);
fieldSignature.scaleY(1 / groupScaleY);
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
// Update text dimensions
fieldSignature.width(rectWidth);
fieldSignature.height(rectHeight);
// Force Konva to recalculate text layout
fieldSignature.height();
fieldGroup.getLayer()?.batchDraw();
});
// Reset the text after transform has ended.
fieldGroup.on('transformend', () => {
fieldSignature.scaleX(1);
fieldSignature.scaleY(1);
const rectWidth = fieldRect.width();
const rectHeight = fieldRect.height();
// Update text dimensions
fieldSignature.width(rectWidth); // Account for padding
fieldSignature.height(rectHeight);
// Force Konva to recalculate text layout
fieldSignature.height();
fieldGroup.getLayer()?.batchDraw();
});
// Handle export mode.
if (mode === 'export') {
// Hide the rectangle.

View File

@ -7,12 +7,19 @@ import {
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
import type { TTextFieldMeta } from '../../types/field-meta';
import { upsertFieldGroup, upsertFieldRect } from './field-generic-items';
import {
konvaTextFill,
konvaTextFontFamily,
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 { pageWidth, pageHeight, mode = 'edit', pageLayer, translations } = options;
const fieldTypeName = translations?.[field.type] || field.type;
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
@ -30,24 +37,21 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
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 textFontSize = textMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE;
const textPadding = 10;
let textToRender: string = field.type;
let textToRender: string = fieldTypeName;
// Handle edit mode.
if (mode === 'edit') {
textToRender = field.type; // Todo: Envelope - Need translations
textToRender = fieldTypeName;
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
@ -59,19 +63,16 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
// Handle sign mode.
if (mode === 'sign' || mode === 'export') {
textToRender = field.type; // Todo: Envelope - Need translations
textToRender = fieldTypeName;
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
@ -82,7 +83,6 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
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
@ -98,11 +98,10 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
verticalAlign: textVerticalAlign,
wrap: 'word',
padding: textPadding,
text: textToRender,
fontSize: textFontSize,
fontFamily: 'Inter, system-ui, sans-serif',
fontFamily: konvaTextFontFamily,
fill: konvaTextFill,
align: textAlign,
width: fieldWidth,
height: fieldHeight,
@ -121,77 +120,62 @@ export const renderTextFieldElement = (
const fieldGroup = upsertFieldGroup(field, options);
// ABOVE IS GENERIC, EXTRACT IT.
// Clear previous children and listeners to re-render fresh.
fieldGroup.removeChildren();
fieldGroup.off('transform');
// Assign elements to group and any listeners that should only be run on initialization.
if (isFirstRender) {
pageLayer.add(fieldGroup);
}
// 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);
fieldGroup.add(fieldRect);
fieldGroup.add(fieldText);
// 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();
// 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);
// 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;
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);
fieldText.height(rectHeight);
// Update text dimensions
fieldText.width(rectWidth); // Account for padding
fieldText.height(rectHeight);
// Force Konva to recalculate text layout
fieldText.height();
console.log({
rectWidth,
});
fieldGroup.getLayer()?.batchDraw();
});
// Force Konva to recalculate text layout
// textInsideField.getTextHeight(); // This forces recalculation
fieldText.height(); // This forces recalculation
// Reset the text after transform has ended.
fieldGroup.on('transformend', () => {
fieldText.scaleX(1);
fieldText.scaleY(1);
// fieldGroup.draw();
fieldGroup.getLayer()?.batchDraw();
});
const rectWidth = fieldRect.width();
const rectHeight = fieldRect.height();
// Reset the text after transform has ended.
fieldGroup.on('transformend', () => {
fieldText.scaleX(1);
fieldText.scaleY(1);
// Update text dimensions
fieldText.width(rectWidth); // Account for padding
fieldText.height(rectHeight);
const rectWidth = fieldRect.width();
const rectHeight = fieldRect.height();
// Force Konva to recalculate text layout
fieldText.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();
});
}
fieldGroup.getLayer()?.batchDraw();
});
// Handle export mode.
if (mode === 'export') {