diff --git a/.cursor/rules/ls-foundry-core.mdc b/.cursor/rules/ls-foundry-core.mdc
index 8751b2b..959857d 100644
--- a/.cursor/rules/ls-foundry-core.mdc
+++ b/.cursor/rules/ls-foundry-core.mdc
@@ -43,7 +43,7 @@ Also update the pin table below, `docs/stickpak/phase-1.md` version table + inst
| Package | Version |
|---------|---------|
| `@jeffgo10/shared-types` | 1.0.0 |
-| `@jeffgo10/react-canvas-designer` | 1.0.3 |
+| `@jeffgo10/react-canvas-designer` | 1.1.1 |
| `@jeffgo10/canvas-upscaler` | 1.0.0 |
Always bump `shared-types` first when changing layout/DPI APIs; keep dependent package pins in sync.
diff --git a/apps/docs/src/components/StickPakCanvasSection.tsx b/apps/docs/src/components/StickPakCanvasSection.tsx
index c5f7285..f70a4c2 100644
--- a/apps/docs/src/components/StickPakCanvasSection.tsx
+++ b/apps/docs/src/components/StickPakCanvasSection.tsx
@@ -522,8 +522,8 @@ function StickPakCanvasSection() {
: "Inspect: click a sticker to select it. Click empty canvas to clear selection. Move, resize, rotate, and marquee stay off."
: hasSelection
? selectedIds.length > 1
- ? `${selectedIds.length} stickers selected — duplicate fills the whole selection together. Use the transform box to move, resize, or rotate.`
- : "Selected sticker — duplicate to fill a row or column inside the printable margin."
+ ? `${selectedIds.length} stickers selected — duplicate fills the whole selection together. Use the transform box to move, resize, or rotate (hold Shift while rotating to snap to 45°). [ / ] rotate 90°.`
+ : "Selected sticker — duplicate to fill a row or column. Hold Shift while dragging the rotate handle to snap to 45°. [ / ] rotate 90° left/right."
: "Click a sticker to select it. Shift-click to multi-select, or drag on empty canvas to marquee-select."}
@@ -543,6 +543,24 @@ function StickPakCanvasSection() {
>
Redo
+
designerRef.current?.rotateSelectedBy(-90)}
+ disabled={!hasSelection || isInspectMode}
+ className="rounded-lg border border-white/15 bg-white/5 px-4 py-2 text-sm text-white/80 transition hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-50"
+ title="Rotate 90° left ( [ )"
+ >
+ Rotate left 90°
+
+
designerRef.current?.rotateSelectedBy(90)}
+ disabled={!hasSelection || isInspectMode}
+ className="rounded-lg border border-white/15 bg-white/5 px-4 py-2 text-sm text-white/80 transition hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-50"
+ title="Rotate 90° right ( ] )"
+ >
+ Rotate right 90°
+
handleDuplicate("horizontal")}
diff --git a/docs/stickpak/engineering-notes.md b/docs/stickpak/engineering-notes.md
index 7b3eda5..28426d7 100644
--- a/docs/stickpak/engineering-notes.md
+++ b/docs/stickpak/engineering-notes.md
@@ -2,6 +2,23 @@
Noteworthy issues and fixes (synced to Obsidian `StickPak/noteworthy/`).
+## Canvas rotation hotkeys (SP-012)
+
+**When:** August 2026 (`@jeffgo10/react-canvas-designer` **1.1.1**).
+
+**Ask:** Shift+drag rotate handle snaps to 45°; 90° rotate left/right via API + keyboard.
+
+**Fix:**
+- Holding **Shift** toggles Konva Transformer `rotationSnaps` to `[0, 45, …, 315]` while rotating; released Shift clears snaps.
+- Snap tolerance is **22.5°** (half step). Konva keeps the *last* angle within tolerance, so a 45° tolerance skipped **0°** in favor of 45°/315° when dragging upright.
+- New handle API `rotateSelectedBy(degrees)` — single select pivots around sticker center; multi-select pivots around selection AABB via existing group transform math.
+- Keyboard **`[`** / **`]`** → −90° / +90° (skipped in inspect mode and when focus is in a form field).
+- Docs `/stickpak` toolbar: Rotate left/right 90° buttons.
+
+**Storefront follow-up:** Pin designer **1.1.1**; optional toolbar buttons calling `rotateSelectedBy(±90)`.
+
+**Code:** `rotationControls.ts`; `CanvasDesigner.tsx` (keydown + handle); `StickPakCanvasSection.tsx`
+
## Offset hole cut-line contours (helpers 1.0.5)
**When:** July 2026 (`@jeffgo10/helpers` **1.0.5**, `@jeffgo10/react-canvas-designer` **1.0.3**).
diff --git a/docs/stickpak/phase-1.md b/docs/stickpak/phase-1.md
index 42dea96..3fa928a 100644
--- a/docs/stickpak/phase-1.md
+++ b/docs/stickpak/phase-1.md
@@ -20,7 +20,7 @@ Phase 1 lives entirely inside the `ls-foundry` monorepo. It delivers the reusabl
| Package | Version | Notes |
|---------|---------|-------|
| `@jeffgo10/shared-types` | **1.0.0** | Layout schema; optional `cutLineOffsetMm` + `cutLineOffsetFill` on layout items |
-| `@jeffgo10/react-canvas-designer` | **1.0.3** | Full StickPak designer; offset hole cut lines (deps helpers **1.0.5**) |
+| `@jeffgo10/react-canvas-designer` | **1.1.1** | Full StickPak designer; SP-012 rotation hotkeys (45° Shift snap + 90° `[`/`]`); offset hole cut lines (deps helpers **1.0.5**) |
| `@jeffgo10/helpers` | **1.0.5** | `./image` (multi-island + offset hole contours; circular pad; offset-only Chaikin), `./gestures`, `./browser`, `./clipboard` |
| `@jeffgo10/canvas-upscaler` | **1.0.0** | Bakes `cutLineOffsetMm`/`cutLineOffsetFill` for StickPak print; dep shared-types **1.0.0** |
@@ -28,7 +28,7 @@ Install both designer packages together:
```json
"@jeffgo10/shared-types": "1.0.0",
-"@jeffgo10/react-canvas-designer": "1.0.3",
+"@jeffgo10/react-canvas-designer": "1.1.1",
"@jeffgo10/canvas-upscaler": "1.0.0"
```
@@ -44,7 +44,7 @@ See [engineering-notes.md](./engineering-notes.md#package-version-mismatch-react
- [x] **1.1** pnpm workspaces + Turborepo (repo root)
- [x] **1.2** `@jeffgo10/shared-types` (v0.2.0) — layout schema, A4 defaults, physical dimension helpers, customizable `canvasWidth`/`canvasHeight` + `designDpi`/`printDpi`, `instanceId`/`assetId` split
-- [x] **1.3** `@jeffgo10/react-canvas-designer` (v0.3.0) — dropzone, transform handles, cut-line preview, export, auto-arrange, selection dimensions, remote URLs, S3 persistence, customizable canvas size, Delete/Backspace to remove selection, minimum resize size (`minResizeSizeMm`), canvas edge margin (`canvasMarginMm`, cut-line bounds), **`cutLineOffsetMm`** (default 5 mm Silhouette-style outward pad on alpha contour), duplicate library images on one sheet, mobile touch-friendly transformer, **duplicate selected sticker(s) to fill row/column** (`duplicateSelectedHorizontally` / `duplicateSelectedVertically` with cut-line gap; multi-select duplicates the whole block), **Shift/Ctrl/Cmd multi-select with group transform box**, **marquee rubber-band selection**, **mobile select-on-press + pinch zoom/rotate selected sticker** (centroid pivot; live dimension labels during pinch), **`fitToContainer`** (scale canvas to parent width on mobile; margin guide + dimension labels stay aligned), **`setSelectedSize`** (typed width/height in mm/cm/in with optional aspect-ratio lock; respects `minResizeSizeMm`), **undo/redo** (`undo`/`redo` handle + Ctrl/Cmd+Z shortcuts; backed by `@jeffgo10/history`), **`interactionMode="inspect"`** (select + blue border + W×H labels; no resize/rotate handles, move, or pinch)
+- [x] **1.3** `@jeffgo10/react-canvas-designer` (v0.3.0) — dropzone, transform handles, cut-line preview, export, auto-arrange, selection dimensions, remote URLs, S3 persistence, customizable canvas size, Delete/Backspace to remove selection, minimum resize size (`minResizeSizeMm`), canvas edge margin (`canvasMarginMm`, cut-line bounds), **`cutLineOffsetMm`** (default 5 mm Silhouette-style outward pad on alpha contour), duplicate library images on one sheet, mobile touch-friendly transformer, **duplicate selected sticker(s) to fill row/column** (`duplicateSelectedHorizontally` / `duplicateSelectedVertically` with cut-line gap; multi-select duplicates the whole block), **Shift/Ctrl/Cmd multi-select with group transform box**, **marquee rubber-band selection**, **mobile select-on-press + pinch zoom/rotate selected sticker** (centroid pivot; live dimension labels during pinch), **`fitToContainer`** (scale canvas to parent width on mobile; margin guide + dimension labels stay aligned), **`setSelectedSize`** (typed width/height in mm/cm/in with optional aspect-ratio lock; respects `minResizeSizeMm`), **undo/redo** (`undo`/`redo` handle + Ctrl/Cmd+Z shortcuts; backed by `@jeffgo10/history`), **`interactionMode="inspect"`** (select + blue border + W×H labels; no resize/rotate handles, move, or pinch), **SP-012 rotation hotkeys** (`rotateSelectedBy`; Shift+rotate-handle → 45° snap; `[`/`]` → 90° CCW/CW)
- [x] **1.4** `@jeffgo10/canvas-upscaler` (v0.2.2) — JSON CLI; print output size from layout dimensions + DPI; 1 mm Silhouette corner markers; bakes cut-line offset from layout metadata for StickPak admin print
- [x] **1.5** `@jeffgo10/helpers` (v0.4.1) — `./image` (contour, `bakeCutLineOffset`, `offsetClosedPolygon`, blob URL, mobile-safe PNG download) + `./gestures` + `./browser` + `./clipboard`
- [x] **1.6** Docs test page — `apps/docs` `/stickpak`
diff --git a/packages/react-canvas-designer/README.md b/packages/react-canvas-designer/README.md
index 9b89742..79b5041 100644
--- a/packages/react-canvas-designer/README.md
+++ b/packages/react-canvas-designer/README.md
@@ -105,7 +105,7 @@ import { CanvasDesigner } from "@jeffgo10/react-canvas-designer";
/>
```
-Drop images onto the canvas (default **A4 @ 72 DPI**, 595 × 842 px). Select a sticker to move, resize, or rotate. **Delete** / **Backspace** removes the selection.
+Drop images onto the canvas (default **A4 @ 72 DPI**, 595 × 842 px). Select a sticker to move, resize, or rotate. **Delete** / **Backspace** removes the selection. Hold **Shift** while dragging the rotate handle to snap to **45°** increments. **`[`** / **`]`** rotate the selection **90°** left (CCW) / right (CW).
On touch devices (`touchFriendly` or coarse-pointer auto-detect): stickers select on **press-down** (not tap-up); with one sticker selected, **pinch** on the canvas to resize uniformly around the pinch center and **twist** to rotate.
@@ -149,12 +149,13 @@ On touch devices (`touchFriendly` or coarse-pointer auto-detect): stickers selec
| `duplicateSelectedVertically({ gapMm? })` | Copies downward until the printable area is full; multi-select duplicates the whole block together |
| `addImagesFromUrls(sources)` | Place images from remote URLs; reuses `assetId`, mints new `instanceId` per placement |
| `setSelectedSize({ width?, height?, unit?, lockAspectRatio? })` | Resize the single selected sticker from typed dimensions |
+| `rotateSelectedBy(degrees)` | Rotate selection by degrees (positive = CW); single pivots on sticker center, multi on AABB center |
| `setSelectedCutLineOffset({ enabled?, offsetMm?, fill? })` | Bake/remove/re-bake offset pad on the single selected sticker. `fill` omitted keeps current; `undefined`/`""` = auto edge; CSS color = explicit. Persists `cutLineOffsetFill` when set |
| `getSelectedCutLineOffset()` | `{ enabled, offsetMm, fill? }` when one sticker selected; `fill` undefined = auto |
| `undo()` / `redo()` | Step backward/forward through design mutations; returns `false` when unavailable |
| `canUndo()` / `canRedo()` | Whether undo/redo is available |
-Keyboard shortcuts (when focus is not in a form field): **Ctrl/Cmd+Z** undo, **Ctrl/Cmd+Shift+Z** redo. History covers add/delete/move/resize/rotate/duplicate/arrange/clear and typed size changes. Drag and transform gestures commit one snapshot per interaction. Stack logic lives in `@jeffgo10/history`; canvas-specific cloning in `canvasHistory.ts`.
+Keyboard shortcuts (when focus is not in a form field): **Ctrl/Cmd+Z** undo, **Ctrl/Cmd+Shift+Z** redo, **`[`** / **`]`** rotate 90° CCW/CW, **Shift+rotate-handle** snaps to 45°. History covers add/delete/move/resize/rotate/duplicate/arrange/clear and typed size changes. Drag and transform gestures commit one snapshot per interaction. Stack logic lives in `@jeffgo10/history`; canvas-specific cloning in `canvasHistory.ts`.
## Layout item identity
diff --git a/packages/react-canvas-designer/package.json b/packages/react-canvas-designer/package.json
index e43d716..6ffa7ac 100644
--- a/packages/react-canvas-designer/package.json
+++ b/packages/react-canvas-designer/package.json
@@ -1,6 +1,6 @@
{
"name": "@jeffgo10/react-canvas-designer",
- "version": "1.0.3",
+ "version": "1.1.1",
"description": "72 DPI A4 Konva canvas for StickPak sticker layout design",
"license": "MIT",
"repository": {
diff --git a/packages/react-canvas-designer/src/CanvasDesigner.test.tsx b/packages/react-canvas-designer/src/CanvasDesigner.test.tsx
index a6ec2c5..e190e4c 100644
--- a/packages/react-canvas-designer/src/CanvasDesigner.test.tsx
+++ b/packages/react-canvas-designer/src/CanvasDesigner.test.tsx
@@ -128,6 +128,7 @@ describe("CanvasDesigner", () => {
expect(handle.verifyOverlaps).toBeDefined();
expect(handle.clearOverlapHighlights).toBeDefined();
expect(handle.setSelectedSize).toBeDefined();
+ expect(handle.rotateSelectedBy).toBeDefined();
expect(handle.setSelectedCutLineOffset).toBeDefined();
expect(handle.getSelectedCutLineOffset).toBeDefined();
expect(handle.undo).toBeDefined();
@@ -353,6 +354,69 @@ describe("CanvasDesigner", () => {
expect(nextScale).toBeGreaterThan(initialScale);
});
+ it("rotateSelectedBy returns false without a selection", async () => {
+ const ref = createRef();
+ render( );
+ await waitFor(() => expect(ref.current).toBeTruthy());
+ expect(ref.current!.rotateSelectedBy(90)).toBe(false);
+ });
+
+ it("rotateSelectedBy rotates the selected sticker 90° around its center", async () => {
+ const ref = createRef();
+ render( );
+ await waitFor(() => expect(ref.current).toBeTruthy());
+
+ act(() => {
+ ref.current!.addImagesFromUrls([{ url: "blob:test", mimeType: "image/png" }]);
+ });
+
+ await waitFor(() =>
+ expect(ref.current!.exportLayoutState().layout.items).toHaveLength(1),
+ );
+
+ const before = ref.current!.exportLayoutState().layout.items[0]!;
+
+ act(() => {
+ expect(ref.current!.rotateSelectedBy(90)).toBe(true);
+ });
+
+ const after = ref.current!.exportLayoutState().layout.items[0]!;
+ expect(after.rotation).toBeCloseTo(before.rotation + 90);
+ expect(after.instanceId).toBe(before.instanceId);
+ });
+
+ it("rotates 90° CCW / CW via [ and ] keyboard shortcuts", async () => {
+ const ref = createRef();
+ render( );
+ await waitFor(() => expect(ref.current).toBeTruthy());
+
+ act(() => {
+ ref.current!.addImagesFromUrls([{ url: "blob:test", mimeType: "image/png" }]);
+ });
+
+ await waitFor(() =>
+ expect(ref.current!.exportLayoutState().layout.items).toHaveLength(1),
+ );
+
+ const start = ref.current!.exportLayoutState().layout.items[0]!.rotation;
+
+ act(() => {
+ window.dispatchEvent(new KeyboardEvent("keydown", { key: "]" }));
+ });
+
+ expect(ref.current!.exportLayoutState().layout.items[0]!.rotation).toBeCloseTo(
+ start + 90,
+ );
+
+ act(() => {
+ window.dispatchEvent(new KeyboardEvent("keydown", { key: "[" }));
+ });
+
+ expect(ref.current!.exportLayoutState().layout.items[0]!.rotation).toBeCloseTo(
+ start,
+ );
+ });
+
it("exportLayout returns payload when items exist", async () => {
const ref = createRef();
const onExport = jest.fn();
diff --git a/packages/react-canvas-designer/src/CanvasDesigner.tsx b/packages/react-canvas-designer/src/CanvasDesigner.tsx
index 885173f..7022a88 100644
--- a/packages/react-canvas-designer/src/CanvasDesigner.tsx
+++ b/packages/react-canvas-designer/src/CanvasDesigner.tsx
@@ -123,6 +123,11 @@ import {
type GroupTransformSnapshot,
type ProxyState,
} from "./groupTransform";
+import {
+ getRotationSnapToleranceForShift,
+ getRotationSnapsForShift,
+ rotateItemAroundCenter,
+} from "./rotationControls";
import { createInstanceId } from "./createInstanceId";
import {
cloneItemsForHistory,
@@ -366,6 +371,12 @@ export type CanvasDesignerHandle = {
* Returns false when nothing is selected, multiple stickers are selected, or input is invalid.
*/
setSelectedSize: (options: SetSelectedSizeOptions) => boolean;
+ /**
+ * Rotate the selected sticker(s) by `degrees` (positive = CW, negative = CCW).
+ * Single select pivots around the sticker center; multi-select pivots around the
+ * selection AABB center. Returns false when nothing is selected or in inspect mode.
+ */
+ rotateSelectedBy: (degrees: number) => boolean;
/**
* Enable/disable or change cut-line offset on the single selected sticker.
* Amount is per sticker; changing `offsetMm` while enabled re-bakes in place.
@@ -689,6 +700,7 @@ export const CanvasDesigner = forwardRef boolean>(() => false);
const historyStacksRef = useRef(createCanvasHistoryStacks(historyLimit));
const isApplyingHistoryRef = useRef(false);
const gestureBeforeRef = useRef(null);
@@ -1198,7 +1210,22 @@ export const CanvasDesigner = forwardRef {
+ const transformer = transformerRef.current;
+ if (!transformer) {
+ return;
+ }
+ transformer.rotationSnaps(getRotationSnapsForShift(shiftHeld));
+ transformer.rotationSnapTolerance(
+ getRotationSnapToleranceForShift(shiftHeld),
+ );
+ };
+
const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Shift") {
+ applyShiftRotationSnap(true);
+ }
+
if (isEditableTarget(event.target)) {
return;
}
@@ -1218,6 +1245,16 @@ export const CanvasDesigner = forwardRef {
+ if (event.key === "Shift") {
+ applyShiftRotationSnap(false);
+ }
+ };
+
+ const onWindowBlur = () => {
+ applyShiftRotationSnap(false);
+ };
+
window.addEventListener("keydown", onKeyDown);
- return () => window.removeEventListener("keydown", onKeyDown);
+ window.addEventListener("keyup", onKeyUp);
+ window.addEventListener("blur", onWindowBlur);
+ return () => {
+ window.removeEventListener("keydown", onKeyDown);
+ window.removeEventListener("keyup", onKeyUp);
+ window.removeEventListener("blur", onWindowBlur);
+ };
}, [deleteSelectedItems, isInspectMode, redo, undo]);
const runAutoArrange = useCallback(
@@ -2264,6 +2317,115 @@ export const CanvasDesigner = forwardRef {
+ if (isInspectMode || degrees === 0) {
+ return false;
+ }
+
+ const activeIds = selectedIdsRef.current;
+ if (activeIds.length === 0) {
+ return false;
+ }
+
+ if (activeIds.length === 1) {
+ const selectedId = activeIds[0]!;
+ const item = itemsRef.current.find(
+ (entry) => entry.instanceId === selectedId,
+ );
+ if (!item) {
+ return false;
+ }
+
+ pushHistoryBeforeMutation();
+ const next = clampPlacedTransform(
+ rotateItemAroundCenter(item, degrees),
+ );
+
+ const node = shapeRefs.current.get(selectedId);
+ if (node) {
+ node.rotation(next.rotation);
+ node.x(next.x);
+ node.y(next.y);
+ node.getLayer()?.batchDraw();
+ }
+
+ updateItem(next);
+ return true;
+ }
+
+ const selected = itemsRef.current.filter((item) =>
+ activeIds.includes(item.instanceId),
+ );
+ const box = getSelectionAxisAlignedBox(selected);
+ if (!box || selected.length < 2) {
+ return false;
+ }
+
+ pushHistoryBeforeMutation();
+ const snapshot: GroupTransformSnapshot = {
+ items: selected.map((item) => ({
+ instanceId: item.instanceId,
+ x: item.x,
+ y: item.y,
+ scaleX: item.scaleX,
+ scaleY: item.scaleY,
+ rotation: item.rotation,
+ width: item.width,
+ height: item.height,
+ })),
+ proxy: { ...box },
+ };
+ const transformed = applyGroupTransformFromProxy(snapshot, {
+ ...box,
+ rotation: degrees,
+ });
+
+ const updated = itemsRef.current.map((entry) => {
+ const nextEntry = transformed.find(
+ (item) => item.instanceId === entry.instanceId,
+ );
+ if (!nextEntry) {
+ return entry;
+ }
+ return clampPlacedTransform({ ...entry, ...nextEntry });
+ });
+
+ for (const entry of updated) {
+ if (!activeIds.includes(entry.instanceId)) {
+ continue;
+ }
+ const node = shapeRefs.current.get(entry.instanceId);
+ if (!node) {
+ continue;
+ }
+ node.rotation(entry.rotation);
+ node.x(entry.x);
+ node.y(entry.y);
+ }
+
+ const proxy = multiSelectProxyRef.current;
+ if (proxy) {
+ const nextBox = getSelectionAxisAlignedBox(
+ updated.filter((item) => activeIds.includes(item.instanceId)),
+ );
+ if (nextBox) {
+ proxy.position({ x: nextBox.x, y: nextBox.y });
+ proxy.rotation(0);
+ proxy.scale({ x: 1, y: 1 });
+ }
+ }
+
+ itemsRef.current = updated;
+ setItems(updated);
+ transformerRef.current?.forceUpdate();
+ transformerRef.current?.getLayer()?.batchDraw();
+ return true;
+ },
+ [clampPlacedTransform, isInspectMode, pushHistoryBeforeMutation, updateItem],
+ );
+ rotateSelectedByRef.current = rotateSelectedBy;
+
const getSelectedCutLineOffset = useCallback((): SelectedCutLineOffsetState | null => {
const selectedId = selectedIdsRef.current[0];
if (!selectedId || selectedIdsRef.current.length !== 1) {
@@ -2499,6 +2661,7 @@ export const CanvasDesigner = forwardRef {
+ describe("getRotationSnapsForShift", () => {
+ it("returns empty snaps when Shift is not held", () => {
+ expect(getRotationSnapsForShift(false)).toEqual([]);
+ });
+
+ it("returns 45° increments when Shift is held", () => {
+ expect(getRotationSnapsForShift(true)).toEqual([...ROTATION_SNAPS_45]);
+ });
+ });
+
+ describe("getRotationSnapToleranceForShift", () => {
+ it("uses a half-step tolerance while Shift is held (avoids skipping 0°)", () => {
+ expect(getRotationSnapToleranceForShift(true)).toBe(22.5);
+ expect(getRotationSnapToleranceForShift(false)).toBe(5);
+ });
+ });
+
+ /**
+ * Mirrors Konva Transformer's getSnap: last matching angle within tolerance wins.
+ * Documents why ROTATION_SNAP_TOLERANCE_SHIFT must stay ≤ 22.5°.
+ */
+ function konvaStyleSnap(
+ snaps: readonly number[],
+ rotationDeg: number,
+ toleranceDeg: number,
+ ): number {
+ const toRad = (deg: number) => (deg * Math.PI) / 180;
+ const newRotationRad = toRad(rotationDeg);
+ let snapped = newRotationRad;
+ const tol = toRad(toleranceDeg);
+ for (const snapDeg of snaps) {
+ const angle = toRad(snapDeg);
+ const absDiff = Math.abs(angle - newRotationRad) % (Math.PI * 2);
+ const dif = Math.min(absDiff, Math.PI * 2 - absDiff);
+ if (dif < tol) {
+ snapped = angle;
+ }
+ }
+ return (snapped * 180) / Math.PI;
+ }
+
+ describe("Konva-style snap near 0°", () => {
+ it("reaches 0° with half-step tolerance from either drag side", () => {
+ expect(konvaStyleSnap(ROTATION_SNAPS_45, 10, 22.5)).toBeCloseTo(0);
+ expect(konvaStyleSnap(ROTATION_SNAPS_45, -10, 22.5)).toBeCloseTo(0);
+ });
+
+ it("skips 0° with a full-step tolerance (the former bug)", () => {
+ // Last match within 45° wins → 45° or 315°, not 0°.
+ expect(konvaStyleSnap(ROTATION_SNAPS_45, 10, 45)).toBeCloseTo(45);
+ expect(konvaStyleSnap(ROTATION_SNAPS_45, -10, 45)).toBeCloseTo(315);
+ });
+ });
+
+ describe("rotateItemAroundCenter", () => {
+ it("returns the same item for a zero delta", () => {
+ const item = {
+ x: 10,
+ y: 20,
+ width: 100,
+ height: 50,
+ scaleX: 1,
+ scaleY: 1,
+ rotation: 15,
+ };
+ expect(rotateItemAroundCenter(item, 0)).toBe(item);
+ });
+
+ it("keeps the visual center fixed for a 90° CW rotation", () => {
+ const item = {
+ x: 100,
+ y: 50,
+ width: 100,
+ height: 50,
+ scaleX: 1,
+ scaleY: 1,
+ rotation: 0,
+ };
+ // Visual center stays at (150, 75); top-left → (175, 25) after 90° CW.
+ const rotated = rotateItemAroundCenter(item, 90);
+ expect(rotated.rotation).toBe(90);
+ expect(rotated.x).toBeCloseTo(175);
+ expect(rotated.y).toBeCloseTo(25);
+ });
+
+ it("keeps the visual center fixed for a 90° CCW rotation", () => {
+ const item = {
+ x: 100,
+ y: 50,
+ width: 100,
+ height: 50,
+ scaleX: 1,
+ scaleY: 1,
+ rotation: 0,
+ };
+ const rotated = rotateItemAroundCenter(item, -90);
+ expect(rotated.rotation).toBe(-90);
+ // Visual center stays at (150, 75); top-left → (125, 125) after 90° CCW.
+ expect(rotated.x).toBeCloseTo(125);
+ expect(rotated.y).toBeCloseTo(125);
+ });
+
+ it("round-trips ±90° back to the original pose", () => {
+ const item = {
+ x: 40,
+ y: 80,
+ width: 80,
+ height: 40,
+ scaleX: 1.5,
+ scaleY: 1.5,
+ rotation: 12,
+ };
+ const cw = rotateItemAroundCenter(item, 90);
+ const back = rotateItemAroundCenter(cw, -90);
+ expect(back.rotation).toBeCloseTo(item.rotation);
+ expect(back.x).toBeCloseTo(item.x);
+ expect(back.y).toBeCloseTo(item.y);
+ });
+ });
+});
diff --git a/packages/react-canvas-designer/src/rotationControls.ts b/packages/react-canvas-designer/src/rotationControls.ts
new file mode 100644
index 0000000..b9015ee
--- /dev/null
+++ b/packages/react-canvas-designer/src/rotationControls.ts
@@ -0,0 +1,72 @@
+import { localPointToParentOffset } from "./selectedStickerPinch";
+
+/** Absolute angles (degrees) for Shift+rotate-handle snap. */
+export const ROTATION_SNAPS_45 = [
+ 0, 45, 90, 135, 180, 225, 270, 315,
+] as const;
+
+/**
+ * Half of the 45° snap step. Konva's snap picker keeps the *last* angle within
+ * tolerance (not the nearest), so a wider value (e.g. 45°) lets both 0° and
+ * 45°/315° match near upright and skips 0°. Keep this ≤ 22.5°.
+ */
+export const ROTATION_SNAP_TOLERANCE_SHIFT = 22.5;
+
+/** Konva default when Shift is not held (snaps disabled via empty array). */
+export const ROTATION_SNAP_TOLERANCE_DEFAULT = 5;
+
+export type RotatableItem = {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ scaleX: number;
+ scaleY: number;
+ rotation: number;
+};
+
+/**
+ * Rotate a sticker around its visual center (local midpoint), preserving
+ * Konva translate → rotate → scale order via {@link localPointToParentOffset}.
+ */
+export function rotateItemAroundCenter(
+ item: T,
+ deltaDeg: number,
+): T {
+ if (deltaDeg === 0) {
+ return item;
+ }
+
+ const localCenter = { x: item.width / 2, y: item.height / 2 };
+ const before = localPointToParentOffset(
+ localCenter,
+ item.scaleX,
+ item.scaleY,
+ item.rotation,
+ );
+ const nextRotation = item.rotation + deltaDeg;
+ const after = localPointToParentOffset(
+ localCenter,
+ item.scaleX,
+ item.scaleY,
+ nextRotation,
+ );
+
+ return {
+ ...item,
+ rotation: nextRotation,
+ x: item.x + before.x - after.x,
+ y: item.y + before.y - after.y,
+ };
+}
+
+/** Snap angles for the Konva Transformer when Shift is held (empty otherwise). */
+export function getRotationSnapsForShift(shiftHeld: boolean): number[] {
+ return shiftHeld ? [...ROTATION_SNAPS_45] : [];
+}
+
+export function getRotationSnapToleranceForShift(shiftHeld: boolean): number {
+ return shiftHeld
+ ? ROTATION_SNAP_TOLERANCE_SHIFT
+ : ROTATION_SNAP_TOLERANCE_DEFAULT;
+}
diff --git a/packages/test-utils/src/mockKonva.tsx b/packages/test-utils/src/mockKonva.tsx
index 03694a5..26b18aa 100644
--- a/packages/test-utils/src/mockKonva.tsx
+++ b/packages/test-utils/src/mockKonva.tsx
@@ -437,6 +437,9 @@ export type MockTransformerApi = {
resizeEnabled: (enabled?: boolean) => boolean;
rotateEnabled: (enabled?: boolean) => boolean;
enabledAnchors: (anchors?: string[]) => string[];
+ rotationSnaps: (snaps?: number[]) => number[];
+ rotationSnapTolerance: (tolerance?: number) => number;
+ forceUpdate: () => void;
};
/** Test helper: read the mock Transformer API attached to `data-konva="Transformer"`. */
@@ -464,6 +467,8 @@ export const Transformer = forwardRef(
resizeEnabled: resizeEnabledProp,
rotateEnabled: rotateEnabledProp,
enabledAnchors: enabledAnchorsProp,
+ rotationSnaps: [] as number[],
+ rotationSnapTolerance: 5,
});
stateRef.current.resizeEnabled = resizeEnabledProp;
stateRef.current.rotateEnabled = rotateEnabledProp;
@@ -490,6 +495,19 @@ export const Transformer = forwardRef(
}
return stateRef.current.enabledAnchors;
}),
+ rotationSnaps: jest.fn((snaps?: number[]) => {
+ if (snaps) {
+ stateRef.current.rotationSnaps = snaps;
+ }
+ return stateRef.current.rotationSnaps;
+ }),
+ rotationSnapTolerance: jest.fn((tolerance?: number) => {
+ if (typeof tolerance === "number") {
+ stateRef.current.rotationSnapTolerance = tolerance;
+ }
+ return stateRef.current.rotationSnapTolerance;
+ }),
+ forceUpdate: jest.fn(),
});
useImperativeHandle(ref, () => apiRef.current);