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/5] 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/5] 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/5] 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/5] 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/5] 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.