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/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..b06c3a8 --- /dev/null +++ b/packages/web/src/editor/canvas/elements/text-box-element.tsx @@ -0,0 +1,163 @@ +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; + verticalAlign?: 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 (
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) => ( + + ))} +
+
+
+
+ )}