feat: add new field overflow methods (#2715)

This commit is contained in:
David Nguyen
2026-05-08 15:14:27 +10:00
committed by GitHub
parent 4877d1964a
commit 207135d6f3
28 changed files with 2437 additions and 65 deletions
@@ -0,0 +1,340 @@
import Konva from 'konva';
import type { TFieldOverflowMode } from '../../types/field-meta';
type OverflowLayoutParams = {
/** The resolved overflow mode ('crop' | 'auto' | 'horizontal' | 'vertical'). */
overflowMode: TFieldOverflowMode;
/** True when rendering the field type name (like "Text", "Date", "Email") or a user label, not actual user content. */
isLabel: boolean;
/** The text content to render. Used to determine if text overflows the field bounds. */
textToRender: string;
/** Font size in pixels. */
fontSize: number;
/** CSS font family string. */
fontFamily: string;
/** Line height multiplier. */
lineHeight: number;
/** Letter spacing in pixels. */
letterSpacing: number;
/** Horizontal text alignment. */
textAlign: 'left' | 'center' | 'right';
/** Vertical text alignment. */
verticalAlign: 'top' | 'middle' | 'bottom';
/** Text x position within the group (e.g. padding offset). */
baseX: number;
/** Text y position within the group. */
baseY: number;
/** Text width at field bounds (fieldWidth minus any padding). */
baseWidth: number;
/** Text height at field bounds (fieldHeight). */
baseHeight: number;
/** Group x position on the page (fieldX from calculateFieldPosition). */
groupX: number;
/** Group y position on the page (fieldY from calculateFieldPosition). */
groupY: number;
/** Full page width in pixels. */
pageWidth: number;
/** Full page height in pixels. */
pageHeight: number;
};
type OverflowLayoutResult = {
x: number;
y: number;
width: number;
height: number;
wrap: 'word' | 'none';
textAlign: 'left' | 'center' | 'right';
verticalAlign: 'top' | 'middle' | 'bottom';
};
/**
* Calculate layout metrics for the text within the field.
*
* Returns:
* - exceedsWidth: whether the unwrapped text exceeds the field width
* - exceedsHeightWhenWrapped: whether wrapping the text at field width exceeds the field height
* - hasRoomForMoreThanOneLine: whether the field can fit 2+ lines of text
*/
const calculateLayout = (params: {
textToRender: string;
fontSize: number;
fontFamily: string;
lineHeight: number;
letterSpacing: number;
baseWidth: number;
baseHeight: number;
}): {
exceedsWidth: boolean;
exceedsHeightWhenWrapped: boolean;
hasRoomForMoreThanOneLine: boolean;
} => {
const { textToRender, fontSize, fontFamily, lineHeight, letterSpacing, baseWidth, baseHeight } =
params;
// Measure the text without width constraint to get natural width and single-line height.
const unwrappedNode = new Konva.Text({
text: textToRender,
fontSize,
fontFamily,
lineHeight,
letterSpacing,
});
const exceedsWidth = unwrappedNode.width() > baseWidth;
const oneLineHeight = unwrappedNode.height();
unwrappedNode.destroy();
const hasRoomForMoreThanOneLine = baseHeight >= oneLineHeight * 2;
// Measure the text wrapped at field width to check vertical overflow.
const wrappedNode = new Konva.Text({
text: textToRender,
fontSize,
fontFamily,
lineHeight,
letterSpacing,
width: baseWidth,
wrap: 'word',
});
const exceedsHeightWhenWrapped = wrappedNode.height() > baseHeight;
wrappedNode.destroy();
return { exceedsWidth, exceedsHeightWhenWrapped, hasRoomForMoreThanOneLine };
};
/**
* Calculate horizontal overflow layout based on text alignment.
*
* The text node is expanded beyond the field bounds toward the page edges.
* - left-aligned: extends rightward to page right edge
* - right-aligned: extends leftward to page left edge
* - center-aligned: extends symmetrically toward the closer page edge
*/
const calculateHorizontalOverflow = (params: OverflowLayoutParams): OverflowLayoutResult => {
const { textAlign, baseX, baseY, baseWidth, baseHeight, groupX, pageWidth } = params;
if (textAlign === 'right') {
// Extend leftward to page left edge.
// Right edge of text stays at (baseX + baseWidth) within the group.
const newX = -groupX;
const newWidth = groupX + baseX + baseWidth;
return {
x: newX,
y: baseY,
width: newWidth,
height: baseHeight,
wrap: 'none',
textAlign,
verticalAlign: params.verticalAlign,
};
}
if (textAlign === 'center') {
// Extend symmetrically from the text center toward the closer page edge.
const leftSpace = groupX + baseX;
const rightSpace = pageWidth - (groupX + baseX + baseWidth);
const maxExtend = Math.min(leftSpace, rightSpace);
const newX = baseX - maxExtend;
const newWidth = baseWidth + maxExtend * 2;
return {
x: newX,
y: baseY,
width: newWidth,
height: baseHeight,
wrap: 'none',
textAlign,
verticalAlign: params.verticalAlign,
};
}
// Default: left-aligned — extend rightward to page right edge.
const newWidth = pageWidth - groupX - baseX;
return {
x: baseX,
y: baseY,
width: newWidth,
height: baseHeight,
wrap: 'none',
textAlign,
verticalAlign: params.verticalAlign,
};
};
/**
* Calculate vertical overflow layout based on vertical alignment.
*
* The text node keeps the field width (text wraps) and expands height toward the page edges.
* - top aligned: extends downward to page bottom
* - bottom aligned: extends upward to page top
* - middle aligned: extends symmetrically up and down toward the closer page edge
*/
const calculateVerticalOverflow = (params: OverflowLayoutParams): OverflowLayoutResult => {
const { verticalAlign, textAlign, baseX, baseY, baseWidth, baseHeight, groupY, pageHeight } =
params;
if (verticalAlign === 'bottom') {
// Extend upward to page top edge.
// Bottom edge of text stays at (baseY + baseHeight) within the group.
const newY = -groupY;
const newHeight = groupY + baseY + baseHeight;
return {
x: baseX,
y: newY,
width: baseWidth,
height: newHeight,
wrap: 'word',
textAlign,
verticalAlign: 'bottom',
};
}
if (verticalAlign === 'middle') {
// Extend both up and down from the field center.
// Text stays vertically centered at the original field position.
const upSpace = groupY + baseY;
const downSpace = pageHeight - (groupY + baseY + baseHeight);
const maxExtend = Math.min(upSpace, downSpace);
const newY = baseY - maxExtend;
const newHeight = baseHeight + maxExtend * 2;
return {
x: baseX,
y: newY,
width: baseWidth,
height: newHeight,
wrap: 'word',
textAlign,
verticalAlign: 'middle',
};
}
// Default: top — extend downward to page bottom edge.
const newHeight = pageHeight - groupY - baseY;
return {
x: baseX,
y: baseY,
width: baseWidth,
height: newHeight,
wrap: 'word',
textAlign,
verticalAlign: 'top',
};
};
/**
* Calculate overflow-aware text layout dimensions.
*
* Returns { x, y, width, height, wrap } to spread into a Konva.Text setAttrs() call.
*
* For 'crop' mode or placeholder content, returns the original field bounds (current behavior).
* For 'horizontal'/'vertical'/'auto', expands the text node dimensions toward the page edges
* based on text alignment and field position.
*/
export const calculateOverflowLayout = (params: OverflowLayoutParams): OverflowLayoutResult => {
const { overflowMode, isLabel, baseX, baseY, baseWidth, baseHeight } = params;
// No overflow for placeholders or crop mode — return original field bounds.
if (isLabel || overflowMode === 'crop') {
return {
x: baseX,
y: baseY,
width: baseWidth,
height: baseHeight,
wrap: 'word',
textAlign: params.textAlign,
verticalAlign: params.verticalAlign,
};
}
if (overflowMode === 'horizontal') {
return calculateHorizontalOverflow(params);
}
if (overflowMode === 'vertical') {
return calculateVerticalOverflow(params);
}
// Auto mode: measure the text and field to decide overflow direction.
const layout = calculateLayout({
textToRender: params.textToRender,
fontSize: params.fontSize,
fontFamily: params.fontFamily,
lineHeight: params.lineHeight,
letterSpacing: params.letterSpacing,
baseWidth,
baseHeight,
});
// Auto single-line: overflow horizontal only when text exceeds field width.
// Center text align is overridden to left so it overflows right.
if (!layout.hasRoomForMoreThanOneLine) {
if (!layout.exceedsWidth) {
// Text fits — keep original alignment, no overflow needed.
return {
x: baseX,
y: baseY,
width: baseWidth,
height: baseHeight,
wrap: 'none',
textAlign: params.textAlign,
verticalAlign: params.verticalAlign,
};
}
return calculateHorizontalOverflow({
...params,
textAlign: params.textAlign === 'center' ? 'left' : params.textAlign,
});
}
// Auto multi-line: overflow vertical only when wrapped text exceeds field height.
// Middle vertical align is only overridden to top if the text actually overflows vertically.
// If it fits, keep middle so the text stays centered within the field.
if (!layout.exceedsHeightWhenWrapped) {
// Text fits when wrapped — keep original alignment, no overflow needed.
return {
x: baseX,
y: baseY,
width: baseWidth,
height: baseHeight,
wrap: 'word',
textAlign: params.textAlign,
verticalAlign: params.verticalAlign,
};
}
const verticalAlignOverride = params.verticalAlign === 'middle' ? 'top' : params.verticalAlign;
return calculateVerticalOverflow({
...params,
verticalAlign: verticalAlignOverride,
});
};
@@ -7,7 +7,9 @@ import {
FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN,
FIELD_DEFAULT_LETTER_SPACING,
FIELD_DEFAULT_LINE_HEIGHT,
resolveFieldOverflowMode,
} from '../../types/field-meta';
import { calculateOverflowLayout } from './calculate-overflow-layout';
import {
createFieldHoverInteraction,
konvaTextFill,
@@ -20,10 +22,14 @@ import { calculateFieldPosition } from './field-renderer';
const DEFAULT_TEXT_X_PADDING = 6;
const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOptions): Konva.Text => {
const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOptions) => {
const { pageWidth, pageHeight, mode = 'edit', pageLayer, translations } = options;
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
const { fieldX, fieldY, fieldWidth, fieldHeight } = calculateFieldPosition(
field,
pageWidth,
pageHeight,
);
const fieldMeta = field.fieldMeta as GenericTextFieldTypeMetas | undefined;
@@ -41,7 +47,10 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
const textY = 0;
const textFontSize = fieldMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE;
// By default, render the field name or label centered
// By default, render the field name or label centered.
// isLabel tracks whether we're rendering the field type name (like "Text", "Date", "Email")
// or a user label — overflow should not apply to these, only to actual content.
let isLabel = true;
let textToRender: string = fieldMeta?.label || fieldTypeName;
let textAlign: 'left' | 'center' | 'right' = 'center';
let textVerticalAlign: 'top' | 'middle' | 'bottom' = FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN;
@@ -53,6 +62,7 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
const value = fieldMeta?.type === 'text' ? fieldMeta.text : fieldMeta.value;
if (value) {
isLabel = false;
textToRender = value;
textVerticalAlign = fieldMeta.verticalAlign || FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN;
@@ -73,6 +83,7 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
const value = fieldMeta?.type === 'text' ? fieldMeta.text : fieldMeta.value;
if (value) {
isLabel = false;
textToRender = value;
textVerticalAlign = fieldMeta.verticalAlign || FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN;
@@ -84,6 +95,7 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
// Override everything with value if it's inserted.
if (field.inserted) {
isLabel = false;
textToRender = field.customText;
textAlign = fieldMeta?.textAlign || FIELD_DEFAULT_GENERIC_ALIGN;
@@ -95,25 +107,54 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
}
}
const overflowLayout = calculateOverflowLayout({
overflowMode: resolveFieldOverflowMode(fieldMeta),
isLabel,
textToRender,
fontSize: textFontSize,
fontFamily: konvaTextFontFamily,
lineHeight: textLineHeight,
letterSpacing: textLetterSpacing,
textAlign,
verticalAlign: textVerticalAlign,
baseX: textX + DEFAULT_TEXT_X_PADDING,
baseY: textY,
baseWidth: fieldWidth - DEFAULT_TEXT_X_PADDING * 2,
baseHeight: fieldHeight,
groupX: fieldX,
groupY: fieldY,
pageWidth,
pageHeight,
});
// Note: Do not use native text padding since it's uniform.
// We only want to have padding on the left and right hand sides.
fieldText.setAttrs({
x: textX + DEFAULT_TEXT_X_PADDING,
y: textY,
verticalAlign: textVerticalAlign,
wrap: 'word',
x: overflowLayout.x,
y: overflowLayout.y,
verticalAlign: overflowLayout.verticalAlign,
wrap: overflowLayout.wrap,
text: textToRender,
fontSize: textFontSize,
align: textAlign,
align: overflowLayout.textAlign,
lineHeight: textLineHeight,
letterSpacing: textLetterSpacing,
fontFamily: konvaTextFontFamily,
fill: konvaTextFill,
width: fieldWidth - DEFAULT_TEXT_X_PADDING * 2,
height: fieldHeight,
width: overflowLayout.width,
height: overflowLayout.height,
} satisfies Partial<Konva.TextConfig>);
return fieldText;
return {
fieldText,
isLabel,
textToRender,
textFontSize,
textAlign,
textVerticalAlign,
textLineHeight,
textLetterSpacing,
};
};
export const renderGenericTextFieldElement = (
@@ -121,6 +162,8 @@ export const renderGenericTextFieldElement = (
options: RenderFieldElementOptions,
) => {
const { mode = 'edit', pageLayer, color } = options;
const { pageWidth, pageHeight } = options;
const fieldMeta = field.fieldMeta as GenericTextFieldTypeMetas | undefined;
const isFirstRender = !pageLayer.findOne(`#${field.renderId}`);
@@ -136,13 +179,20 @@ export const renderGenericTextFieldElement = (
// Render the field background and text.
const fieldRect = upsertFieldRect(field, options);
const fieldText = upsertFieldText(field, options);
const {
fieldText,
isLabel,
textToRender,
textFontSize,
textAlign,
textVerticalAlign,
textLineHeight,
textLetterSpacing,
} = upsertFieldText(field, options);
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();
@@ -154,17 +204,16 @@ export const renderGenericTextFieldElement = (
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
// Update text dimensions
// During active transform, use crop dimensions (field bounds only).
fieldText.x(DEFAULT_TEXT_X_PADDING);
fieldText.y(0);
fieldText.width(rectWidth - DEFAULT_TEXT_X_PADDING * 2);
fieldText.height(rectHeight);
// Force Konva to recalculate text layout
fieldText.height();
fieldText.wrap('word');
fieldGroup.getLayer()?.batchDraw();
});
// Reset the text after transform has ended.
fieldGroup.on('transformend', () => {
fieldText.scaleX(1);
fieldText.scaleY(1);
@@ -172,12 +221,33 @@ export const renderGenericTextFieldElement = (
const rectWidth = fieldRect.width();
const rectHeight = fieldRect.height();
// Update text dimensions
fieldText.width(rectWidth - DEFAULT_TEXT_X_PADDING * 2);
fieldText.height(rectHeight);
// Recalculate overflow layout with new field dimensions.
const newOverflowLayout = calculateOverflowLayout({
overflowMode: resolveFieldOverflowMode(fieldMeta),
isLabel,
textToRender,
fontSize: textFontSize,
fontFamily: konvaTextFontFamily,
lineHeight: textLineHeight,
letterSpacing: textLetterSpacing,
textAlign,
verticalAlign: textVerticalAlign,
baseX: DEFAULT_TEXT_X_PADDING,
baseY: 0,
baseWidth: rectWidth - DEFAULT_TEXT_X_PADDING * 2,
baseHeight: rectHeight,
groupX: fieldGroup.x(),
groupY: fieldGroup.y(),
pageWidth,
pageHeight,
});
// Force Konva to recalculate text layout
fieldText.height();
fieldText.x(newOverflowLayout.x);
fieldText.y(newOverflowLayout.y);
fieldText.width(newOverflowLayout.width);
fieldText.height(newOverflowLayout.height);
fieldText.wrap(newOverflowLayout.wrap);
fieldText.verticalAlign(newOverflowLayout.verticalAlign);
fieldGroup.getLayer()?.batchDraw();
});
@@ -2,6 +2,9 @@ import Konva from 'konva';
import { DEFAULT_SIGNATURE_TEXT_FONT_SIZE } from '../../constants/pdf';
import { AppError } from '../../errors/app-error';
import type { TSignatureFieldMeta } from '../../types/field-meta';
import { resolveFieldOverflowMode } from '../../types/field-meta';
import { calculateOverflowLayout } from './calculate-overflow-layout';
import {
createFieldHoverInteraction,
upsertFieldGroup,
@@ -40,6 +43,18 @@ const getImageDimensions = (img: HTMLImageElement, fieldWidth: number, fieldHeig
};
};
type FieldSignature =
| {
node: Konva.Text;
isImageSignature: false;
isLabel: boolean;
}
| {
node: Konva.Image;
isImageSignature: true;
isLabel: boolean;
};
/**
* The pixel ratio used when caching the signature image as an offscreen bitmap.
*
@@ -108,10 +123,14 @@ const createSignatureImage = (
const createFieldSignature = (
field: FieldToRender,
options: RenderFieldElementOptions,
): Konva.Text | Konva.Image => {
): FieldSignature => {
const { pageWidth, pageHeight, mode = 'edit', translations } = options;
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
const { fieldX, fieldY, fieldWidth, fieldHeight } = calculateFieldPosition(
field,
pageWidth,
pageHeight,
);
const fontSize = field.fieldMeta?.fontSize || DEFAULT_SIGNATURE_TEXT_FONT_SIZE;
const fieldText = new Konva.Text({
@@ -140,7 +159,11 @@ const createFieldSignature = (
}
if (field.inserted && signature?.signatureImageAsBase64) {
return createSignatureImage(signature.signatureImageAsBase64, fieldWidth, fieldHeight);
return {
node: createSignatureImage(signature.signatureImageAsBase64, fieldWidth, fieldHeight),
isImageSignature: true,
isLabel: false,
};
}
}
@@ -157,31 +180,61 @@ const createFieldSignature = (
}
if (signature?.signatureImageAsBase64) {
return createSignatureImage(signature.signatureImageAsBase64, fieldWidth, fieldHeight);
return {
node: createSignatureImage(signature.signatureImageAsBase64, fieldWidth, fieldHeight),
isImageSignature: true,
isLabel: false,
};
}
}
fieldText.setAttrs({
x: textX,
y: textY,
const fieldMeta = field.fieldMeta as TSignatureFieldMeta | undefined;
// Whether we're rendering the field type name (like "Signature") vs actual signed content.
// Overflow should not apply to the label.
const isLabel = !signature?.typedSignature;
const overflowLayout = calculateOverflowLayout({
overflowMode: resolveFieldOverflowMode(fieldMeta),
isLabel,
textToRender,
fontSize,
fontFamily: 'Caveat, sans-serif',
lineHeight: 1,
letterSpacing: 0,
textAlign: 'center',
verticalAlign: 'middle',
wrap: 'char',
baseX: textX,
baseY: textY,
baseWidth: fieldWidth,
baseHeight: fieldHeight,
groupX: fieldX,
groupY: fieldY,
pageWidth,
pageHeight,
});
fieldText.setAttrs({
x: overflowLayout.x,
y: overflowLayout.y,
verticalAlign: overflowLayout.verticalAlign,
wrap: overflowLayout.wrap,
text: textToRender,
fontSize,
fontFamily: 'Caveat, sans-serif',
align: 'center',
width: fieldWidth,
height: fieldHeight,
align: overflowLayout.textAlign,
width: overflowLayout.width,
height: overflowLayout.height,
} satisfies Partial<Konva.TextConfig>);
return fieldText;
return { node: fieldText, isImageSignature: false, isLabel };
};
export const renderSignatureFieldElement = (
field: FieldToRender,
options: RenderFieldElementOptions,
) => {
const { mode = 'edit', pageLayer, color } = options;
const { mode = 'edit', pageLayer, pageWidth, pageHeight, color } = options;
const isFirstRender = !pageLayer.findOne(`#${field.renderId}`);
@@ -198,13 +251,11 @@ export const renderSignatureFieldElement = (
// Render the field background and text.
const fieldRect = upsertFieldRect(field, options);
const fieldSignature = createFieldSignature(field, options);
const { node: fieldSignature, isImageSignature, isLabel } = 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();
@@ -216,17 +267,19 @@ export const renderSignatureFieldElement = (
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
// Update text dimensions
// During active transform, use crop dimensions (field bounds only).
if (!isImageSignature) {
fieldSignature.x(0);
fieldSignature.y(0);
fieldSignature.wrap('word');
}
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);
@@ -234,12 +287,39 @@ export const renderSignatureFieldElement = (
const rectWidth = fieldRect.width();
const rectHeight = fieldRect.height();
// Update text dimensions
fieldSignature.width(rectWidth); // Account for padding
fieldSignature.height(rectHeight);
if (!isImageSignature) {
const fieldMeta = field.fieldMeta as TSignatureFieldMeta | undefined;
// Force Konva to recalculate text layout
fieldSignature.height();
const newOverflowLayout = calculateOverflowLayout({
overflowMode: resolveFieldOverflowMode(fieldMeta),
isLabel,
textToRender: fieldSignature.text(),
fontSize: fieldSignature.fontSize(),
fontFamily: 'Caveat, sans-serif',
lineHeight: 1,
letterSpacing: 0,
textAlign: 'center',
verticalAlign: 'middle',
baseX: 0,
baseY: 0,
baseWidth: rectWidth,
baseHeight: rectHeight,
groupX: fieldGroup.x(),
groupY: fieldGroup.y(),
pageWidth,
pageHeight,
});
fieldSignature.x(newOverflowLayout.x);
fieldSignature.y(newOverflowLayout.y);
fieldSignature.width(newOverflowLayout.width);
fieldSignature.height(newOverflowLayout.height);
fieldSignature.wrap(newOverflowLayout.wrap);
fieldSignature.verticalAlign(newOverflowLayout.verticalAlign);
} else {
fieldSignature.width(rectWidth);
fieldSignature.height(rectHeight);
}
fieldGroup.getLayer()?.batchDraw();
});