From 5636ef625b95d3f44f4cf9c85238a16e901519a0 Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 10 May 2026 23:49:17 +0200 Subject: [PATCH 1/2] feat(canvas): add 90 deg quick-rotate button for step-rotation objects Shows a small floating rotate icon at the top-right of the selected node when exactly one step-rotation object (text, serial, all barcodes) is selected. Cycles the ZPL rotation prop N to R to I to B to N on click, so users no longer have to bounce to the properties panel for that common operation. - registry/rotation.ts: pure helpers nextZplRotation + getStepRotation - components/Canvas/RotationButton.tsx: Konva-only presentation, lucide rotate-cw glyph as inline SVG path - LabelCanvas wires the affordance via getClientRect so the button tracks the visual top-right through both object-rotation and viewRotation --- src/components/Canvas/LabelCanvas.tsx | 56 ++++++++++++++++ src/components/Canvas/RotationButton.tsx | 83 ++++++++++++++++++++++++ src/registry/rotation.test.ts | 24 ++++++- src/registry/rotation.ts | 19 ++++++ 4 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 src/components/Canvas/RotationButton.tsx diff --git a/src/components/Canvas/LabelCanvas.tsx b/src/components/Canvas/LabelCanvas.tsx index 2ab74a6a..f9ed22bb 100644 --- a/src/components/Canvas/LabelCanvas.tsx +++ b/src/components/Canvas/LabelCanvas.tsx @@ -1,6 +1,7 @@ import { forwardRef, useImperativeHandle, + useLayoutEffect, useMemo, useRef, useEffect, @@ -39,8 +40,17 @@ import { type ViewRotation, } from "./rotationGeometry"; import { useAltClickCycle } from "./hooks/useAltClickCycle"; +import { RotationButton } from "./RotationButton"; +import { + getStepRotation, + nextZplRotation, +} from "../../registry/rotation"; const PADDING = 40; +// Quick-rotate button: horizontal gap from the selected node's right edge +// (stage px), and a small vertical bias so it lines up with the visual top. +const ROTATE_BUTTON_GAP_PX = 16; +const ROTATE_BUTTON_TOP_OFFSET_PX = -2; interface Props { unit: Unit; @@ -373,6 +383,43 @@ export const LabelCanvas = forwardRef(function LabelCa viewRotation, }); + // Quick 90°-rotation button overlay. Only step-rotation objects (those + // with a `rotation: N|R|I|B` prop — text, serial, all barcodes) get the + // affordance; box/ellipse/circle/line/image rotate freely via the + // Transformer or have no rotation. Positioned at the visual top-right + // corner of the selected node, derived from getClientRect so it tracks + // the rendered bbox through both object-rotation and viewRotation. + const singleSelected = selectedIds.length === 1 + ? objects.find((o) => o.id === selectedIds[0]) ?? null + : null; + const stepRotation = singleSelected ? getStepRotation(singleSelected) : null; + const [rotationBtnPos, setRotationBtnPos] = useState<{ x: number; y: number } | null>(null); + useLayoutEffect(() => { + if (!singleSelected || !stepRotation) { + setRotationBtnPos(null); + return; + } + const stage = stageRef.current; + if (!stage) return; + const node = stage.findOne(`#${singleSelected.id}`); + if (!node) { + setRotationBtnPos(null); + return; + } + const rect = node.getClientRect({ relativeTo: stage, skipStroke: true }); + setRotationBtnPos({ + x: rect.x + rect.width + ROTATE_BUTTON_GAP_PX, + y: rect.y + ROTATE_BUTTON_TOP_OFFSET_PX, + }); + }, [singleSelected, stepRotation, scale, viewRotation]); + + const handleRotateStep = () => { + if (!singleSelected || !stepRotation) return; + updateObject(singleSelected.id, { + props: { rotation: nextZplRotation(stepRotation) }, + }); + }; + const handleObjectChange = ( id: string, changes: Parameters[1], @@ -756,6 +803,15 @@ export const LabelCanvas = forwardRef(function LabelCa // jumps under pxToDots rounding. ignoreStroke /> + + {rotationBtnPos && ( + + )} {/* Ruler — topmost layer. Tracks the visually-rotated label edges; diff --git a/src/components/Canvas/RotationButton.tsx b/src/components/Canvas/RotationButton.tsx new file mode 100644 index 00000000..913b7327 --- /dev/null +++ b/src/components/Canvas/RotationButton.tsx @@ -0,0 +1,83 @@ +import { useState } from "react"; +import { Group, Circle, Path } from "react-konva"; +import type Konva from "konva"; + +/** + * Small floating "rotate 90°" button rendered next to the selected object's + * top-right corner. Only used for step-rotation objects (text, serial, + * barcodes) where rotation is N/R/I/B rather than a free angle from the + * Konva Transformer. + * + * Positioned in stage-space (relative to the Layer) — the caller resolves + * the selected node's bbox via getClientRect({ relativeTo: stage }) so the + * button stays at the visual top-right even when the underlying object is + * itself rotated to R/I/B. + */ +interface Props { + x: number; + y: number; + color: string; + onClick: () => void; +} + +const RADIUS = 11; +// Lucide "rotate-cw" (24x24). Single-arrow rotation glyph — the +// universally-recognised "rotate 90°" icon (Photoshop / Figma / Word all +// use this shape). Drawn as a Konva Path scaled down and centred on the +// button origin via a wrapping Group. +const ARROW_PATH_ICON = + "M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8 M21 3v5h-5"; +const ICON_SCALE = 0.55; +const ICON_OFFSET = 12; // 24x24 viewBox → centre at (12, 12) + +export function RotationButton({ x, y, color, onClick }: Props) { + const [hover, setHover] = useState(false); + + const cursorIn = (e: Konva.KonvaEventObject) => { + const stage = e.target.getStage(); + if (stage) stage.container().style.cursor = "pointer"; + setHover(true); + }; + const cursorOut = (e: Konva.KonvaEventObject) => { + const stage = e.target.getStage(); + if (stage) stage.container().style.cursor = ""; + setHover(false); + }; + + return ( + { + e.cancelBubble = true; + onClick(); + }} + onTap={(e) => { + e.cancelBubble = true; + onClick(); + }} + > + {/* Invisible hitbox — gives the icon a comfortable click target + even though the icon path itself is thin. */} + + + + + + ); +} diff --git a/src/registry/rotation.test.ts b/src/registry/rotation.test.ts index ad5ad977..3ef53082 100644 --- a/src/registry/rotation.test.ts +++ b/src/registry/rotation.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { isZplRotation, objectRotation, ZPL_ROTATIONS } from "./rotation"; +import { getStepRotation, isZplRotation, nextZplRotation, objectRotation, ZPL_ROTATIONS } from "./rotation"; describe("isZplRotation", () => { it("accepts the four ZPL letters", () => { @@ -29,3 +29,25 @@ describe("objectRotation", () => { expect(objectRotation({ rotation: "garbage" })).toBe("N"); }); }); + +describe("nextZplRotation", () => { + it("cycles N → R → I → B → N", () => { + expect(nextZplRotation("N")).toBe("R"); + expect(nextZplRotation("R")).toBe("I"); + expect(nextZplRotation("I")).toBe("B"); + expect(nextZplRotation("B")).toBe("N"); + }); +}); + +describe("getStepRotation", () => { + it("returns the rotation letter for step-rotation objects", () => { + expect(getStepRotation({ props: { rotation: "R" } })).toBe("R"); + expect(getStepRotation({ props: { rotation: "N" } })).toBe("N"); + }); + + it("returns null when the object has no rotation prop or an invalid value", () => { + expect(getStepRotation({ props: {} })).toBeNull(); + expect(getStepRotation({ props: { rotation: "L" } })).toBeNull(); + expect(getStepRotation({ props: { rotation: 90 } })).toBeNull(); + }); +}); diff --git a/src/registry/rotation.ts b/src/registry/rotation.ts index 095bf396..ac2195fd 100644 --- a/src/registry/rotation.ts +++ b/src/registry/rotation.ts @@ -20,3 +20,22 @@ export function objectRotation(props: object): ZplRotation { const r = (props as { rotation?: string }).rotation; return r !== undefined && isZplRotation(r) ? r : 'N'; } + +/** Next 90° step in the N → R → I → B → N cycle. */ +export function nextZplRotation(r: ZplRotation): ZplRotation { + const i = ZPL_ROTATIONS.indexOf(r); + const next = ZPL_ROTATIONS[(i + 1) % ZPL_ROTATIONS.length]; + return next ?? 'N'; +} + +/** + * Returns the object's step-rotation if it has one, else `null`. Step-rotation + * objects (text, serial, all barcodes) declare a `rotation: 'N'|'R'|'I'|'B'` + * prop; box/ellipse/circle/line/image do not. Lets callers gate UI affordances + * (e.g. the canvas quick-rotate button) without inspecting `props` shapes + * themselves. + */ +export function getStepRotation(obj: { props: object }): ZplRotation | null { + const r = (obj.props as { rotation?: unknown }).rotation; + return typeof r === 'string' && isZplRotation(r) ? r : null; +} From 2e2e81291e9a42e85316d497a85fd47925b5b0eb Mon Sep 17 00:00:00 2001 From: u8array Date: Mon, 11 May 2026 00:04:50 +0200 Subject: [PATCH 2/2] fix(canvas): hide rotate button during drag/transform, clean up cursor on unmount Addresses gemini review on PR #53: - Hide the quick-rotate button while a drag or transform is in progress. The button's position is React-state-driven and would otherwise lag behind the live Konva node until the interaction ends. - Reset the stage cursor when the button unmounts. Without this, deleting or deselecting the object while the cursor is over the button would leave the pointer stuck in 'pointer' style because onMouseLeave never fires. --- src/components/Canvas/LabelCanvas.tsx | 17 ++++++++++++++++- src/components/Canvas/RotationButton.tsx | 19 +++++++++++++++++-- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/components/Canvas/LabelCanvas.tsx b/src/components/Canvas/LabelCanvas.tsx index f9ed22bb..5a338e7b 100644 --- a/src/components/Canvas/LabelCanvas.tsx +++ b/src/components/Canvas/LabelCanvas.tsx @@ -394,6 +394,21 @@ export const LabelCanvas = forwardRef(function LabelCa : null; const stepRotation = singleSelected ? getStepRotation(singleSelected) : null; const [rotationBtnPos, setRotationBtnPos] = useState<{ x: number; y: number } | null>(null); + // Hide the rotate affordance during an active drag / transform — the + // button's position is React-state-driven and would otherwise lag behind + // the live Konva node until the interaction ends. + const [isInteracting, setIsInteracting] = useState(false); + useEffect(() => { + const stage = stageRef.current; + if (!stage) return; + const start = () => setIsInteracting(true); + const end = () => setIsInteracting(false); + stage.on("dragstart.rotbtn transformstart.rotbtn", start); + stage.on("dragend.rotbtn transformend.rotbtn", end); + return () => { + stage.off("dragstart.rotbtn transformstart.rotbtn dragend.rotbtn transformend.rotbtn"); + }; + }, []); useLayoutEffect(() => { if (!singleSelected || !stepRotation) { setRotationBtnPos(null); @@ -804,7 +819,7 @@ export const LabelCanvas = forwardRef(function LabelCa ignoreStroke /> - {rotationBtnPos && ( + {rotationBtnPos && !isInteracting && ( (null); const cursorIn = (e: Konva.KonvaEventObject) => { const stage = e.target.getStage(); - if (stage) stage.container().style.cursor = "pointer"; + if (stage) { + stage.container().style.cursor = "pointer"; + cursorStageRef.current = stage; + } setHover(true); }; const cursorOut = (e: Konva.KonvaEventObject) => { const stage = e.target.getStage(); if (stage) stage.container().style.cursor = ""; + cursorStageRef.current = null; setHover(false); }; + useEffect(() => { + return () => { + const stage = cursorStageRef.current; + if (stage) stage.container().style.cursor = ""; + }; + }, []); + return (