Skip to content
Merged
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
71 changes: 71 additions & 0 deletions src/components/Canvas/LabelCanvas.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
forwardRef,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useEffect,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -373,6 +383,58 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(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]);
Comment thread
u8array marked this conversation as resolved.

const handleRotateStep = () => {
if (!singleSelected || !stepRotation) return;
updateObject(singleSelected.id, {
props: { rotation: nextZplRotation(stepRotation) },
Comment thread
u8array marked this conversation as resolved.
});
};

const handleObjectChange = (
id: string,
changes: Parameters<typeof updateObject>[1],
Expand Down Expand Up @@ -756,6 +818,15 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(function LabelCa
// jumps under pxToDots rounding.
ignoreStroke
/>

{rotationBtnPos && !isInteracting && (
<RotationButton
x={rotationBtnPos.x}
y={rotationBtnPos.y}
color={colors.selection}
onClick={handleRotateStep}
/>
)}
</Layer>

{/* Ruler — topmost layer. Tracks the visually-rotated label edges;
Expand Down
98 changes: 98 additions & 0 deletions src/components/Canvas/RotationButton.tsx
Original file line number Diff line number Diff line change
@@ -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<Konva.Stage | null>(null);

const cursorIn = (e: Konva.KonvaEventObject<MouseEvent>) => {
const stage = e.target.getStage();
if (stage) {
stage.container().style.cursor = "pointer";
cursorStageRef.current = stage;
}
setHover(true);
};
const cursorOut = (e: Konva.KonvaEventObject<MouseEvent>) => {
const stage = e.target.getStage();
if (stage) stage.container().style.cursor = "";
cursorStageRef.current = null;
setHover(false);
};
Comment thread
u8array marked this conversation as resolved.

useEffect(() => {
return () => {
const stage = cursorStageRef.current;
if (stage) stage.container().style.cursor = "";
};
}, []);

return (
<Group
x={x}
y={y}
onMouseEnter={cursorIn}
onMouseLeave={cursorOut}
onClick={(e) => {
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. */}
<Circle radius={RADIUS} fill="transparent" />
<Group
scaleX={ICON_SCALE}
scaleY={ICON_SCALE}
offsetX={ICON_OFFSET}
offsetY={ICON_OFFSET}
listening={false}
>
<Path
data={ARROW_PATH_ICON}
stroke={color}
strokeWidth={hover ? 3 : 2}
lineCap="round"
lineJoin="round"
fill="transparent"
/>
</Group>
</Group>
);
}
24 changes: 23 additions & 1 deletion src/registry/rotation.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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();
});
});
19 changes: 19 additions & 0 deletions src/registry/rotation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}