Skip to content

Commit 5636ef6

Browse files
committed
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
1 parent 5d7a96b commit 5636ef6

4 files changed

Lines changed: 181 additions & 1 deletion

File tree

‎src/components/Canvas/LabelCanvas.tsx‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
forwardRef,
33
useImperativeHandle,
4+
useLayoutEffect,
45
useMemo,
56
useRef,
67
useEffect,
@@ -39,8 +40,17 @@ import {
3940
type ViewRotation,
4041
} from "./rotationGeometry";
4142
import { useAltClickCycle } from "./hooks/useAltClickCycle";
43+
import { RotationButton } from "./RotationButton";
44+
import {
45+
getStepRotation,
46+
nextZplRotation,
47+
} from "../../registry/rotation";
4248

4349
const PADDING = 40;
50+
// Quick-rotate button: horizontal gap from the selected node's right edge
51+
// (stage px), and a small vertical bias so it lines up with the visual top.
52+
const ROTATE_BUTTON_GAP_PX = 16;
53+
const ROTATE_BUTTON_TOP_OFFSET_PX = -2;
4454

4555
interface Props {
4656
unit: Unit;
@@ -373,6 +383,43 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(function LabelCa
373383
viewRotation,
374384
});
375385

386+
// Quick 90°-rotation button overlay. Only step-rotation objects (those
387+
// with a `rotation: N|R|I|B` prop — text, serial, all barcodes) get the
388+
// affordance; box/ellipse/circle/line/image rotate freely via the
389+
// Transformer or have no rotation. Positioned at the visual top-right
390+
// corner of the selected node, derived from getClientRect so it tracks
391+
// the rendered bbox through both object-rotation and viewRotation.
392+
const singleSelected = selectedIds.length === 1
393+
? objects.find((o) => o.id === selectedIds[0]) ?? null
394+
: null;
395+
const stepRotation = singleSelected ? getStepRotation(singleSelected) : null;
396+
const [rotationBtnPos, setRotationBtnPos] = useState<{ x: number; y: number } | null>(null);
397+
useLayoutEffect(() => {
398+
if (!singleSelected || !stepRotation) {
399+
setRotationBtnPos(null);
400+
return;
401+
}
402+
const stage = stageRef.current;
403+
if (!stage) return;
404+
const node = stage.findOne(`#${singleSelected.id}`);
405+
if (!node) {
406+
setRotationBtnPos(null);
407+
return;
408+
}
409+
const rect = node.getClientRect({ relativeTo: stage, skipStroke: true });
410+
setRotationBtnPos({
411+
x: rect.x + rect.width + ROTATE_BUTTON_GAP_PX,
412+
y: rect.y + ROTATE_BUTTON_TOP_OFFSET_PX,
413+
});
414+
}, [singleSelected, stepRotation, scale, viewRotation]);
415+
416+
const handleRotateStep = () => {
417+
if (!singleSelected || !stepRotation) return;
418+
updateObject(singleSelected.id, {
419+
props: { rotation: nextZplRotation(stepRotation) },
420+
});
421+
};
422+
376423
const handleObjectChange = (
377424
id: string,
378425
changes: Parameters<typeof updateObject>[1],
@@ -756,6 +803,15 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(function LabelCa
756803
// jumps under pxToDots rounding.
757804
ignoreStroke
758805
/>
806+
807+
{rotationBtnPos && (
808+
<RotationButton
809+
x={rotationBtnPos.x}
810+
y={rotationBtnPos.y}
811+
color={colors.selection}
812+
onClick={handleRotateStep}
813+
/>
814+
)}
759815
</Layer>
760816

761817
{/* Ruler — topmost layer. Tracks the visually-rotated label edges;
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { useState } from "react";
2+
import { Group, Circle, Path } from "react-konva";
3+
import type Konva from "konva";
4+
5+
/**
6+
* Small floating "rotate 90°" button rendered next to the selected object's
7+
* top-right corner. Only used for step-rotation objects (text, serial,
8+
* barcodes) where rotation is N/R/I/B rather than a free angle from the
9+
* Konva Transformer.
10+
*
11+
* Positioned in stage-space (relative to the Layer) — the caller resolves
12+
* the selected node's bbox via getClientRect({ relativeTo: stage }) so the
13+
* button stays at the visual top-right even when the underlying object is
14+
* itself rotated to R/I/B.
15+
*/
16+
interface Props {
17+
x: number;
18+
y: number;
19+
color: string;
20+
onClick: () => void;
21+
}
22+
23+
const RADIUS = 11;
24+
// Lucide "rotate-cw" (24x24). Single-arrow rotation glyph — the
25+
// universally-recognised "rotate 90°" icon (Photoshop / Figma / Word all
26+
// use this shape). Drawn as a Konva Path scaled down and centred on the
27+
// button origin via a wrapping Group.
28+
const ARROW_PATH_ICON =
29+
"M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8 M21 3v5h-5";
30+
const ICON_SCALE = 0.55;
31+
const ICON_OFFSET = 12; // 24x24 viewBox → centre at (12, 12)
32+
33+
export function RotationButton({ x, y, color, onClick }: Props) {
34+
const [hover, setHover] = useState(false);
35+
36+
const cursorIn = (e: Konva.KonvaEventObject<MouseEvent>) => {
37+
const stage = e.target.getStage();
38+
if (stage) stage.container().style.cursor = "pointer";
39+
setHover(true);
40+
};
41+
const cursorOut = (e: Konva.KonvaEventObject<MouseEvent>) => {
42+
const stage = e.target.getStage();
43+
if (stage) stage.container().style.cursor = "";
44+
setHover(false);
45+
};
46+
47+
return (
48+
<Group
49+
x={x}
50+
y={y}
51+
onMouseEnter={cursorIn}
52+
onMouseLeave={cursorOut}
53+
onClick={(e) => {
54+
e.cancelBubble = true;
55+
onClick();
56+
}}
57+
onTap={(e) => {
58+
e.cancelBubble = true;
59+
onClick();
60+
}}
61+
>
62+
{/* Invisible hitbox — gives the icon a comfortable click target
63+
even though the icon path itself is thin. */}
64+
<Circle radius={RADIUS} fill="transparent" />
65+
<Group
66+
scaleX={ICON_SCALE}
67+
scaleY={ICON_SCALE}
68+
offsetX={ICON_OFFSET}
69+
offsetY={ICON_OFFSET}
70+
listening={false}
71+
>
72+
<Path
73+
data={ARROW_PATH_ICON}
74+
stroke={color}
75+
strokeWidth={hover ? 3 : 2}
76+
lineCap="round"
77+
lineJoin="round"
78+
fill="transparent"
79+
/>
80+
</Group>
81+
</Group>
82+
);
83+
}

‎src/registry/rotation.test.ts‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from "vitest";
2-
import { isZplRotation, objectRotation, ZPL_ROTATIONS } from "./rotation";
2+
import { getStepRotation, isZplRotation, nextZplRotation, objectRotation, ZPL_ROTATIONS } from "./rotation";
33

44
describe("isZplRotation", () => {
55
it("accepts the four ZPL letters", () => {
@@ -29,3 +29,25 @@ describe("objectRotation", () => {
2929
expect(objectRotation({ rotation: "garbage" })).toBe("N");
3030
});
3131
});
32+
33+
describe("nextZplRotation", () => {
34+
it("cycles N → R → I → B → N", () => {
35+
expect(nextZplRotation("N")).toBe("R");
36+
expect(nextZplRotation("R")).toBe("I");
37+
expect(nextZplRotation("I")).toBe("B");
38+
expect(nextZplRotation("B")).toBe("N");
39+
});
40+
});
41+
42+
describe("getStepRotation", () => {
43+
it("returns the rotation letter for step-rotation objects", () => {
44+
expect(getStepRotation({ props: { rotation: "R" } })).toBe("R");
45+
expect(getStepRotation({ props: { rotation: "N" } })).toBe("N");
46+
});
47+
48+
it("returns null when the object has no rotation prop or an invalid value", () => {
49+
expect(getStepRotation({ props: {} })).toBeNull();
50+
expect(getStepRotation({ props: { rotation: "L" } })).toBeNull();
51+
expect(getStepRotation({ props: { rotation: 90 } })).toBeNull();
52+
});
53+
});

‎src/registry/rotation.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,22 @@ export function objectRotation(props: object): ZplRotation {
2020
const r = (props as { rotation?: string }).rotation;
2121
return r !== undefined && isZplRotation(r) ? r : 'N';
2222
}
23+
24+
/** Next 90° step in the N → R → I → B → N cycle. */
25+
export function nextZplRotation(r: ZplRotation): ZplRotation {
26+
const i = ZPL_ROTATIONS.indexOf(r);
27+
const next = ZPL_ROTATIONS[(i + 1) % ZPL_ROTATIONS.length];
28+
return next ?? 'N';
29+
}
30+
31+
/**
32+
* Returns the object's step-rotation if it has one, else `null`. Step-rotation
33+
* objects (text, serial, all barcodes) declare a `rotation: 'N'|'R'|'I'|'B'`
34+
* prop; box/ellipse/circle/line/image do not. Lets callers gate UI affordances
35+
* (e.g. the canvas quick-rotate button) without inspecting `props` shapes
36+
* themselves.
37+
*/
38+
export function getStepRotation(obj: { props: object }): ZplRotation | null {
39+
const r = (obj.props as { rotation?: unknown }).rotation;
40+
return typeof r === 'string' && isZplRotation(r) ? r : null;
41+
}

0 commit comments

Comments
 (0)