diff --git a/src/components/Canvas/LabelCanvas.tsx b/src/components/Canvas/LabelCanvas.tsx index 2ab74a6a..5a338e7b 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,58 @@ 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); + // 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); + 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 +818,15 @@ export const LabelCanvas = forwardRef(function LabelCa // jumps under pxToDots rounding. ignoreStroke /> + + {rotationBtnPos && !isInteracting && ( + + )} {/* 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..93598203 --- /dev/null +++ b/src/components/Canvas/RotationButton.tsx @@ -0,0 +1,98 @@ +import { useEffect, useRef, 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); + // Track the stage we set a cursor on so unmount-while-hovering can still + // clean up (onMouseLeave never fires in that case — e.g. user hits Delete + // or Esc while the cursor is over the button). + const cursorStageRef = useRef(null); + + const cursorIn = (e: Konva.KonvaEventObject) => { + const stage = e.target.getStage(); + 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 ( + { + 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; +}