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
2 changes: 1 addition & 1 deletion .cursor/rules/ls-foundry-core.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 20 additions & 2 deletions apps/docs/src/components/StickPakCanvasSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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."}
</p>
<div className="flex flex-wrap gap-3">
Expand All @@ -543,6 +543,24 @@ function StickPakCanvasSection() {
>
Redo
</button>
<button
type="button"
onClick={() => 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°
</button>
<button
type="button"
onClick={() => 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°
</button>
<button
type="button"
onClick={() => handleDuplicate("horizontal")}
Expand Down
17 changes: 17 additions & 0 deletions docs/stickpak/engineering-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**).
Expand Down
6 changes: 3 additions & 3 deletions docs/stickpak/phase-1.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,15 @@ 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** |

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"
```

Expand All @@ -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`
Expand Down
5 changes: 3 additions & 2 deletions packages/react-canvas-designer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion packages/react-canvas-designer/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
64 changes: 64 additions & 0 deletions packages/react-canvas-designer/src/CanvasDesigner.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -353,6 +354,69 @@ describe("CanvasDesigner", () => {
expect(nextScale).toBeGreaterThan(initialScale);
});

it("rotateSelectedBy returns false without a selection", async () => {
const ref = createRef<CanvasDesignerHandle>();
render(<CanvasDesigner ref={ref} />);
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<CanvasDesignerHandle>();
render(<CanvasDesigner ref={ref} canvasMarginMm={0} />);
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<CanvasDesignerHandle>();
render(<CanvasDesigner ref={ref} canvasMarginMm={0} />);
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<CanvasDesignerHandle>();
const onExport = jest.fn();
Expand Down
Loading
Loading