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 @@ -42,7 +42,7 @@ Also update the pin table below, `docs/stickpak/phase-1.md` version table + inst
| Package | Version |
|---------|---------|
| `@jeffgo10/shared-types` | 0.2.0 |
| `@jeffgo10/react-canvas-designer` | 0.2.9 |
| `@jeffgo10/react-canvas-designer` | 0.2.15 |
| `@jeffgo10/canvas-upscaler` | 0.2.0 |

Always bump `shared-types` first when changing layout/DPI APIs; keep dependent package pins in sync.
Expand Down
14 changes: 7 additions & 7 deletions apps/docs/src/components/StickPakCanvasSection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ const exportLayout = jest.fn(async () => ({
assets: [],
}));

let mockSelectedId: string | null = "sticker-1";
let mockSelectedIds: string[] = ["sticker-1"];

jest.mock("next/dynamic", () => () =>
forwardRef<CanvasDesignerHandle, { onSelectedIdChange?: (id: string | null) => void }>(
function MockCanvasDesigner({ onSelectedIdChange }, ref) {
forwardRef<CanvasDesignerHandle, { onSelectedIdsChange?: (ids: string[]) => void }>(
function MockCanvasDesigner({ onSelectedIdsChange }, ref) {
useImperativeHandle(ref, () => ({
exportLayout,
exportLayoutState: jest.fn(),
Expand All @@ -27,8 +27,8 @@ jest.mock("next/dynamic", () => () =>
}));

useEffect(() => {
onSelectedIdChange?.(mockSelectedId);
}, [onSelectedIdChange]);
onSelectedIdsChange?.(mockSelectedIds);
}, [onSelectedIdsChange]);

return <div data-testid="canvas-designer">CanvasDesigner</div>;
},
Expand All @@ -41,7 +41,7 @@ import StickPakCanvasSection from "./StickPakCanvasSection";

describe("StickPakCanvasSection", () => {
beforeEach(() => {
mockSelectedId = "sticker-1";
mockSelectedIds = ["sticker-1"];
duplicateSelectedHorizontally.mockReset().mockReturnValue(2);
duplicateSelectedVertically.mockReset().mockReturnValue(1);
arrangeAll.mockReset().mockResolvedValue(true);
Expand Down Expand Up @@ -79,7 +79,7 @@ describe("StickPakCanvasSection", () => {
});

it("disables duplicate buttons when nothing is selected", () => {
mockSelectedId = null;
mockSelectedIds = [];
render(<StickPakCanvasSection />);

expect(
Expand Down
12 changes: 7 additions & 5 deletions apps/docs/src/components/StickPakCanvasSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,12 @@ function StickPakCanvasSection() {
const [autoArrangeOnAdd, setAutoArrangeOnAdd] = useState(false);
const [showSelectionDimensions, setShowSelectionDimensions] = useState(true);
const [dimensionUnit, setDimensionUnit] = useState<DimensionUnit>("mm");
const [selectedId, setSelectedId] = useState<string | null>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [arrangeMessage, setArrangeMessage] = useState("");
const [duplicateMessage, setDuplicateMessage] = useState("");
const [isExporting, setIsExporting] = useState(false);
const [isArranging, setIsArranging] = useState(false);
const hasSelection = selectedId !== null;
const hasSelection = selectedIds.length > 0;

const handleExport = async () => {
if (!designerRef.current) return;
Expand Down Expand Up @@ -166,7 +166,7 @@ function StickPakCanvasSection() {
autoArrangeOnAdd={autoArrangeOnAdd}
showSelectionDimensions={showSelectionDimensions}
dimensionUnit={dimensionUnit}
onSelectedIdChange={setSelectedId}
onSelectedIdsChange={setSelectedIds}
onAutoArrange={({ allPlaced }) => {
setDuplicateMessage("");
setArrangeMessage(
Expand All @@ -180,8 +180,10 @@ function StickPakCanvasSection() {
<div className="space-y-3">
<p className="text-sm text-white/50">
{hasSelection
? "Selected sticker — duplicate to fill a row or column inside the printable margin."
: "Click a sticker to select it, then duplicate horizontally or vertically."}
? 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."
: "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">
<button
Expand Down
22 changes: 21 additions & 1 deletion docs/stickpak/engineering-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,27 @@ Noteworthy issues and fixes (synced to Obsidian `StickPak/noteworthy/`).

**Fix (June 2026):** `shared-types@0.2.0`, `react-canvas-designer@0.2.6`. Layout items now carry **`instanceId`** (unique per canvas sticker — keys, selection, transforms) and **`assetId`** (shared library reference for export/upscale). `addImagesFromUrls([{ url, assetId }])` always mints a new `instanceId`; export assets are deduped by `assetId`. Legacy layouts without `instanceId` get one assigned on load.

**Storefront pins:** `@jeffgo10/shared-types@0.2.0`, `@jeffgo10/react-canvas-designer@0.2.9`.
**Storefront pins:** `@jeffgo10/shared-types@0.2.0`, `@jeffgo10/react-canvas-designer@0.2.10`.

## Shift multi-select transform box

**When:** July 2026 (`react-canvas-designer` v0.2.10).

**Feature:** Shift-click (or Ctrl/Cmd-click) to add or remove stickers from the selection. The Konva `Transformer` attaches to all selected nodes — group move, uniform resize, and rotate. Per-sticker drag is disabled while multiple are selected (use the transform box border to move the group). Delete/Backspace removes every selected sticker. Dimension captions stay single-selection only.

**API:** `onSelectedIdsChange(selectedIds: string[])` for the full set; `onSelectedIdChange` still reports the last-clicked id for viewport-pan wiring. Duplicate fill duplicates every selected sticker together as a block (v0.2.15).

**Fix (v0.2.11):** Selection uses sticker `onClick`/`onTap` (not `onMouseDown`) so Shift/Ctrl/Cmd modifiers are read reliably and draggable `mousedown` no longer races additive select. Modifier detection accepts `PointerEvent` as well as `MouseEvent`.

**Fix (v0.2.12):** Multi-select uses a proxy selection box + `groupTransform.ts` so move / rotate / scale preserve relative sticker positions (Konva multi-node `Transformer` rotates each node around its own origin).

**Fix (v0.2.13):** Wire transformer **back** `dragstart`/`dragmove`/`dragend` (border move does not fire `transform`). Freeze proxy + skip `transformer.nodes()` refresh while interacting. `constrainMultiSelectBoundBox` allows rotation and translation (old `boundBoxFunc` blocked rotate when AABB width shrank).

**Feature (v0.2.14):** Marquee (rubber-band) selection — drag on empty canvas to draw a box; stickers whose cut-line bounds intersect the box are selected. Shift/Ctrl/Cmd adds to the current selection. A click without drag still clears selection.

**Feature (v0.2.15):** `duplicateSelectedHorizontally` / `duplicateSelectedVertically` duplicate every selected sticker together as a block (preserves relative layout) until the printable area is full. `buildGroupDuplicatesToFit` in `duplicateFill.ts`.

**Code:** `packages/react-canvas-designer/src/selection.ts`, `groupTransform.ts`, `marqueeSelection.ts`, `duplicateFill.ts` (`buildGroupDuplicatesToFit`), `resizeConstraints.ts` (`constrainMultiSelectBoundBox`), `CanvasDesigner.tsx`

## Duplicate selected sticker to fill row/column

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` | **0.2.0** | `instanceId` + `assetId` per layout item |
| `@jeffgo10/react-canvas-designer` | **0.2.9** | Duplicate selected sticker horizontally/vertically with cut-line gap |
| `@jeffgo10/react-canvas-designer` | **0.2.15** | Shift multi-select, marquee, group duplicate fill |
| `@jeffgo10/helpers` | **0.3.0** | `./image`, `./gestures`, `./browser`, `./clipboard`; CrowdBadge consumer |
| `@jeffgo10/canvas-upscaler` | **0.2.0** | Output size from layout dimensions; 1 mm corner markers |

Install both designer packages together:

```json
"@jeffgo10/shared-types": "0.2.0",
"@jeffgo10/react-canvas-designer": "0.2.9",
"@jeffgo10/react-canvas-designer": "0.2.15",
"@jeffgo10/canvas-upscaler": "0.2.0"
```

Expand All @@ -38,7 +38,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.2.9) — 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), duplicate library images on one sheet, mobile touch-friendly transformer, **duplicate selected sticker to fill row/column** (`duplicateSelectedHorizontally` / `duplicateSelectedVertically` with cut-line gap)
- [x] **1.3** `@jeffgo10/react-canvas-designer` (v0.2.15) — 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), 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**
- [x] **1.4** `@jeffgo10/canvas-upscaler` (v0.2.0) — JSON CLI; print output size from layout dimensions + DPI; 1 mm Silhouette corner markers
- [x] **1.5** `@jeffgo10/helpers` (v0.3.0) — `./image` (contour, blob URL, mobile-safe PNG download) + `./gestures` + `./browser` + `./clipboard`
- [x] **1.6** Docs test page — `apps/docs` `/stickpak`
Expand Down
9 changes: 5 additions & 4 deletions packages/react-canvas-designer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@ Drop images onto the canvas (default **A4 @ 72 DPI**, 595 × 842 px). Select a s
| `showSelectionDimensions` | `false` | On-canvas W × H captions |
| `touchFriendly` | auto (coarse pointer) | Larger transformer anchors + hit areas on touch devices |
| `backgroundImageUrl` | — | A4/page background inside Konva (`listening={false}`) — avoids mobile Save-image long-press on CSS backgrounds |
| `onSelectedIdChange` | — | Selection id callback (e.g. pause viewport pan while editing) |
| `onSelectedIdChange` | — | Primary selection id (last clicked); `null` when empty |
| `onSelectedIdsChange` | — | Full selection set (Shift/Ctrl/Cmd multi-select) |

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

Expand All @@ -107,8 +108,8 @@ Drop images onto the canvas (default **A4 @ 72 DPI**, 595 × 842 px). Select a s
| `loadLayoutFromSources({ layout, sources })` | Restore from presigned URLs |
| `clearCanvas()` | Remove all stickers |
| `arrangeAll({ gapMm?, canvasMarginMm? })` | Pack stickers by cut-line spacing |
| `duplicateSelectedHorizontally({ gapMm? })` | Copies of the selection to the right until the printable area is full; uses `autoArrangeGapMm` cut-line spacing by default |
| `duplicateSelectedVertically({ gapMm? })` | Copies downward until the printable area is full; uses `autoArrangeGapMm` cut-line spacing by default |
| `duplicateSelectedHorizontally({ gapMm? })` | Copies of the selection to the right until the printable area is full; multi-select duplicates the whole block together |
| `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 |

## Layout item identity
Expand All @@ -135,7 +136,7 @@ Re-exports from `@jeffgo10/shared-types`: `CANVAS_WIDTH`, `CANVAS_HEIGHT`, `mmTo

Margin / resize utilities: `fitItemToCanvasArea`, `clampItemPosition`, `getMinResizeScale`, `DEFAULT_MIN_RESIZE_SIZE_MM`, …

Duplicate fill helpers: `buildDuplicatesToFit`, `getAdjacentCopyPosition`.
Duplicate fill helpers: `buildDuplicatesToFit`, `buildGroupDuplicatesToFit`, `getAdjacentCopyPosition`.

Mobile touch helpers: `CANVAS_INTERACTION_STYLE`, `getTransformerTouchProfile`, `isCoarsePointerDevice`.

Expand Down
11 changes: 7 additions & 4 deletions packages/react-canvas-designer/jest.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ module.exports = {
"./src/canvasMargin.ts": threshold90,
"./src/autoArrange.ts": { ...threshold90, branches: 79 },
"./src/resizeConstraints.ts": { ...threshold90, branches: 75 },
"./src/marqueeSelection.ts": threshold90,
"./src/groupTransform.ts": { ...threshold90, branches: 80 },
"./src/selection.ts": threshold90,
"./src/selectionDimensions.ts": threshold90,
"./src/transformerTouch.ts": threshold90,
"./src/SelectionDimensionLabels.tsx": {
Expand All @@ -32,10 +35,10 @@ module.exports = {
statements: 90,
},
"./src/CanvasDesigner.tsx": {
branches: 50,
functions: 50,
lines: 55,
statements: 55,
branches: 47,
functions: 49,
lines: 49,
statements: 49,
},
"./src/duplicateFill.ts": threshold90,
},
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": "0.2.9",
"version": "0.2.15",
"description": "72 DPI A4 Konva canvas for StickPak sticker layout design",
"license": "MIT",
"repository": {
Expand Down
24 changes: 21 additions & 3 deletions packages/react-canvas-designer/src/CanvasDesigner.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,24 @@ describe("CanvasDesigner", () => {
);
});

it("calls onSelectedIdsChange with the full selection set", async () => {
const onSelectedIdsChange = jest.fn();
const ref = createRef<CanvasDesignerHandle>();
render(
<CanvasDesigner ref={ref} onSelectedIdsChange={onSelectedIdsChange} />,
);
await waitFor(() => expect(ref.current).toBeTruthy());
expect(onSelectedIdsChange).toHaveBeenCalledWith([]);

act(() => {
ref.current!.addImagesFromUrls([{ url: "blob:test", mimeType: "image/png" }]);
});

await waitFor(() =>
expect(onSelectedIdsChange).toHaveBeenLastCalledWith([expect.any(String)]),
);
});

it("renders Konva background image when backgroundImageUrl is set", async () => {
render(<CanvasDesigner backgroundImageUrl="https://example.com/a4.png" />);
await waitFor(() =>
Expand Down Expand Up @@ -191,8 +209,8 @@ describe("CanvasDesigner", () => {
});

it("returns 0 when duplicate fill adds no copies", async () => {
const buildDuplicatesToFit = jest
.spyOn(duplicateFillModule, "buildDuplicatesToFit")
const buildGroupDuplicatesToFit = jest
.spyOn(duplicateFillModule, "buildGroupDuplicatesToFit")
.mockReturnValue({ copies: [], addedCount: 0 });

const ref = createRef<CanvasDesignerHandle>();
Expand All @@ -208,6 +226,6 @@ describe("CanvasDesigner", () => {
);

expect(ref.current!.duplicateSelectedHorizontally()).toBe(0);
buildDuplicatesToFit.mockRestore();
buildGroupDuplicatesToFit.mockRestore();
});
});
Loading
Loading