Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions docs/fixed-text-box.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 4 additions & 1 deletion packages/web/src/editor/canvas/canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -135,7 +136,9 @@ function LabelSizeSelector({
function renderElement(el: BaseElement, isSelected: boolean) {
switch (el.type) {
case "text":
return <TextElement key={el.id} element={el} isSelected={isSelected} />;
return (el.props as { fixedBox?: boolean }).fixedBox
? <TextBoxElement key={el.id} element={el} isSelected={isSelected} />
: <TextElement key={el.id} element={el} isSelected={isSelected} />;
case "rect":
return <RectElement key={el.id} element={el} isSelected={isSelected} />;
case "line":
Expand Down
163 changes: 163 additions & 0 deletions packages/web/src/editor/canvas/elements/text-box-element.tsx
Original file line number Diff line number Diff line change
@@ -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<Konva.Text>(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 (
<>
<Text
ref={ref}
id={element.id}
x={element.x}
y={element.y}
width={element.width}
height={element.height}
rotation={element.rotation}
text={p.text || "Text"}
fontSize={p.fontSize || 18}
fontFamily={p.fontFamily || "Inter"}
fontStyle={fontStyle}
letterSpacing={p.letterSpacing || 0}
fill={p.fill || "#000000"}
align={(p.align as "left" | "center" | "right") || "center"}
verticalAlign={(p.verticalAlign as "top" | "middle" | "bottom") || "middle"}
wrap="word"
draggable={!isEditing}
onClick={() => 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 && <ElementWrapper nodeRef={ref} isSelected={isSelected} />}
</>
);
}
3 changes: 3 additions & 0 deletions packages/web/src/editor/dock/dock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { DockDivider } from "./dock-divider.tsx";
import { Kbd } from "./kbd.tsx";
import {
addTextEl,
addTextBoxEl,
addQrEl,
addBarcodeEl,
addImageEl,
Expand Down Expand Up @@ -73,6 +74,7 @@ export function Dock() {
<div className="grid grid-cols-4 gap-1 p-2">
{[
{ 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 },
Expand Down Expand Up @@ -108,6 +110,7 @@ export function Dock() {
<div className="hidden md:contents">
<DockGroup label="Add">
<DockBtn icon={Type} label="Text" shortcut="T" onClick={addTextEl} />
<DockBtn icon={Type} label="Text Box" onClick={addTextBoxEl} />
<DockBtn icon={QrCode} label="QR" shortcut="Q" onClick={addQrEl} />
<DockBtn icon={Barcode} label="Barcode" shortcut="B" onClick={addBarcodeEl} />
<DockBtn icon={ImageIcon} label="Image" shortcut="I" onClick={addImageEl} />
Expand Down
6 changes: 5 additions & 1 deletion packages/web/src/editor/inspector/inspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div
Expand Down
Loading