From 45c7156f3fac5aa7e286891578cb5b9d883bc325 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Herrmann?= Date: Sun, 23 Aug 2026 20:50:35 +0200 Subject: [PATCH 1/8] Add fixed-size text box element --- packages/web/src/editor/canvas/canvas.tsx | 5 +- .../canvas/elements/text-box-element.tsx | 161 ++++++++++++++++++ packages/web/src/editor/dock/dock.tsx | 3 + .../web/src/editor/inspector/inspector.tsx | 6 +- packages/web/src/lib/keyboard.ts | 25 +++ 5 files changed, 198 insertions(+), 2 deletions(-) create mode 100644 packages/web/src/editor/canvas/elements/text-box-element.tsx diff --git a/packages/web/src/editor/canvas/canvas.tsx b/packages/web/src/editor/canvas/canvas.tsx index aed54ab..a793f46 100644 --- a/packages/web/src/editor/canvas/canvas.tsx +++ b/packages/web/src/editor/canvas/canvas.tsx @@ -9,6 +9,7 @@ import { getLabelSizes } from "../../label/label-sizes.ts"; import { ChevronDown } from "lucide-react"; import { LabelPaper } from "./label-paper.tsx"; import { TextElement } from "./elements/text-element.tsx"; +import { TextBoxElement } from "./elements/text-box-element.tsx"; import { RectElement } from "./elements/rect-element.tsx"; import { LineElement } from "./elements/line-element.tsx"; import { QrElement } from "./elements/qr-element.tsx"; @@ -135,7 +136,9 @@ function LabelSizeSelector({ function renderElement(el: BaseElement, isSelected: boolean) { switch (el.type) { case "text": - return ; + return (el.props as { fixedBox?: boolean }).fixedBox + ? + : ; case "rect": return ; case "line": diff --git a/packages/web/src/editor/canvas/elements/text-box-element.tsx b/packages/web/src/editor/canvas/elements/text-box-element.tsx new file mode 100644 index 0000000..4ebefc0 --- /dev/null +++ b/packages/web/src/editor/canvas/elements/text-box-element.tsx @@ -0,0 +1,161 @@ +import { useRef, useCallback } from "react"; +import { Text } from "react-konva"; +import type Konva from "konva"; +import type { BaseElement } from "../../../store/editor-store.ts"; +import { useEditorV2Store } from "../../../store/editor-store.ts"; +import { ElementWrapper } from "./element-wrapper.tsx"; + +interface Props { + element: BaseElement; + isSelected: boolean; +} + +export function TextBoxElement({ element, isSelected }: Props) { + const ref = useRef(null); + const updateElement = useEditorV2Store((s) => s.updateElement); + const selectOnly = useEditorV2Store((s) => s.selectOnly); + const editingTextId = useEditorV2Store((s) => s.editingTextId); + + const isEditing = editingTextId === element.id; + + const p = element.props as { + text?: string; + fontSize?: number; + fontFamily?: string; + fontWeight?: number; + letterSpacing?: number; + fill?: string; + align?: string; + italic?: boolean; + }; + + const fontStyle = + [p.italic ? "italic" : "", p.fontWeight && p.fontWeight >= 700 ? "bold" : ""] + .filter(Boolean) + .join(" ") || "normal"; + + const startEditing = useCallback(() => { + const node = ref.current; + if (!node) return; + + const stage = node.getStage(); + if (!stage) return; + + useEditorV2Store.setState({ editingTextId: element.id }); + + const absPos = node.getAbsolutePosition(); + const stageContainer = stage.container(); + const stageRect = stageContainer.getBoundingClientRect(); + const scale = node.getAbsoluteScale(); + + node.hide(); + node.getLayer()?.batchDraw(); + + const textarea = document.createElement("textarea"); + textarea.value = p.text || ""; + const borderWidth = 2; + textarea.style.position = "fixed"; + textarea.style.left = `${stageRect.left + absPos.x - borderWidth}px`; + textarea.style.top = `${stageRect.top + absPos.y - borderWidth}px`; + textarea.style.width = `${element.width * scale.x}px`; + textarea.style.height = `${element.height * scale.y}px`; + textarea.style.boxSizing = "content-box"; + textarea.style.fontSize = `${(p.fontSize || 18) * scale.y}px`; + textarea.style.fontFamily = `'${p.fontFamily || "Inter"}', sans-serif`; + textarea.style.fontWeight = fontStyle.includes("bold") ? "bold" : "normal"; + textarea.style.fontStyle = fontStyle.includes("italic") ? "italic" : "normal"; + textarea.style.letterSpacing = `${(p.letterSpacing || 0) * scale.x}px`; + textarea.style.color = p.fill || "#000000"; + textarea.style.textAlign = (p.align as string) || "left"; + textarea.style.border = "2px solid var(--color-accent)"; + textarea.style.borderRadius = "2px"; + textarea.style.background = "rgba(255,255,255,0.95)"; + textarea.style.outline = "none"; + textarea.style.padding = "0px"; + textarea.style.margin = "0px"; + textarea.style.resize = "none"; + textarea.style.overflow = "hidden"; + textarea.style.lineHeight = `${node.lineHeight()}`; + textarea.style.wordBreak = "break-word"; + textarea.style.whiteSpace = "pre-wrap"; + textarea.style.zIndex = "1000"; + textarea.style.transformOrigin = "left top"; + if (element.rotation) { + textarea.style.transform = `rotate(${element.rotation}deg)`; + } + + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + + const commit = () => { + const newText = textarea.value; + updateElement(element.id, { props: { text: newText } }); + useEditorV2Store.setState({ editingTextId: null }); + document.body.removeChild(textarea); + node.show(); + node.getLayer()?.batchDraw(); + }; + + textarea.addEventListener("blur", commit); + textarea.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + textarea.removeEventListener("blur", commit); + useEditorV2Store.setState({ editingTextId: null }); + document.body.removeChild(textarea); + node.show(); + node.getLayer()?.batchDraw(); + } + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + textarea.blur(); + } + }); + }, [element, p, fontStyle, updateElement]); + + return ( + <> + selectOnly([element.id])} + onTap={() => selectOnly([element.id])} + onDblClick={startEditing} + onDblTap={startEditing} + onDragEnd={(e) => { + updateElement(element.id, { x: e.target.x(), y: e.target.y() }); + }} + onTransformEnd={() => { + const node = ref.current; + if (!node) return; + const scaleX = node.scaleX(); + const scaleY = node.scaleY(); + node.scaleX(1); + node.scaleY(1); + updateElement(element.id, { + x: node.x(), + y: node.y(), + width: Math.max(5, element.width * scaleX), + height: Math.max(5, element.height * scaleY), + rotation: node.rotation(), + }); + }} + /> + {!isEditing && } + + ); +} diff --git a/packages/web/src/editor/dock/dock.tsx b/packages/web/src/editor/dock/dock.tsx index cb83c72..e2516dc 100644 --- a/packages/web/src/editor/dock/dock.tsx +++ b/packages/web/src/editor/dock/dock.tsx @@ -17,6 +17,7 @@ import { DockDivider } from "./dock-divider.tsx"; import { Kbd } from "./kbd.tsx"; import { addTextEl, + addTextBoxEl, addQrEl, addBarcodeEl, addImageEl, @@ -73,6 +74,7 @@ export function Dock() {
{[ { icon: Type, label: "Text", fn: addTextEl }, + { icon: Type, label: "Text Box", fn: addTextBoxEl }, { icon: QrCode, label: "QR Code", fn: addQrEl }, { icon: Barcode, label: "Barcode", fn: addBarcodeEl }, { icon: ImageIcon, label: "Image", fn: addImageEl }, @@ -108,6 +110,7 @@ export function Dock() {
+ diff --git a/packages/web/src/editor/inspector/inspector.tsx b/packages/web/src/editor/inspector/inspector.tsx index 7575f3f..6eba506 100644 --- a/packages/web/src/editor/inspector/inspector.tsx +++ b/packages/web/src/editor/inspector/inspector.tsx @@ -50,7 +50,11 @@ export function Inspector() { const el: BaseElement | null = selected.length === 1 ? selected[0] : null; const TypeIcon = el ? TYPE_ICONS[el.type] : null; - const typeLabel = el ? TYPE_LABELS[el.type] : null; + const typeLabel = el + ? el.type === "text" && (el.props as { fixedBox?: boolean }).fixedBox + ? "Text Box" + : TYPE_LABELS[el.type] + : null; return (
Date: Sun, 23 Aug 2026 22:03:17 +0200 Subject: [PATCH 2/8] Add fixed text box layout controls --- .../canvas/elements/text-box-element.tsx | 4 +- .../inspector/sections/text-section.tsx | 51 +++++++++++++++++++ packages/web/src/lib/keyboard.ts | 9 ++-- 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/packages/web/src/editor/canvas/elements/text-box-element.tsx b/packages/web/src/editor/canvas/elements/text-box-element.tsx index 4ebefc0..b06c3a8 100644 --- a/packages/web/src/editor/canvas/elements/text-box-element.tsx +++ b/packages/web/src/editor/canvas/elements/text-box-element.tsx @@ -26,6 +26,7 @@ export function TextBoxElement({ element, isSelected }: Props) { letterSpacing?: number; fill?: string; align?: string; + verticalAlign?: string; italic?: boolean; }; @@ -129,7 +130,8 @@ export function TextBoxElement({ element, isSelected }: Props) { fontStyle={fontStyle} letterSpacing={p.letterSpacing || 0} fill={p.fill || "#000000"} - align={(p.align as "left" | "center" | "right") || "left"} + align={(p.align as "left" | "center" | "right") || "center"} + verticalAlign={(p.verticalAlign as "top" | "middle" | "bottom") || "middle"} wrap="word" draggable={!isEditing} onClick={() => selectOnly([element.id])} diff --git a/packages/web/src/editor/inspector/sections/text-section.tsx b/packages/web/src/editor/inspector/sections/text-section.tsx index 77631ae..23c0be9 100644 --- a/packages/web/src/editor/inspector/sections/text-section.tsx +++ b/packages/web/src/editor/inspector/sections/text-section.tsx @@ -4,6 +4,9 @@ import { AlignLeft, AlignCenter, AlignRight, + AlignVerticalJustifyStart, + AlignVerticalJustifyCenter, + AlignVerticalJustifyEnd, } from "lucide-react"; import type { BaseElement } from "../../../store/editor-store.ts"; import { useEditorV2Store } from "../../../store/editor-store.ts"; @@ -33,6 +36,8 @@ export function TextSection({ element }: Props) { letterSpacing?: number; fill?: string; align?: string; + verticalAlign?: string; + fixedBox?: boolean; italic?: boolean; }; @@ -99,23 +104,69 @@ export function TextSection({ element }: Props) { update({ align: "left" })} + title="Align left" > update({ align: "center" })} + title="Center horizontally" > update({ align: "right" })} + title="Align right" > + {p.fixedBox && ( + + update({ verticalAlign: "top" })} + title="Align top" + > + + + update({ verticalAlign: "middle" })} + title="Center vertically" + > + + + update({ verticalAlign: "bottom" })} + title="Align bottom" + > + + + + )}
+ {p.fixedBox && ( + + )}
Date: Mon, 24 Aug 2026 07:22:03 +0000 Subject: [PATCH 3/8] Add text box margin controls --- .../inspector/sections/text-section.tsx | 84 +++++++++++++++---- packages/web/src/lib/keyboard.ts | 4 +- 2 files changed, 70 insertions(+), 18 deletions(-) diff --git a/packages/web/src/editor/inspector/sections/text-section.tsx b/packages/web/src/editor/inspector/sections/text-section.tsx index 23c0be9..b0cd958 100644 --- a/packages/web/src/editor/inspector/sections/text-section.tsx +++ b/packages/web/src/editor/inspector/sections/text-section.tsx @@ -150,22 +150,74 @@ export function TextSection({ element }: Props) { )}
{p.fixedBox && ( - +
+ +
+
+ { + const h = Math.max(0, Math.round(v)); + const vMargin = p.marginV ?? 4; + const { label } = useEditorV2Store.getState(); + updateElement(element.id, { + x: h, + y: vMargin, + width: Math.max(5, label.widthPx - 2 * h), + height: Math.max(5, label.heightPx - 2 * vMargin), + rotation: 0, + props: { marginH: h }, + }); + }} + suffix="px H" + /> + { + const vMargin = Math.max(0, Math.round(v)); + const h = p.marginH ?? 4; + const { label } = useEditorV2Store.getState(); + updateElement(element.id, { + x: h, + y: vMargin, + width: Math.max(5, label.widthPx - 2 * h), + height: Math.max(5, label.heightPx - 2 * vMargin), + rotation: 0, + props: { marginV: vMargin }, + }); + }} + suffix="px V" + /> +
+
+ {[ + { label: "0:0", h: 0, v: 0 }, + { label: "4:4", h: 4, v: 4 }, + { label: "8:4", h: 8, v: 4 }, + ].map((preset) => ( + + ))} +
+
+
+
)}
diff --git a/packages/web/src/lib/keyboard.ts b/packages/web/src/lib/keyboard.ts index 17f8c24..62f7d3f 100644 --- a/packages/web/src/lib/keyboard.ts +++ b/packages/web/src/lib/keyboard.ts @@ -16,9 +16,9 @@ function addTextBoxEl() { addElement({ id: uid(), type: "text", - x: 8, + x: 4, y: 4, - width: Math.max(5, label.widthPx - 16), + width: Math.max(5, label.widthPx - 8), height: Math.max(5, label.heightPx - 8), rotation: 0, props: { From 3809210f5f1de7102d7cd8f094a12107d7af2134 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Herrmann?= Date: Mon, 24 Aug 2026 12:42:30 +0200 Subject: [PATCH 4/8] Fix margin types for fixed text box --- packages/web/src/editor/inspector/sections/text-section.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/web/src/editor/inspector/sections/text-section.tsx b/packages/web/src/editor/inspector/sections/text-section.tsx index b0cd958..c7e0f11 100644 --- a/packages/web/src/editor/inspector/sections/text-section.tsx +++ b/packages/web/src/editor/inspector/sections/text-section.tsx @@ -37,6 +37,8 @@ export function TextSection({ element }: Props) { fill?: string; align?: string; verticalAlign?: string; + marginH?: number; + marginV?: number; fixedBox?: boolean; italic?: boolean; }; From 0b73bc2cefad00d910dfed36c51c3abe9fc62086 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Herrmann?= Date: Mon, 24 Aug 2026 12:42:52 +0200 Subject: [PATCH 5/8] Document fixed text box --- docs/fixed-text-box.md | 46 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 docs/fixed-text-box.md diff --git a/docs/fixed-text-box.md b/docs/fixed-text-box.md new file mode 100644 index 0000000..81ba686 --- /dev/null +++ b/docs/fixed-text-box.md @@ -0,0 +1,46 @@ +# Fixed Text Box + +The fixed Text Box is an additional editor element. The existing `Text` element keeps its original behavior and is not changed by this feature. + +A fixed Text Box is stored as a text element with: + + props.fixedBox = true + +Unlike the original text element, its `width` and `height` are independent of the text content. Editing the text therefore does not resize the box. + +## Alignment + +Horizontal alignment: + +- left +- center +- right + +Vertical alignment: + +- top +- middle +- bottom + +New Text Boxes are centered horizontally and vertically. + +## Margin + +The box can be positioned relative to the complete label. The inspector provides editable horizontal and vertical values: + + Hor [4] Vert [4] + +The preset buttons are: + + 0:0 4:4 8:4 + +The notation is `horizontal:vertical`, measured in pixels. The default for a newly created Text Box is `4:4`. + +Applying a margin sets: + + x = horizontal margin + y = vertical margin + width = label width - 2 * horizontal margin + height = label height - 2 * vertical margin + +Afterwards the box remains freely resizable. The margin controls are presets/actions, not a permanent constraint. From d0134dfc0a45489b4955b5f0092f48aaee78a725 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Herrmann?= Date: Tue, 25 Aug 2026 12:04:48 +0200 Subject: [PATCH 6/8] Add auto-fit to fixed text boxes --- .../canvas/elements/text-box-element.tsx | 531 +++++++++++++++++- .../inspector/sections/text-section.tsx | 38 ++ packages/web/src/lib/keyboard.ts | 9 +- 3 files changed, 558 insertions(+), 20 deletions(-) diff --git a/packages/web/src/editor/canvas/elements/text-box-element.tsx b/packages/web/src/editor/canvas/elements/text-box-element.tsx index b06c3a8..5ae4b35 100644 --- a/packages/web/src/editor/canvas/elements/text-box-element.tsx +++ b/packages/web/src/editor/canvas/elements/text-box-element.tsx @@ -1,10 +1,319 @@ -import { useRef, useCallback } from "react"; +import { useRef, useCallback, useEffect } from "react"; import { Text } from "react-konva"; import type Konva from "konva"; import type { BaseElement } from "../../../store/editor-store.ts"; import { useEditorV2Store } from "../../../store/editor-store.ts"; import { ElementWrapper } from "./element-wrapper.tsx"; + +function measureWithLetterSpacing( + ctx: CanvasRenderingContext2D, + text: string, + letterSpacing: number, +): number { + const measured = ctx.measureText(text).width; + return measured + Math.max(0, text.length - 1) * letterSpacing; +} + +function measureTextBlock( + text: string, + fontSize: number, + fontFamily: string, + fontWeight: number, + italic: boolean, + letterSpacing: number, + maxWidth: number, +): { width: number; height: number; lineCount: number } { + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + if (!ctx) return { width: Infinity, height: Infinity, lineCount: 1 }; + + ctx.font = `${italic ? "italic " : ""}${fontWeight >= 700 ? "bold " : ""}${fontSize}px "${fontFamily}", sans-serif`; + + const rawLines = (text || "Text").split("\n"); + const lines: string[] = []; + + for (const rawLine of rawLines) { + if (!rawLine) { + lines.push(""); + continue; + } + + const words = rawLine.split(/(\s+)/); + let line = ""; + + for (const part of words) { + const candidate = line + part; + if (!line || measureWithLetterSpacing(ctx, candidate, letterSpacing) <= maxWidth) { + line = candidate; + continue; + } + + lines.push(line.trimEnd()); + line = part.trimStart(); + + if (measureWithLetterSpacing(ctx, line, letterSpacing) > maxWidth) { + let chunk = ""; + for (const ch of line) { + const candidateChunk = chunk + ch; + if (chunk && measureWithLetterSpacing(ctx, candidateChunk, letterSpacing) > maxWidth) { + lines.push(chunk); + chunk = ch; + } else { + chunk = candidateChunk; + } + } + line = chunk; + } + } + + lines.push(line.trimEnd()); + } + + let width = 0; + for (const line of lines) { + width = Math.max(width, measureWithLetterSpacing(ctx, line, letterSpacing)); + } + + return { + width, + height: Math.max(1, lines.length) * fontSize, + lineCount: Math.max(1, lines.length), + }; +} + +function fontFits( + text: string, + fontSize: number, + boxWidth: number, + boxHeight: number, + fontFamily: string, + fontWeight: number, + italic: boolean, + letterSpacing: number, + maxLines?: number, +): boolean { + const measured = measureTextBlock( + text, + fontSize, + fontFamily, + fontWeight, + italic, + letterSpacing, + boxWidth, + ); + return measured.height <= boxHeight && + (maxLines === undefined || measured.lineCount <= maxLines); +} + +function largestFontThatFits( + text: string, + maxFontSize: number, + boxWidth: number, + boxHeight: number, + fontFamily: string, + fontWeight: number, + italic: boolean, + letterSpacing: number, + maxLines?: number, +): number { + let low = 4; + let high = Math.max(4, Math.floor(maxFontSize)); + let best = 4; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + if (fontFits( + text, mid, boxWidth, boxHeight, fontFamily, fontWeight, + italic, letterSpacing, maxLines, + )) { + best = mid; + low = mid + 1; + } else { + high = mid - 1; + } + } + + return best; +} + +interface FitState { + fontSize: number; + actTries: number; + lineCount: number; +} + +function replayTypingState( + text: string, + maxFontSize: number, + boxWidth: number, + boxHeight: number, + fontFamily: string, + fontWeight: number, + italic: boolean, + letterSpacing: number, + step: number, + tries: number, +): FitState { + let fontSize = Math.max(4, Math.floor(maxFontSize)); + let actTries = 0; + let previousLineCount = 1; + const decrement = Math.max(1, Math.round(step)); + let prefix = ""; + + for (const ch of text || "Text") { + prefix += ch; + let measured = measureTextBlock( + prefix, fontSize, fontFamily, fontWeight, italic, letterSpacing, boxWidth, + ); + + if (measured.lineCount > previousLineCount) { + while (measured.lineCount > previousLineCount && actTries < tries && fontSize > 4) { + const candidate = Math.max(4, fontSize - decrement); + if (candidate === fontSize) break; + fontSize = candidate; + actTries += 1; + measured = measureTextBlock( + prefix, fontSize, fontFamily, fontWeight, italic, letterSpacing, boxWidth, + ); + } + + if (measured.lineCount > previousLineCount) { + const allowedLines = previousLineCount + 1; + const refit = largestFontThatFits( + prefix, + Math.max(fontSize, 4), + boxWidth, + boxHeight, + fontFamily, + fontWeight, + italic, + letterSpacing, + allowedLines, + ); + fontSize = Math.max(4, refit); + actTries = 0; + measured = measureTextBlock( + prefix, fontSize, fontFamily, fontWeight, italic, letterSpacing, boxWidth, + ); + } + } + + previousLineCount = measured.lineCount; + } + + while (!fontFits( + text || "Text", + fontSize, + boxWidth, + boxHeight, + fontFamily, + fontWeight, + italic, + letterSpacing, + ) && fontSize > 4) { + fontSize -= 1; + actTries = 0; + } + + const finalMeasured = measureTextBlock( + text || "Text", fontSize, fontFamily, fontWeight, italic, letterSpacing, boxWidth, + ); + + return { fontSize, actTries, lineCount: finalMeasured.lineCount }; +} + +function advanceTypingState( + previousText: string, + nextText: string, + state: FitState, + boxWidth: number, + boxHeight: number, + fontFamily: string, + fontWeight: number, + italic: boolean, + letterSpacing: number, + step: number, + tries: number, +): FitState { + const decrement = Math.max(1, Math.round(step)); + let fontSize = state.fontSize; + let actTries = state.actTries; + const before = measureTextBlock( + previousText || "Text", fontSize, fontFamily, fontWeight, italic, letterSpacing, boxWidth, + ); + let measured = measureTextBlock( + nextText || "Text", fontSize, fontFamily, fontWeight, italic, letterSpacing, boxWidth, + ); + + if (measured.lineCount > before.lineCount) { + while (measured.lineCount > before.lineCount && actTries < tries && fontSize > 4) { + fontSize = Math.max(4, fontSize - decrement); + actTries += 1; + measured = measureTextBlock( + nextText || "Text", fontSize, fontFamily, fontWeight, italic, letterSpacing, boxWidth, + ); + } + + if (measured.lineCount > before.lineCount) { + const refit = largestFontThatFits( + nextText || "Text", + Math.max(fontSize, 4), + boxWidth, + boxHeight, + fontFamily, + fontWeight, + italic, + letterSpacing, + before.lineCount + 1, + ); + fontSize = Math.max(4, refit); + actTries = 0; + measured = measureTextBlock( + nextText || "Text", fontSize, fontFamily, fontWeight, italic, letterSpacing, boxWidth, + ); + } + } + + if (measured.height > boxHeight) { + fontSize = largestFontThatFits( + nextText || "Text", + fontSize, + boxWidth, + boxHeight, + fontFamily, + fontWeight, + italic, + letterSpacing, + ); + actTries = 0; + measured = measureTextBlock( + nextText || "Text", fontSize, fontFamily, fontWeight, italic, letterSpacing, boxWidth, + ); + } + + return { fontSize, actTries, lineCount: measured.lineCount }; +} + +function lineHeightFor( + text: string, + fontSize: number, + boxHeight: number, + boxWidth: number, + fontFamily: string, + fontWeight: number, + italic: boolean, + letterSpacing: number, +): number { + const measured = measureTextBlock( + text || "Text", fontSize, fontFamily, fontWeight, italic, letterSpacing, boxWidth, + ); + if (measured.lineCount <= 1) return 1; + const spare = Math.max(0, boxHeight - measured.lineCount * fontSize); + const gap = spare / (measured.lineCount + 3); + return 1 + gap / fontSize; +} + interface Props { element: BaseElement; isSelected: boolean; @@ -27,6 +336,11 @@ export function TextBoxElement({ element, isSelected }: Props) { fill?: string; align?: string; verticalAlign?: string; + autoFit?: boolean; + fitStep?: number; + fitTries?: number; + actSize?: number; + actTries?: number; italic?: boolean; }; @@ -35,37 +349,136 @@ export function TextBoxElement({ element, isSelected }: Props) { .filter(Boolean) .join(" ") || "normal"; + const configuredFontSize = p.fontSize || 48; + const fontFamily = p.fontFamily || "Inter"; + const fontWeight = p.fontWeight || 400; + const italic = !!p.italic; + const letterSpacing = p.letterSpacing || 0; + const effectiveFontSize = p.autoFit ? (p.actSize ?? configuredFontSize) : configuredFontSize; + const effectiveLineHeight = p.autoFit + ? lineHeightFor( + p.text || "Text", + effectiveFontSize, + Math.max(1, element.height), + Math.max(1, element.width), + fontFamily, + fontWeight, + italic, + letterSpacing, + ) + : 1; + + const editFitStateRef = useRef({ + fontSize: p.actSize ?? configuredFontSize, + actTries: p.actTries ?? 0, + lineCount: 1, + }); + const editTextRef = useRef(p.text || "Text"); + + + /* Reflow immediately when a formatting input changes. */ + const fitConfigRef = useRef(null); + useEffect(() => { + const configKey = JSON.stringify([ + p.autoFit ?? false, + configuredFontSize, + p.fitStep ?? 1, + p.fitTries ?? 0, + element.width, + element.height, + p.fontFamily || "Inter", + p.fontWeight || 400, + !!p.italic, + p.letterSpacing || 0, + ]); + + if (fitConfigRef.current === null) { + fitConfigRef.current = configKey; + return; + } + if (fitConfigRef.current === configKey) return; + fitConfigRef.current = configKey; + + if (!p.autoFit) return; + + const replay = replayTypingState( + p.text || "Text", + configuredFontSize, + Math.max(1, element.width), + Math.max(1, element.height), + p.fontFamily || "Inter", + p.fontWeight || 400, + !!p.italic, + p.letterSpacing || 0, + p.fitStep ?? 1, + p.fitTries ?? 0, + ); + + if (p.actSize !== replay.fontSize || p.actTries !== replay.actTries) { + updateElement(element.id, { + props: { actSize: replay.fontSize, actTries: replay.actTries }, + }); + } + }, [ + p.autoFit, + configuredFontSize, + p.fitStep, + p.fitTries, + element.width, + element.height, + p.fontFamily, + p.fontWeight, + p.italic, + p.letterSpacing, + element.id, + updateElement, + ]); + const startEditing = useCallback(() => { const node = ref.current; if (!node) return; - const stage = node.getStage(); - if (!stage) return; - useEditorV2Store.setState({ editingTextId: element.id }); const absPos = node.getAbsolutePosition(); - const stageContainer = stage.container(); - const stageRect = stageContainer.getBoundingClientRect(); + const stage = node.getStage(); + if (!stage) return; + const stageRect = stage.container().getBoundingClientRect(); const scale = node.getAbsoluteScale(); + const initialText = p.text || "Text"; + editFitStateRef.current = { + fontSize: p.autoFit ? (p.actSize ?? configuredFontSize) : configuredFontSize, + actTries: p.autoFit ? (p.actTries ?? 0) : 0, + lineCount: measureTextBlock( + initialText, + p.autoFit ? (p.actSize ?? configuredFontSize) : configuredFontSize, + fontFamily, + fontWeight, + italic, + letterSpacing, + Math.max(1, element.width), + ).lineCount, + }; + editTextRef.current = initialText; + + const borderWidth = 2; node.hide(); node.getLayer()?.batchDraw(); const textarea = document.createElement("textarea"); - textarea.value = p.text || ""; - const borderWidth = 2; + textarea.value = initialText; textarea.style.position = "fixed"; textarea.style.left = `${stageRect.left + absPos.x - borderWidth}px`; textarea.style.top = `${stageRect.top + absPos.y - borderWidth}px`; textarea.style.width = `${element.width * scale.x}px`; textarea.style.height = `${element.height * scale.y}px`; textarea.style.boxSizing = "content-box"; - textarea.style.fontSize = `${(p.fontSize || 18) * scale.y}px`; - textarea.style.fontFamily = `'${p.fontFamily || "Inter"}', sans-serif`; + textarea.style.fontSize = `${editFitStateRef.current.fontSize * scale.y}px`; + textarea.style.fontFamily = `'${fontFamily}', sans-serif`; textarea.style.fontWeight = fontStyle.includes("bold") ? "bold" : "normal"; textarea.style.fontStyle = fontStyle.includes("italic") ? "italic" : "normal"; - textarea.style.letterSpacing = `${(p.letterSpacing || 0) * scale.x}px`; + textarea.style.letterSpacing = `${letterSpacing * scale.x}px`; textarea.style.color = p.fill || "#000000"; textarea.style.textAlign = (p.align as string) || "left"; textarea.style.border = "2px solid var(--color-accent)"; @@ -76,28 +489,95 @@ export function TextBoxElement({ element, isSelected }: Props) { textarea.style.margin = "0px"; textarea.style.resize = "none"; textarea.style.overflow = "hidden"; - textarea.style.lineHeight = `${node.lineHeight()}`; + textarea.style.lineHeight = `${effectiveLineHeight}`; textarea.style.wordBreak = "break-word"; textarea.style.whiteSpace = "pre-wrap"; textarea.style.zIndex = "1000"; textarea.style.transformOrigin = "left top"; - if (element.rotation) { - textarea.style.transform = `rotate(${element.rotation}deg)`; - } + if (element.rotation) textarea.style.transform = `rotate(${element.rotation}deg)`; document.body.appendChild(textarea); textarea.focus(); textarea.select(); const commit = () => { - const newText = textarea.value; - updateElement(element.id, { props: { text: newText } }); + updateElement(element.id, { + props: { + text: textarea.value, + actSize: editFitStateRef.current.fontSize, + actTries: editFitStateRef.current.actTries, + }, + }); useEditorV2Store.setState({ editingTextId: null }); document.body.removeChild(textarea); node.show(); node.getLayer()?.batchDraw(); }; + const refreshFit = () => { + if (!p.autoFit) { + updateElement(element.id, { props: { text: textarea.value } }); + editTextRef.current = textarea.value; + return; + } + + const nextText = textarea.value; + const previousText = editTextRef.current; + const isSimpleAppend = + nextText.length === previousText.length + 1 && nextText.startsWith(previousText); + + const nextState = isSimpleAppend + ? advanceTypingState( + previousText, + nextText, + editFitStateRef.current, + Math.max(1, element.width), + Math.max(1, element.height), + fontFamily, + fontWeight, + italic, + letterSpacing, + p.fitStep ?? 1, + p.fitTries ?? 0, + ) + : replayTypingState( + nextText, + configuredFontSize, + Math.max(1, element.width), + Math.max(1, element.height), + fontFamily, + fontWeight, + italic, + letterSpacing, + p.fitStep ?? 1, + p.fitTries ?? 0, + ); + + editFitStateRef.current = nextState; + editTextRef.current = nextText; + + const lh = lineHeightFor( + nextText, + nextState.fontSize, + Math.max(1, element.height), + Math.max(1, element.width), + fontFamily, + fontWeight, + italic, + letterSpacing, + ); + textarea.style.fontSize = `${nextState.fontSize * scale.y}px`; + textarea.style.lineHeight = `${lh}`; + updateElement(element.id, { + props: { + text: nextText, + actSize: nextState.fontSize, + actTries: nextState.actTries, + }, + }); + }; + + textarea.addEventListener("input", refreshFit); textarea.addEventListener("blur", commit); textarea.addEventListener("keydown", (e) => { if (e.key === "Escape") { @@ -112,7 +592,19 @@ export function TextBoxElement({ element, isSelected }: Props) { textarea.blur(); } }); - }, [element, p, fontStyle, updateElement]); + }, [ + configuredFontSize, + element, + fontFamily, + fontStyle, + fontWeight, + italic, + letterSpacing, + p, + updateElement, + effectiveLineHeight, + ]); + return ( <> @@ -125,7 +617,8 @@ export function TextBoxElement({ element, isSelected }: Props) { height={element.height} rotation={element.rotation} text={p.text || "Text"} - fontSize={p.fontSize || 18} + fontSize={effectiveFontSize} + lineHeight={effectiveLineHeight} fontFamily={p.fontFamily || "Inter"} fontStyle={fontStyle} letterSpacing={p.letterSpacing || 0} diff --git a/packages/web/src/editor/inspector/sections/text-section.tsx b/packages/web/src/editor/inspector/sections/text-section.tsx index c7e0f11..22ef823 100644 --- a/packages/web/src/editor/inspector/sections/text-section.tsx +++ b/packages/web/src/editor/inspector/sections/text-section.tsx @@ -40,6 +40,11 @@ export function TextSection({ element }: Props) { marginH?: number; marginV?: number; fixedBox?: boolean; + autoFit?: boolean; + fitStep?: number; + fitTries?: number; + actSize?: number; + actTries?: number; italic?: boolean; }; @@ -221,6 +226,39 @@ export function TextSection({ element }: Props) {
)} + {p.fixedBox && ( +
+ +
+ {Math.round(p.actSize ?? p.fontSize ?? 48)} px +
+
+ + + update({ autoFit: !p.autoFit })} + title="Fit text to the fixed box" + > + Fit + + + + + update({ fitStep: Math.max(1, Math.round(v)) })} + suffix="px" + /> + + + update({ fitTries: Math.max(0, Math.round(v)) })} + /> + +
+ )}
Date: Tue, 25 Aug 2026 23:38:40 +0200 Subject: [PATCH 7/8] Add configurable line height to text box auto-fit --- .../canvas/elements/text-box-element.tsx | 99 +++++++++---------- packages/web/src/editor/inspector/fields.tsx | 4 +- .../inspector/sections/text-section.tsx | 12 +++ packages/web/src/lib/keyboard.ts | 1 + 4 files changed, 62 insertions(+), 54 deletions(-) diff --git a/packages/web/src/editor/canvas/elements/text-box-element.tsx b/packages/web/src/editor/canvas/elements/text-box-element.tsx index 5ae4b35..9062c07 100644 --- a/packages/web/src/editor/canvas/elements/text-box-element.tsx +++ b/packages/web/src/editor/canvas/elements/text-box-element.tsx @@ -1,6 +1,6 @@ import { useRef, useCallback, useEffect } from "react"; import { Text } from "react-konva"; -import type Konva from "konva"; +import Konva from "konva"; import type { BaseElement } from "../../../store/editor-store.ts"; import { useEditorV2Store } from "../../../store/editor-store.ts"; import { ElementWrapper } from "./element-wrapper.tsx"; @@ -23,6 +23,7 @@ function measureTextBlock( italic: boolean, letterSpacing: number, maxWidth: number, + lineHeight = 1, ): { width: number; height: number; lineCount: number } { const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d"); @@ -75,9 +76,23 @@ function measureTextBlock( width = Math.max(width, measureWithLetterSpacing(ctx, line, letterSpacing)); } + const measuredText = new Konva.Text({ + text: lines.join("\n"), + width: maxWidth, + fontSize, + fontFamily, + fontStyle: `${italic ? "italic " : ""}${fontWeight >= 700 ? "bold" : ""}`.trim() || "normal", + letterSpacing, + lineHeight, + wrap: "word", + padding: 0, + }); + + const konvaHeight = measuredText.height(); + return { width, - height: Math.max(1, lines.length) * fontSize, + height: konvaHeight, lineCount: Math.max(1, lines.length), }; } @@ -91,6 +106,7 @@ function fontFits( fontWeight: number, italic: boolean, letterSpacing: number, + lineHeight: number, maxLines?: number, ): boolean { const measured = measureTextBlock( @@ -101,6 +117,7 @@ function fontFits( italic, letterSpacing, boxWidth, + lineHeight, ); return measured.height <= boxHeight && (maxLines === undefined || measured.lineCount <= maxLines); @@ -115,6 +132,7 @@ function largestFontThatFits( fontWeight: number, italic: boolean, letterSpacing: number, + lineHeight: number, maxLines?: number, ): number { let low = 4; @@ -125,7 +143,7 @@ function largestFontThatFits( const mid = Math.floor((low + high) / 2); if (fontFits( text, mid, boxWidth, boxHeight, fontFamily, fontWeight, - italic, letterSpacing, maxLines, + italic, letterSpacing, lineHeight, maxLines, )) { best = mid; low = mid + 1; @@ -154,6 +172,7 @@ function replayTypingState( letterSpacing: number, step: number, tries: number, + lineHeight: number, ): FitState { let fontSize = Math.max(4, Math.floor(maxFontSize)); let actTries = 0; @@ -164,7 +183,7 @@ function replayTypingState( for (const ch of text || "Text") { prefix += ch; let measured = measureTextBlock( - prefix, fontSize, fontFamily, fontWeight, italic, letterSpacing, boxWidth, + prefix, fontSize, fontFamily, fontWeight, italic, letterSpacing, boxWidth, lineHeight, ); if (measured.lineCount > previousLineCount) { @@ -174,7 +193,7 @@ function replayTypingState( fontSize = candidate; actTries += 1; measured = measureTextBlock( - prefix, fontSize, fontFamily, fontWeight, italic, letterSpacing, boxWidth, + prefix, fontSize, fontFamily, fontWeight, italic, letterSpacing, boxWidth, lineHeight, ); } @@ -189,12 +208,13 @@ function replayTypingState( fontWeight, italic, letterSpacing, + lineHeight, allowedLines, ); fontSize = Math.max(4, refit); actTries = 0; measured = measureTextBlock( - prefix, fontSize, fontFamily, fontWeight, italic, letterSpacing, boxWidth, + prefix, fontSize, fontFamily, fontWeight, italic, letterSpacing, boxWidth, lineHeight, ); } } @@ -211,13 +231,14 @@ function replayTypingState( fontWeight, italic, letterSpacing, + lineHeight, ) && fontSize > 4) { fontSize -= 1; actTries = 0; } const finalMeasured = measureTextBlock( - text || "Text", fontSize, fontFamily, fontWeight, italic, letterSpacing, boxWidth, + text || "Text", fontSize, fontFamily, fontWeight, italic, letterSpacing, boxWidth, lineHeight, ); return { fontSize, actTries, lineCount: finalMeasured.lineCount }; @@ -235,15 +256,16 @@ function advanceTypingState( letterSpacing: number, step: number, tries: number, + lineHeight: number, ): FitState { const decrement = Math.max(1, Math.round(step)); let fontSize = state.fontSize; let actTries = state.actTries; const before = measureTextBlock( - previousText || "Text", fontSize, fontFamily, fontWeight, italic, letterSpacing, boxWidth, + previousText || "Text", fontSize, fontFamily, fontWeight, italic, letterSpacing, boxWidth, lineHeight, ); let measured = measureTextBlock( - nextText || "Text", fontSize, fontFamily, fontWeight, italic, letterSpacing, boxWidth, + nextText || "Text", fontSize, fontFamily, fontWeight, italic, letterSpacing, boxWidth, lineHeight, ); if (measured.lineCount > before.lineCount) { @@ -251,7 +273,7 @@ function advanceTypingState( fontSize = Math.max(4, fontSize - decrement); actTries += 1; measured = measureTextBlock( - nextText || "Text", fontSize, fontFamily, fontWeight, italic, letterSpacing, boxWidth, + nextText || "Text", fontSize, fontFamily, fontWeight, italic, letterSpacing, boxWidth, lineHeight, ); } @@ -265,12 +287,13 @@ function advanceTypingState( fontWeight, italic, letterSpacing, + lineHeight, before.lineCount + 1, ); fontSize = Math.max(4, refit); actTries = 0; measured = measureTextBlock( - nextText || "Text", fontSize, fontFamily, fontWeight, italic, letterSpacing, boxWidth, + nextText || "Text", fontSize, fontFamily, fontWeight, italic, letterSpacing, boxWidth, lineHeight, ); } } @@ -285,35 +308,17 @@ function advanceTypingState( fontWeight, italic, letterSpacing, + lineHeight, ); actTries = 0; measured = measureTextBlock( - nextText || "Text", fontSize, fontFamily, fontWeight, italic, letterSpacing, boxWidth, + nextText || "Text", fontSize, fontFamily, fontWeight, italic, letterSpacing, boxWidth, lineHeight, ); } return { fontSize, actTries, lineCount: measured.lineCount }; } -function lineHeightFor( - text: string, - fontSize: number, - boxHeight: number, - boxWidth: number, - fontFamily: string, - fontWeight: number, - italic: boolean, - letterSpacing: number, -): number { - const measured = measureTextBlock( - text || "Text", fontSize, fontFamily, fontWeight, italic, letterSpacing, boxWidth, - ); - if (measured.lineCount <= 1) return 1; - const spare = Math.max(0, boxHeight - measured.lineCount * fontSize); - const gap = spare / (measured.lineCount + 3); - return 1 + gap / fontSize; -} - interface Props { element: BaseElement; isSelected: boolean; @@ -341,6 +346,7 @@ export function TextBoxElement({ element, isSelected }: Props) { fitTries?: number; actSize?: number; actTries?: number; + lineHeight?: number; italic?: boolean; }; @@ -354,19 +360,9 @@ export function TextBoxElement({ element, isSelected }: Props) { const fontWeight = p.fontWeight || 400; const italic = !!p.italic; const letterSpacing = p.letterSpacing || 0; + const configuredLineHeight = p.lineHeight ?? 1; const effectiveFontSize = p.autoFit ? (p.actSize ?? configuredFontSize) : configuredFontSize; - const effectiveLineHeight = p.autoFit - ? lineHeightFor( - p.text || "Text", - effectiveFontSize, - Math.max(1, element.height), - Math.max(1, element.width), - fontFamily, - fontWeight, - italic, - letterSpacing, - ) - : 1; + const effectiveLineHeight = configuredLineHeight; const editFitStateRef = useRef({ fontSize: p.actSize ?? configuredFontSize, @@ -390,6 +386,7 @@ export function TextBoxElement({ element, isSelected }: Props) { p.fontWeight || 400, !!p.italic, p.letterSpacing || 0, + configuredLineHeight, ]); if (fitConfigRef.current === null) { @@ -412,6 +409,7 @@ export function TextBoxElement({ element, isSelected }: Props) { p.letterSpacing || 0, p.fitStep ?? 1, p.fitTries ?? 0, + configuredLineHeight, ); if (p.actSize !== replay.fontSize || p.actTries !== replay.actTries) { @@ -430,6 +428,7 @@ export function TextBoxElement({ element, isSelected }: Props) { p.fontWeight, p.italic, p.letterSpacing, + configuredLineHeight, element.id, updateElement, ]); @@ -458,6 +457,7 @@ export function TextBoxElement({ element, isSelected }: Props) { italic, letterSpacing, Math.max(1, element.width), + configuredLineHeight, ).lineCount, }; editTextRef.current = initialText; @@ -539,6 +539,7 @@ export function TextBoxElement({ element, isSelected }: Props) { letterSpacing, p.fitStep ?? 1, p.fitTries ?? 0, + configuredLineHeight, ) : replayTypingState( nextText, @@ -551,21 +552,13 @@ export function TextBoxElement({ element, isSelected }: Props) { letterSpacing, p.fitStep ?? 1, p.fitTries ?? 0, + configuredLineHeight, ); editFitStateRef.current = nextState; editTextRef.current = nextText; - const lh = lineHeightFor( - nextText, - nextState.fontSize, - Math.max(1, element.height), - Math.max(1, element.width), - fontFamily, - fontWeight, - italic, - letterSpacing, - ); + const lh = configuredLineHeight; textarea.style.fontSize = `${nextState.fontSize * scale.y}px`; textarea.style.lineHeight = `${lh}`; updateElement(element.id, { diff --git a/packages/web/src/editor/inspector/fields.tsx b/packages/web/src/editor/inspector/fields.tsx index 12ed973..7c05efa 100644 --- a/packages/web/src/editor/inspector/fields.tsx +++ b/packages/web/src/editor/inspector/fields.tsx @@ -47,6 +47,7 @@ export function NumInput({ min, max, step = 1, + decimals, }: { value: number; onChange: (v: number) => void; @@ -54,12 +55,13 @@ export function NumInput({ min?: number; max?: number; step?: number; + decimals?: number; }) { return (
update({ fitTries: Math.max(0, Math.round(v)) })} /> + + + update({ lineHeight: Math.max(0, Math.round(v * 20) / 20) }) + } + step={0.05} + min={0} + decimals={2} + /> +
)}
diff --git a/packages/web/src/lib/keyboard.ts b/packages/web/src/lib/keyboard.ts index 1197864..4f086a6 100644 --- a/packages/web/src/lib/keyboard.ts +++ b/packages/web/src/lib/keyboard.ts @@ -30,6 +30,7 @@ function addTextBoxEl() { actTries: 0, fitStep: 1, fitTries: 0, + lineHeight: 1, marginH: 4, marginV: 4, fontFamily: "Inter", From 8cd17b1cfb4317bcbfae583a12d3cbfa1efb0453 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Herrmann?= Date: Tue, 25 Aug 2026 23:51:29 +0200 Subject: [PATCH 8/8] Document text box auto-fit --- docs/text-box-auto-fit.md | 100 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 docs/text-box-auto-fit.md diff --git a/docs/text-box-auto-fit.md b/docs/text-box-auto-fit.md new file mode 100644 index 0000000..d1d4ebf --- /dev/null +++ b/docs/text-box-auto-fit.md @@ -0,0 +1,100 @@ +# Text Box Auto Fit + +Auto Fit extends the fixed-size Text Box with automatic font fitting. It does not change the behavior of the original `Text` element. + +## Properties + +| Property | Meaning | +| --- | --- | +| `fontSize` | Configured maximum font size | +| `actSize` | Font size currently selected and rendered by Auto Fit | +| `autoFit` | Enables automatic fitting | +| `fitStep` | Font-size reduction for one fitting attempt | +| `fitTries` | Number of reduction attempts allowed before accepting a line break | +| `actTries` | Attempts already consumed for the current line | +| `lineHeight` | Konva line-height multiplier used for measurement and rendering | + +Defaults for a new Text Box are: + + fontSize = 48 + actSize = 48 + autoFit = true + fitStep = 1 + fitTries = 0 + actTries = 0 + lineHeight = 1.00 + +`fontSize` is an upper bound. Auto Fit may reduce `actSize`, but does not increase it beyond `fontSize`. + +## Step and Tries + +When additional text would require a line break, Auto Fit can first try a smaller font. + +`fitStep` specifies how much the font is reduced for one attempt. `fitTries` specifies how many such attempts may be made before the line break is accepted. + +For example: + + fontSize = 48 + fitStep = 2 + fitTries = 2 + +may produce the sequence: + + 48 -> 46 -> 44 + +while `actTries` progresses: + + 0 -> 1 -> 2 + +After an accepted line break, `actTries` is reset. Auto Fit then searches for the largest font, up to `fontSize`, that fits the resulting layout. + +With `fitTries = 0`, no additional font-reduction attempt is made solely to avoid a line break. + +Changing `fitStep` or `fitTries` immediately triggers a reflow of the existing text. + +## Reflow + +Normal typing at the end of the text continues from the current `actSize` and `actTries` state. + +A structural edit to existing text, such as inserting, deleting, or replacing text away from the end, causes a complete reflow starting from `fontSize`. This avoids retaining an unnecessarily small font after earlier text has been shortened or rearranged. + +The fitting state therefore describes the current layout, not an irreversible history of previous edits. + +## Line Height + +`lineHeight` controls the vertical distance between text lines. It is passed to Konva as its line-height multiplier and is used both when measuring the text for Auto Fit and when rendering it. + +The inspector changes Line Height in steps of `0.05` and displays it with two decimal places, for example `0.90`, `1.00`, or `1.25`. + +Typical values are: + + 0.80 tighter line spacing + 1.00 normal line height + 1.20 wider line spacing + +Values below `1.00` are intentionally permitted. `0.00` is also technically permitted and causes lines to occupy effectively the same vertical position; it is useful as a boundary case but is normally not suitable for readable multi-line text. + +No artificial upper limit is imposed. Increasing Line Height consumes more vertical space and can therefore cause Auto Fit to select a smaller `actSize` so that all lines remain inside the fixed box. + +The remaining space above and below the rendered text block is handled by the Text Box's vertical alignment. Line Height replaces the earlier indirect spacing-distribution approach; there is no separate spacing-balance parameter. + +## JSON state + +The Auto Fit configuration and current fitting state are stored in the Text Box properties. For example: + +```json +{ + "fixedBox": true, + "fontSize": 48, + "actSize": 41, + "autoFit": true, + "fitStep": 1, + "fitTries": 2, + "actTries": 0, + "lineHeight": 0.90 +} +``` + +`actSize` and `actTries` are persisted together with the configuration so that the current editor state can be represented in the label JSON. + +These properties apply to fixed Text Boxes. The original thermoprint `Text` element remains independent of this Auto Fit state.