Files
documenso/packages/lib/universal/field-renderer/calculate-overflow-layout.ts
T
2026-05-08 15:14:27 +10:00

341 lines
9.5 KiB
TypeScript

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,
});
};