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.1.1 |
| `@jeffgo10/react-canvas-designer` | 1.2.0 |
| `@jeffgo10/canvas-upscaler` | 1.0.0 |

Always bump `shared-types` first when changing layout/DPI APIs; keep dependent package pins in sync.
Expand Down
44 changes: 39 additions & 5 deletions apps/docs/src/components/StickPakCanvasSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ function StickPakCanvasSection() {
const [isArranging, setIsArranging] = useState(false);
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const [viewportZoom, setViewportZoom] = useState(1);
const [selectedCutLineOffset, setSelectedCutLineOffset] = useState(false);
const [selectedCutLineOffsetMm, setSelectedCutLineOffsetMm] = useState(5);
const [selectedCutLineOffsetFill, setSelectedCutLineOffsetFill] = useState<
Expand Down Expand Up @@ -503,6 +504,9 @@ function StickPakCanvasSection() {
setCanUndo(undoAvailable);
setCanRedo(redoAvailable);
}}
onViewportChange={({ zoom }) => {
setViewportZoom(zoom);
}}
onAutoArrange={({ allPlaced }) => {
setDuplicateMessage("");
setOverlapMessage("");
Expand All @@ -518,15 +522,45 @@ function StickPakCanvasSection() {
<p className="text-sm text-white/50">
{isInspectMode
? hasSelection
? "Inspect: sticker selected — blue border and size labels only (no drag, resize, or rotate). Click empty canvas to deselect."
: "Inspect: click a sticker to select it. Click empty canvas to clear selection. Move, resize, rotate, and marquee stay off."
? "Inspect: sticker selected — blue border and size labels only (no drag, resize, or rotate). Click empty canvas to deselect. Scroll to zoom; hold Space and drag to pan."
: "Inspect: click a sticker to select it. Click empty canvas to clear selection. Move, resize, rotate, and marquee stay off. Scroll to zoom; hold Space and drag to pan."
: hasSelection
? selectedIds.length > 1
? `${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."}
? `${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°. Scroll to zoom; Space + drag to pan.`
: "Selected sticker — duplicate to fill a row or column. Hold Shift while dragging the rotate handle to snap to 45°. [ / ] rotate 90° left/right. Scroll to zoom; Space + drag to pan."
: "Click a sticker to select it. Shift-click to multi-select, or drag on empty canvas to marquee-select. Scroll to zoom; hold Space and drag to pan."}
</p>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={() => designerRef.current?.zoomBy(1 / 1.25)}
disabled={viewportZoom <= 1}
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="Zoom out"
>
Zoom −
</button>
<span className="inline-flex items-center rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white/60 tabular-nums">
{Math.round(viewportZoom * 100)}%
</span>
<button
type="button"
onClick={() => designerRef.current?.zoomBy(1.25)}
disabled={viewportZoom >= 4}
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="Zoom in"
>
Zoom +
</button>
<button
type="button"
onClick={() => designerRef.current?.resetViewport()}
disabled={viewportZoom === 1}
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="Reset zoom and pan"
>
Reset view
</button>
<button
type="button"
onClick={() => designerRef.current?.undo()}
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 zoom and pan (SP-024)

**When:** August 2026 (`@jeffgo10/react-canvas-designer` **1.2.0**).

**Ask:** Zoom into the canvas to inspect stickers closely; pan with Spacebar + drag (Figma-style).

**Fix:**
- User zoom multiplies the fit/full baseline (`1`…`4`). Stage scale = `fitScale × userZoom`; pan via Stage `x`/`y` inside the overflow-hidden shell.
- **Wheel** over the canvas zooms toward the pointer; **Space + drag** pans (grab/grabbing cursor). Skipped when focus is in a form field.
- At zoom `1`, pan resets to `0`. Sticker pinch (SP-006) maps through `(buffer − pan) / effectiveScale` so it stays aligned when zoomed.
- Handle API: `getViewport` / `setViewportZoom` / `zoomBy` / `resetViewport`; optional `onViewportChange`.
- Docs `/stickpak`: Zoom − / % / + / Reset view toolbar.

**Storefront follow-up:** Pin designer **1.2.0**. Built-in Space/wheel may overlap storefront `use-canvas-viewport` — prefer the designer camera or keep outer CSS viewport for mobile only; lock outer pan while editing via existing `onSelectedIdChange`.

**Code:** `canvasViewport.ts`; `stagePointer.ts`; `selectedStickerPinch.ts` (`touchClientToDesign`); `CanvasDesigner.tsx`; `StickPakCanvasSection.tsx`

## Canvas rotation hotkeys (SP-012)

**When:** August 2026 (`@jeffgo10/react-canvas-designer` **1.1.1**).
Expand Down
4 changes: 2 additions & 2 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.1.1** | Full StickPak designer; SP-012 rotation hotkeys (45° Shift snap + 90° `[`/`]`); offset hole cut lines (deps helpers **1.0.5**) |
| `@jeffgo10/react-canvas-designer` | **1.2.0** | Full StickPak designer; SP-024 zoom/pan (Space + drag, wheel); SP-012 rotation hotkeys; 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.1.1",
"@jeffgo10/react-canvas-designer": "1.2.0",
"@jeffgo10/canvas-upscaler": "1.0.0"
```

Expand Down
7 changes: 6 additions & 1 deletion packages/react-canvas-designer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ On touch devices (`touchFriendly` or coarse-pointer auto-detect): stickers selec
| `onSelectedIdsChange` | — | Full selection set (Shift/Ctrl/Cmd multi-select) |
| `historyLimit` | `50` | Maximum undo snapshots kept in memory |
| `onHistoryChange` | — | `{ canUndo, canRedo }` when stack availability changes |
| `onViewportChange` | — | `{ zoom, panX, panY }` when the canvas camera changes (zoom 1…4 relative to fit/full) |

## Imperative API (`ref` / `onReady`)

Expand All @@ -152,10 +153,14 @@ On touch devices (`touchFriendly` or coarse-pointer auto-detect): stickers selec
| `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 |
| `getViewport()` | `{ zoom, panX, panY }` — user zoom (1 = fitted/full) and pan in Stage buffer pixels |
| `setViewportZoom(zoom)` | Set user zoom (clamped 1…4), anchoring on viewport center |
| `zoomBy(factor)` | Multiply current zoom by `factor` |
| `resetViewport()` | Zoom `1`, pan `0` |
| `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, **`[`** / **`]`** 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`.
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°, **Space + drag** pans when zoomed, **scroll wheel** zooms toward the pointer. 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.1.1",
"version": "1.2.0",
"description": "72 DPI A4 Konva canvas for StickPak sticker layout design",
"license": "MIT",
"repository": {
Expand Down
61 changes: 61 additions & 0 deletions packages/react-canvas-designer/src/CanvasDesigner.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,67 @@ describe("CanvasDesigner", () => {
expect(handle.redo).toBeDefined();
expect(handle.canUndo).toBeDefined();
expect(handle.canRedo).toBeDefined();
expect(handle.getViewport).toBeDefined();
expect(handle.setViewportZoom).toBeDefined();
expect(handle.zoomBy).toBeDefined();
expect(handle.resetViewport).toBeDefined();
});

it("zooms and resets the viewport via the imperative handle", async () => {
const onViewportChange = jest.fn();
const ref = createRef<CanvasDesignerHandle>();
render(<CanvasDesigner ref={ref} onViewportChange={onViewportChange} />);
await waitFor(() => expect(ref.current).toBeTruthy());

expect(ref.current!.getViewport()).toEqual({ zoom: 1, panX: 0, panY: 0 });

act(() => {
ref.current!.setViewportZoom(2);
});
expect(ref.current!.getViewport().zoom).toBe(2);
expect(onViewportChange).toHaveBeenCalled();

act(() => {
ref.current!.zoomBy(1.5);
});
expect(ref.current!.getViewport().zoom).toBe(3);

act(() => {
ref.current!.resetViewport();
});
expect(ref.current!.getViewport()).toEqual({ zoom: 1, panX: 0, panY: 0 });
});

it("holds Space without throwing and ignores Space while typing in an input", async () => {
render(
<div>
<input data-testid="field" />
<CanvasDesigner />
</div>,
);

act(() => {
window.dispatchEvent(
new KeyboardEvent("keydown", { key: " ", code: "Space", bubbles: true }),
);
});
act(() => {
window.dispatchEvent(
new KeyboardEvent("keyup", { key: " ", code: "Space", bubbles: true }),
);
});

const field = screen.getByTestId("field");
act(() => {
field.focus();
field.dispatchEvent(
new KeyboardEvent("keydown", {
key: " ",
code: "Space",
bubbles: true,
}),
);
});
});

it("exposes exportLayoutState via ref", async () => {
Expand Down
Loading
Loading