216 lines
5.9 KiB
TypeScript
216 lines
5.9 KiB
TypeScript
import { getFontFamily } from "@/pages/editor/utils/fontFamily";
|
|
|
|
/** CSS font-weight values for canvas/Konva text rendering. */
|
|
export const getCssFontWeight = (fontWeight?: string): number => {
|
|
if (!fontWeight || fontWeight === "normal") return 400;
|
|
if (fontWeight === "bold" || fontWeight === "bolder") return 700;
|
|
|
|
const n = parseInt(fontWeight, 10);
|
|
if (!Number.isNaN(n)) return n;
|
|
|
|
return 400;
|
|
};
|
|
|
|
/** Konva.Text uses `fontStyle: bold` (canvas keyword), not numeric weight. */
|
|
export const usesKonvaBoldStyle = (fontWeight?: string): boolean => {
|
|
if (!fontWeight || fontWeight === "normal") return false;
|
|
if (fontWeight === "bold" || fontWeight === "bolder") return true;
|
|
const n = parseInt(fontWeight, 10);
|
|
return !Number.isNaN(n) && n >= 600;
|
|
};
|
|
|
|
/** Canvas/CSS `font` string shared by editor measurement and viewer layout. */
|
|
export const buildCanvasFont = (
|
|
fontSize: number,
|
|
fontFamily?: string,
|
|
fontWeight?: string,
|
|
): string => {
|
|
const weight = getCssFontWeight(fontWeight);
|
|
const family = getFontFamily(fontFamily);
|
|
return `${weight} ${fontSize}px ${family}`;
|
|
};
|
|
|
|
/** Canvas font string that matches Konva.Text (`fontStyle: bold`). */
|
|
export const buildKonvaCanvasFont = (
|
|
fontSize: number,
|
|
fontFamily?: string,
|
|
fontWeight?: string,
|
|
): string => {
|
|
const family = getFontFamily(fontFamily);
|
|
const style = usesKonvaBoldStyle(fontWeight) ? "bold" : "normal";
|
|
return `${style} ${fontSize}px ${family}`;
|
|
};
|
|
|
|
const LINE_BREAK_RE = /[\r\n\u2028\u2029]/;
|
|
|
|
export const isSingleLineText = (text?: string): boolean =>
|
|
!text || !LINE_BREAK_RE.test(text);
|
|
|
|
/**
|
|
* Konva can wrap text inside a fixed width without inserting `\n`.
|
|
* Use stored height to detect multi-line layout.
|
|
*/
|
|
export const usesWrappedLayout = (
|
|
text: string | undefined,
|
|
height: number | undefined,
|
|
fontSize: number,
|
|
lineHeight = 1.2,
|
|
): boolean => {
|
|
if (!isSingleLineText(text)) return true;
|
|
const linePx = fontSize * lineHeight;
|
|
return (height ?? 0) > linePx * 1.35;
|
|
};
|
|
|
|
/** Extra px so HTML layout does not wrap tighter than Konva/canvas metrics. */
|
|
export const SINGLE_LINE_WIDTH_SLACK_PX = 12;
|
|
|
|
export type TextMeasureOptions = {
|
|
fontSize: number;
|
|
fontFamily?: string;
|
|
fontWeight?: string;
|
|
letterSpacing?: number;
|
|
lineHeight?: number;
|
|
};
|
|
|
|
const measureLineWidth = (
|
|
ctx: CanvasRenderingContext2D,
|
|
line: string,
|
|
letterSpacing: number,
|
|
): number => {
|
|
let lineWidth = ctx.measureText(line).width;
|
|
if (letterSpacing && line.length > 1) {
|
|
lineWidth += letterSpacing * (line.length - 1);
|
|
}
|
|
return lineWidth;
|
|
};
|
|
|
|
/** Measure text using CSS and Konva font strings; returns the wider result. */
|
|
export const measureTextBlock = (
|
|
text: string,
|
|
options: TextMeasureOptions,
|
|
): { width: number; height: number; lineCount: number } => {
|
|
if (!text) return { width: 0, height: 0, lineCount: 0 };
|
|
|
|
const canvas = document.createElement("canvas");
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) return { width: 0, height: 0, lineCount: 1 };
|
|
|
|
const {
|
|
fontSize,
|
|
fontFamily,
|
|
fontWeight,
|
|
letterSpacing = 0,
|
|
lineHeight = 1.2,
|
|
} = options;
|
|
|
|
const lines = text.split(LINE_BREAK_RE);
|
|
const fonts = [
|
|
buildCanvasFont(fontSize, fontFamily, fontWeight),
|
|
buildKonvaCanvasFont(fontSize, fontFamily, fontWeight),
|
|
];
|
|
|
|
let maxWidth = 0;
|
|
for (const font of fonts) {
|
|
ctx.font = font;
|
|
for (const line of lines) {
|
|
maxWidth = Math.max(maxWidth, measureLineWidth(ctx, line, letterSpacing));
|
|
}
|
|
}
|
|
|
|
return {
|
|
width: Math.ceil(maxWidth),
|
|
height: Math.ceil(lines.length * fontSize * lineHeight),
|
|
lineCount: lines.length,
|
|
};
|
|
};
|
|
|
|
/** Width used for layout: at least stored Konva width and canvas measurement. */
|
|
export const getEffectiveTextWidth = (
|
|
storedWidth: number | undefined,
|
|
text: string | undefined,
|
|
options: TextMeasureOptions,
|
|
singleLine: boolean,
|
|
): number | undefined => {
|
|
if (!text && !storedWidth) return undefined;
|
|
|
|
const measured = measureTextBlock(text || "", options).width;
|
|
const base = Math.max(storedWidth || 0, measured);
|
|
|
|
if (!singleLine) return base || undefined;
|
|
return Math.max(1, base + SINGLE_LINE_WIDTH_SLACK_PX);
|
|
};
|
|
|
|
/** Scaled width for viewer text boxes (includes slack to avoid tighter CSS wrapping than Konva). */
|
|
export const getScaledTextWidth = (
|
|
width: number | undefined,
|
|
scale: number,
|
|
): number | undefined => {
|
|
if (!width) return undefined;
|
|
return Math.ceil(width * scale) + Math.ceil(SINGLE_LINE_WIDTH_SLACK_PX * scale);
|
|
};
|
|
|
|
export type TextAlignValue = "left" | "center" | "right";
|
|
|
|
export type ViewerTextLayout = {
|
|
leftPx: number;
|
|
widthPx: number | undefined;
|
|
textAlign: TextAlignValue;
|
|
transform: string | undefined;
|
|
transformOrigin: "top right";
|
|
};
|
|
|
|
/**
|
|
* Viewer HTML layout matching Konva TextShape: (x, y) is the top-right anchor of the text box.
|
|
* Text alignment is applied inside a fixed-width box that extends left from the anchor.
|
|
*/
|
|
export function getViewerTextLayout(args: {
|
|
x: number;
|
|
width?: number;
|
|
text?: string;
|
|
textAlign?: TextAlignValue;
|
|
rotation?: number;
|
|
scale: number;
|
|
measureOpts: TextMeasureOptions;
|
|
wrapped: boolean;
|
|
}): ViewerTextLayout {
|
|
const {
|
|
x,
|
|
width: storedWidth,
|
|
text,
|
|
textAlign = "right",
|
|
rotation = 0,
|
|
scale,
|
|
measureOpts,
|
|
wrapped,
|
|
} = args;
|
|
|
|
const layoutWidth = getEffectiveTextWidth(
|
|
storedWidth,
|
|
text,
|
|
measureOpts,
|
|
!wrapped,
|
|
);
|
|
const widthPx = getScaledTextWidth(layoutWidth, scale);
|
|
const slackPx =
|
|
widthPx !== undefined ? Math.ceil(SINGLE_LINE_WIDTH_SLACK_PX * scale) : 0;
|
|
const rightPx = x * scale;
|
|
const boxWidthUnscaled = layoutWidth ?? storedWidth ?? 0;
|
|
|
|
let leftPx: number;
|
|
if (widthPx !== undefined && boxWidthUnscaled > 0) {
|
|
leftPx = Math.round(rightPx - boxWidthUnscaled * scale) - slackPx;
|
|
} else {
|
|
leftPx = Math.round(rightPx);
|
|
}
|
|
|
|
const rotationTransform = rotation ? `rotate(${rotation}deg)` : undefined;
|
|
|
|
return {
|
|
leftPx,
|
|
widthPx,
|
|
textAlign,
|
|
transform: rotationTransform,
|
|
transformOrigin: "top right",
|
|
};
|
|
}
|