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. 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. 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..9062c07 --- /dev/null +++ b/packages/web/src/editor/canvas/elements/text-box-element.tsx @@ -0,0 +1,649 @@ +import { useRef, useCallback, useEffect } from "react"; +import { Text } from "react-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"; + + +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, + lineHeight = 1, +): { 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)); + } + + 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: konvaHeight, + lineCount: Math.max(1, lines.length), + }; +} + +function fontFits( + text: string, + fontSize: number, + boxWidth: number, + boxHeight: number, + fontFamily: string, + fontWeight: number, + italic: boolean, + letterSpacing: number, + lineHeight: number, + maxLines?: number, +): boolean { + const measured = measureTextBlock( + text, + fontSize, + fontFamily, + fontWeight, + italic, + letterSpacing, + boxWidth, + lineHeight, + ); + 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, + lineHeight: 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, lineHeight, 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, + lineHeight: 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, lineHeight, + ); + + 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, lineHeight, + ); + } + + if (measured.lineCount > previousLineCount) { + const allowedLines = previousLineCount + 1; + const refit = largestFontThatFits( + prefix, + Math.max(fontSize, 4), + boxWidth, + boxHeight, + fontFamily, + fontWeight, + italic, + letterSpacing, + lineHeight, + allowedLines, + ); + fontSize = Math.max(4, refit); + actTries = 0; + measured = measureTextBlock( + prefix, fontSize, fontFamily, fontWeight, italic, letterSpacing, boxWidth, lineHeight, + ); + } + } + + previousLineCount = measured.lineCount; + } + + while (!fontFits( + text || "Text", + fontSize, + boxWidth, + boxHeight, + fontFamily, + fontWeight, + italic, + letterSpacing, + lineHeight, + ) && fontSize > 4) { + fontSize -= 1; + actTries = 0; + } + + const finalMeasured = measureTextBlock( + text || "Text", fontSize, fontFamily, fontWeight, italic, letterSpacing, boxWidth, lineHeight, + ); + + 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, + 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, lineHeight, + ); + let measured = measureTextBlock( + nextText || "Text", fontSize, fontFamily, fontWeight, italic, letterSpacing, boxWidth, lineHeight, + ); + + 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, lineHeight, + ); + } + + if (measured.lineCount > before.lineCount) { + const refit = largestFontThatFits( + nextText || "Text", + Math.max(fontSize, 4), + boxWidth, + boxHeight, + fontFamily, + fontWeight, + italic, + letterSpacing, + lineHeight, + before.lineCount + 1, + ); + fontSize = Math.max(4, refit); + actTries = 0; + measured = measureTextBlock( + nextText || "Text", fontSize, fontFamily, fontWeight, italic, letterSpacing, boxWidth, lineHeight, + ); + } + } + + if (measured.height > boxHeight) { + fontSize = largestFontThatFits( + nextText || "Text", + fontSize, + boxWidth, + boxHeight, + fontFamily, + fontWeight, + italic, + letterSpacing, + lineHeight, + ); + actTries = 0; + measured = measureTextBlock( + nextText || "Text", fontSize, fontFamily, fontWeight, italic, letterSpacing, boxWidth, lineHeight, + ); + } + + return { fontSize, actTries, lineCount: measured.lineCount }; +} + +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; + verticalAlign?: string; + autoFit?: boolean; + fitStep?: number; + fitTries?: number; + actSize?: number; + actTries?: number; + lineHeight?: number; + italic?: boolean; + }; + + const fontStyle = + [p.italic ? "italic" : "", p.fontWeight && p.fontWeight >= 700 ? "bold" : ""] + .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 configuredLineHeight = p.lineHeight ?? 1; + const effectiveFontSize = p.autoFit ? (p.actSize ?? configuredFontSize) : configuredFontSize; + const effectiveLineHeight = configuredLineHeight; + + 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, + configuredLineHeight, + ]); + + 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, + configuredLineHeight, + ); + + 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, + configuredLineHeight, + element.id, + updateElement, + ]); + + const startEditing = useCallback(() => { + const node = ref.current; + if (!node) return; + + useEditorV2Store.setState({ editingTextId: element.id }); + + const absPos = node.getAbsolutePosition(); + 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), + configuredLineHeight, + ).lineCount, + }; + editTextRef.current = initialText; + + const borderWidth = 2; + node.hide(); + node.getLayer()?.batchDraw(); + + const textarea = document.createElement("textarea"); + 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 = `${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 = `${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)"; + 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 = `${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)`; + + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + + const commit = () => { + 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, + configuredLineHeight, + ) + : replayTypingState( + nextText, + configuredFontSize, + Math.max(1, element.width), + Math.max(1, element.height), + fontFamily, + fontWeight, + italic, + letterSpacing, + p.fitStep ?? 1, + p.fitTries ?? 0, + configuredLineHeight, + ); + + editFitStateRef.current = nextState; + editTextRef.current = nextText; + + const lh = configuredLineHeight; + 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") { + 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(); + } + }); + }, [ + configuredFontSize, + element, + fontFamily, + fontStyle, + fontWeight, + italic, + letterSpacing, + p, + updateElement, + effectiveLineHeight, + ]); + + + 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/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({ 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 && ( +
+ +
+
+ { + 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) => ( + + ))} +
+
+
+
+ )} + {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)) })} + /> + + + + update({ lineHeight: Math.max(0, Math.round(v * 20) / 20) }) + } + step={0.05} + min={0} + decimals={2} + /> + +
+ )}