diff --git a/.cursor/rules/ls-foundry-core.mdc b/.cursor/rules/ls-foundry-core.mdc
index 959857d..4e43d4b 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.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.
diff --git a/apps/docs/src/components/StickPakCanvasSection.tsx b/apps/docs/src/components/StickPakCanvasSection.tsx
index f70a4c2..b4e2e10 100644
--- a/apps/docs/src/components/StickPakCanvasSection.tsx
+++ b/apps/docs/src/components/StickPakCanvasSection.tsx
@@ -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<
@@ -503,6 +504,9 @@ function StickPakCanvasSection() {
setCanUndo(undoAvailable);
setCanRedo(redoAvailable);
}}
+ onViewportChange={({ zoom }) => {
+ setViewportZoom(zoom);
+ }}
onAutoArrange={({ allPlaced }) => {
setDuplicateMessage("");
setOverlapMessage("");
@@ -518,15 +522,45 @@ function StickPakCanvasSection() {
{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."}
+
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 −
+
+
+ {Math.round(viewportZoom * 100)}%
+
+
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 +
+
+
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
+
designerRef.current?.undo()}
diff --git a/docs/stickpak/engineering-notes.md b/docs/stickpak/engineering-notes.md
index 28426d7..595dcc8 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 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**).
diff --git a/docs/stickpak/phase-1.md b/docs/stickpak/phase-1.md
index 3fa928a..ac2585e 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.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** |
@@ -28,7 +28,7 @@ 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"
```
diff --git a/packages/react-canvas-designer/README.md b/packages/react-canvas-designer/README.md
index 79b5041..325e565 100644
--- a/packages/react-canvas-designer/README.md
+++ b/packages/react-canvas-designer/README.md
@@ -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`)
@@ -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
diff --git a/packages/react-canvas-designer/package.json b/packages/react-canvas-designer/package.json
index 6ffa7ac..997b88b 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.1.1",
+ "version": "1.2.0",
"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 e190e4c..dba48e0 100644
--- a/packages/react-canvas-designer/src/CanvasDesigner.test.tsx
+++ b/packages/react-canvas-designer/src/CanvasDesigner.test.tsx
@@ -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();
+ render( );
+ 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(
+
+
+
+
,
+ );
+
+ 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 () => {
diff --git a/packages/react-canvas-designer/src/CanvasDesigner.tsx b/packages/react-canvas-designer/src/CanvasDesigner.tsx
index 7022a88..413d82f 100644
--- a/packages/react-canvas-designer/src/CanvasDesigner.tsx
+++ b/packages/react-canvas-designer/src/CanvasDesigner.tsx
@@ -98,6 +98,12 @@ import {
} from "./transformerTouch";
import { useContainerFitScale } from "./useContainerFitScale";
import { stagePointerToDesign } from "./stagePointer";
+import {
+ clampPan,
+ composeStageTransform,
+ zoomAtPoint,
+ type CanvasViewportState,
+} from "./canvasViewport";
import {
beginPinchTransformSession,
canPinchResizeSelection,
@@ -107,7 +113,7 @@ import {
isAnyTouchOnElement,
isPinchResizeTouchCount,
parentPointToLocal,
- touchPairCentroidToStage,
+ touchPairCentroidToDesign,
transformFromPinchSession,
type PinchTransformSession,
PINCH_LIVE_NODE_EVENT,
@@ -283,8 +289,12 @@ export type CanvasDesignerProps = {
historyLimit?: number;
/** Fired when undo/redo availability changes. */
onHistoryChange?: (state: { canUndo: boolean; canRedo: boolean }) => void;
+ /** Fired when canvas zoom/pan (viewport camera) changes. */
+ onViewportChange?: (viewport: CanvasViewportState) => void;
};
+export type { CanvasViewportState };
+
export type ImageSourceFromUrl = {
url: string;
mimeType?: string;
@@ -395,6 +405,14 @@ export type CanvasDesignerHandle = {
redo: () => boolean;
canUndo: () => boolean;
canRedo: () => boolean;
+ /** Current user zoom (1 = fitted/full) and pan in Stage buffer pixels. */
+ getViewport: () => CanvasViewportState;
+ /** Set user zoom (clamped 1…4), anchoring on the viewport center. */
+ setViewportZoom: (zoom: number) => void;
+ /** Multiply current zoom by `factor`, anchoring on the viewport center. */
+ zoomBy: (factor: number) => void;
+ /** Reset zoom to 1 and pan to 0. */
+ resetViewport: () => void;
};
function DraggableImage({
@@ -655,6 +673,7 @@ export const CanvasDesigner = forwardRef([]);
const [overlapHighlightIds, setOverlapHighlightIds] = useState([]);
const [selectedIds, setSelectedIds] = useState([]);
+ const [userZoom, setUserZoom] = useState(1);
+ const [panX, setPanX] = useState(0);
+ const [panY, setPanY] = useState(0);
+ const [spacePanHeld, setSpacePanHeld] = useState(false);
+ const [isPanning, setIsPanning] = useState(false);
const [backgroundImage, setBackgroundImage] = useState(
null,
);
@@ -700,6 +724,22 @@ export const CanvasDesigner = forwardRef(null);
+ const userZoomRef = useRef(userZoom);
+ userZoomRef.current = userZoom;
+ const panXRef = useRef(panX);
+ panXRef.current = panX;
+ const panYRef = useRef(panY);
+ panYRef.current = panY;
const rotateSelectedByRef = useRef<(degrees: number) => boolean>(() => false);
const historyStacksRef = useRef(createCanvasHistoryStacks(historyLimit));
const isApplyingHistoryRef = useRef(false);
@@ -853,6 +893,89 @@ export const CanvasDesigner = forwardRef {
+ setUserZoom(next.zoom);
+ setPanX(next.panX);
+ setPanY(next.panY);
+ onViewportChangeRef.current?.(next);
+ }, []);
+
+ const zoomTowardBufferPoint = useCallback(
+ (nextZoom: number, pointerBuffer: { x: number; y: number }) => {
+ const result = zoomAtPoint({
+ currentZoom: userZoomRef.current,
+ nextZoom,
+ pan: { panX: panXRef.current, panY: panYRef.current },
+ fitScale: fitScaleRef.current > 0 ? fitScaleRef.current : 1,
+ pointerBuffer,
+ canvasWidth: canvasConfig.canvasWidth,
+ canvasHeight: canvasConfig.canvasHeight,
+ viewportWidth: shellWidthRef.current,
+ viewportHeight: shellHeightRef.current,
+ });
+ applyViewport(result);
+ },
+ [applyViewport, canvasConfig.canvasHeight, canvasConfig.canvasWidth],
+ );
+
+ const zoomTowardCenter = useCallback(
+ (nextZoom: number) => {
+ zoomTowardBufferPoint(nextZoom, {
+ x: shellWidthRef.current / 2,
+ y: shellHeightRef.current / 2,
+ });
+ },
+ [zoomTowardBufferPoint],
+ );
+
+ // Re-clamp pan when the fit baseline or shell size changes.
+ useEffect(() => {
+ if (userZoom <= 1) {
+ if (panX !== 0 || panY !== 0) {
+ applyViewport({ zoom: userZoom, panX: 0, panY: 0 });
+ }
+ return;
+ }
+ const clamped = clampPan(
+ { panX, panY },
+ {
+ userZoom,
+ fitScale: fitScale > 0 ? fitScale : 1,
+ canvasWidth: canvasConfig.canvasWidth,
+ canvasHeight: canvasConfig.canvasHeight,
+ viewportWidth: shellWidth,
+ viewportHeight: shellHeight,
+ },
+ );
+ if (clamped.panX !== panX || clamped.panY !== panY) {
+ applyViewport({ zoom: userZoom, ...clamped });
+ }
+ }, [
+ applyViewport,
+ canvasConfig.canvasHeight,
+ canvasConfig.canvasWidth,
+ fitScale,
+ panX,
+ panY,
+ shellHeight,
+ shellWidth,
+ userZoom,
+ ]);
const selectedIdSet = useMemo(
() => new Set(selectedIds),
@@ -1221,6 +1344,13 @@ export const CanvasDesigner = forwardRef {
+ spacePanHeldRef.current = false;
+ setSpacePanHeld(false);
+ panSessionRef.current = null;
+ setIsPanning(false);
+ };
+
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Shift") {
applyShiftRotationSnap(true);
@@ -1230,6 +1360,16 @@ export const CanvasDesigner = forwardRef {
applyShiftRotationSnap(false);
+ clearSpacePan();
};
window.addEventListener("keydown", onKeyDown);
@@ -1282,6 +1426,76 @@ export const CanvasDesigner = forwardRef {
+ const shell = canvasShellRef.current;
+ if (!shell || !canvasShellReady) {
+ return;
+ }
+
+ const onWheel = (event: WheelEvent) => {
+ event.preventDefault();
+ const rect = shell.getBoundingClientRect();
+ const pointerBuffer = {
+ x: event.clientX - rect.left,
+ y: event.clientY - rect.top,
+ };
+ const factor = event.deltaY < 0 ? 1.1 : 1 / 1.1;
+ zoomTowardBufferPoint(userZoomRef.current * factor, pointerBuffer);
+ };
+
+ shell.addEventListener("wheel", onWheel, { passive: false });
+ return () => shell.removeEventListener("wheel", onWheel);
+ }, [canvasShellReady, zoomTowardBufferPoint]);
+
+ // Spacebar + drag pan (window listeners while a pan session is active).
+ useEffect(() => {
+ if (!isPanning) {
+ return;
+ }
+
+ const onPointerMove = (event: PointerEvent) => {
+ const session = panSessionRef.current;
+ if (!session) {
+ return;
+ }
+ const nextPan = clampPan(
+ {
+ panX: session.originPanX + (event.clientX - session.startClientX),
+ panY: session.originPanY + (event.clientY - session.startClientY),
+ },
+ {
+ userZoom: userZoomRef.current,
+ fitScale: fitScaleRef.current > 0 ? fitScaleRef.current : 1,
+ canvasWidth: canvasConfig.canvasWidth,
+ canvasHeight: canvasConfig.canvasHeight,
+ viewportWidth: shellWidthRef.current,
+ viewportHeight: shellHeightRef.current,
+ },
+ );
+ applyViewport({ zoom: userZoomRef.current, ...nextPan });
+ };
+
+ const onPointerUp = () => {
+ panSessionRef.current = null;
+ setIsPanning(false);
+ };
+
+ window.addEventListener("pointermove", onPointerMove);
+ window.addEventListener("pointerup", onPointerUp);
+ window.addEventListener("pointercancel", onPointerUp);
+ return () => {
+ window.removeEventListener("pointermove", onPointerMove);
+ window.removeEventListener("pointerup", onPointerUp);
+ window.removeEventListener("pointercancel", onPointerUp);
+ };
+ }, [
+ applyViewport,
+ canvasConfig.canvasHeight,
+ canvasConfig.canvasWidth,
+ isPanning,
+ ]);
+
const runAutoArrange = useCallback(
async (options?: AutoArrangeOptions): Promise => {
clearSelection();
@@ -1676,11 +1890,12 @@ export const CanvasDesigner = forwardRef ({
+ zoom: userZoomRef.current,
+ panX: panXRef.current,
+ panY: panYRef.current,
+ }),
+ [],
+ );
+
+ const setViewportZoom = useCallback(
+ (zoom: number) => {
+ zoomTowardCenter(zoom);
+ },
+ [zoomTowardCenter],
+ );
+
+ const zoomBy = useCallback(
+ (factor: number) => {
+ zoomTowardCenter(userZoomRef.current * factor);
+ },
+ [zoomTowardCenter],
+ );
+
+ const resetViewport = useCallback(() => {
+ applyViewport({ zoom: 1, panX: 0, panY: 0 });
+ }, [applyViewport]);
+
const handle = useMemo(
() => ({
exportLayout,
@@ -2668,6 +2921,10 @@ export const CanvasDesigner = forwardRef {
if (node) {
shapeRefs.current.set(item.instanceId, node);
@@ -2913,7 +3177,7 @@ export const CanvasDesigner = forwardRef 0 ? effectiveScale : 1}
liveDimensionFormatting={{
unit: dimensionUnit,
dpi: selectionDpi,
diff --git a/packages/react-canvas-designer/src/canvasViewport.test.ts b/packages/react-canvas-designer/src/canvasViewport.test.ts
new file mode 100644
index 0000000..0c61b2f
--- /dev/null
+++ b/packages/react-canvas-designer/src/canvasViewport.test.ts
@@ -0,0 +1,115 @@
+import {
+ clampPan,
+ clampUserZoom,
+ composeStageTransform,
+ DEFAULT_MAX_USER_ZOOM,
+ DEFAULT_MIN_USER_ZOOM,
+ zoomAtPoint,
+} from "./canvasViewport";
+
+describe("clampUserZoom", () => {
+ it("clamps to the default 1…4 range", () => {
+ expect(clampUserZoom(0.5)).toBe(DEFAULT_MIN_USER_ZOOM);
+ expect(clampUserZoom(1)).toBe(1);
+ expect(clampUserZoom(2.5)).toBe(2.5);
+ expect(clampUserZoom(10)).toBe(DEFAULT_MAX_USER_ZOOM);
+ });
+
+ it("falls back to min for non-finite values", () => {
+ expect(clampUserZoom(Number.NaN)).toBe(DEFAULT_MIN_USER_ZOOM);
+ expect(clampUserZoom(Number.POSITIVE_INFINITY)).toBe(DEFAULT_MIN_USER_ZOOM);
+ });
+});
+
+describe("clampPan", () => {
+ const base = {
+ fitScale: 0.5,
+ canvasWidth: 600,
+ canvasHeight: 800,
+ viewportWidth: 300,
+ viewportHeight: 400,
+ };
+
+ it("forces pan to 0 at zoom 1", () => {
+ expect(
+ clampPan({ panX: -50, panY: -80 }, { ...base, userZoom: 1 }),
+ ).toEqual({ panX: 0, panY: 0 });
+ });
+
+ it("clamps pan so content stays overlapping the viewport when zoomed in", () => {
+ // content = 600 * 0.5 * 2 = 600, viewport = 300 → panX in [-300, 0]
+ expect(
+ clampPan({ panX: -50, panY: -100 }, { ...base, userZoom: 2 }),
+ ).toEqual({ panX: -50, panY: -100 });
+
+ expect(
+ clampPan({ panX: -500, panY: 20 }, { ...base, userZoom: 2 }),
+ ).toEqual({ panX: -300, panY: 0 });
+ });
+});
+
+describe("zoomAtPoint", () => {
+ const base = {
+ fitScale: 0.5,
+ canvasWidth: 600,
+ canvasHeight: 800,
+ viewportWidth: 300,
+ viewportHeight: 400,
+ pointerBuffer: { x: 150, y: 200 },
+ pan: { panX: 0, panY: 0 },
+ };
+
+ it("keeps the design point under the pointer fixed when zooming in", () => {
+ const result = zoomAtPoint({
+ ...base,
+ currentZoom: 1,
+ nextZoom: 2,
+ });
+
+ expect(result.zoom).toBe(2);
+ // design under pointer at zoom 1: (150/0.5, 200/0.5) = (300, 400)
+ // new pan: 150 - 300*1 = -150, 200 - 400*1 = -200
+ expect(result.panX).toBeCloseTo(-150);
+ expect(result.panY).toBeCloseTo(-200);
+ });
+
+ it("resets pan when zooming back to 1", () => {
+ const result = zoomAtPoint({
+ ...base,
+ currentZoom: 2,
+ nextZoom: 1,
+ pan: { panX: -150, panY: -200 },
+ });
+
+ expect(result).toEqual({ zoom: 1, panX: 0, panY: 0 });
+ });
+
+ it("clamps nextZoom to max", () => {
+ const result = zoomAtPoint({
+ ...base,
+ currentZoom: 1,
+ nextZoom: 99,
+ });
+ expect(result.zoom).toBe(DEFAULT_MAX_USER_ZOOM);
+ });
+});
+
+describe("composeStageTransform", () => {
+ it("multiplies fit scale by user zoom and zeros pan at zoom 1", () => {
+ expect(composeStageTransform(0.5, 1, { panX: -10, panY: -20 })).toEqual({
+ scaleX: 0.5,
+ scaleY: 0.5,
+ x: 0,
+ y: 0,
+ });
+ });
+
+ it("applies pan when zoomed in", () => {
+ expect(composeStageTransform(0.5, 2, { panX: -40, panY: -80 })).toEqual({
+ scaleX: 1,
+ scaleY: 1,
+ x: -40,
+ y: -80,
+ });
+ });
+});
diff --git a/packages/react-canvas-designer/src/canvasViewport.ts b/packages/react-canvas-designer/src/canvasViewport.ts
new file mode 100644
index 0000000..67ed6ef
--- /dev/null
+++ b/packages/react-canvas-designer/src/canvasViewport.ts
@@ -0,0 +1,169 @@
+/** Minimum user zoom multiplier relative to the fit/full baseline (1 = fitted). */
+export const DEFAULT_MIN_USER_ZOOM = 1;
+
+/** Maximum user zoom multiplier relative to the fit/full baseline. */
+export const DEFAULT_MAX_USER_ZOOM = 4;
+
+export type ViewportPan = {
+ panX: number;
+ panY: number;
+};
+
+export type CanvasViewportState = ViewportPan & {
+ /** Multiplier on fit/full baseline scale. `1` = fitted or full-size view. */
+ zoom: number;
+};
+
+export type StageTransform = {
+ scaleX: number;
+ scaleY: number;
+ x: number;
+ y: number;
+};
+
+export function clampUserZoom(
+ zoom: number,
+ minZoom = DEFAULT_MIN_USER_ZOOM,
+ maxZoom = DEFAULT_MAX_USER_ZOOM,
+): number {
+ if (!Number.isFinite(zoom)) {
+ return minZoom;
+ }
+ return Math.min(maxZoom, Math.max(minZoom, zoom));
+}
+
+/**
+ * Clamp pan so the design canvas stays overlapping the viewport shell.
+ * At zoom ≤ 1, pan is forced to 0 (nothing to pan).
+ */
+export function clampPan(
+ pan: ViewportPan,
+ options: {
+ userZoom: number;
+ fitScale: number;
+ canvasWidth: number;
+ canvasHeight: number;
+ viewportWidth: number;
+ viewportHeight: number;
+ },
+): ViewportPan {
+ const { userZoom, fitScale, canvasWidth, canvasHeight, viewportWidth, viewportHeight } =
+ options;
+
+ if (userZoom <= 1 || fitScale <= 0) {
+ return { panX: 0, panY: 0 };
+ }
+
+ const effectiveScale = fitScale * userZoom;
+ const contentWidth = canvasWidth * effectiveScale;
+ const contentHeight = canvasHeight * effectiveScale;
+
+ // Content larger than viewport: pan in [viewportSize - contentSize, 0].
+ const clampedX =
+ contentWidth <= viewportWidth
+ ? 0
+ : Math.min(0, Math.max(viewportWidth - contentWidth, pan.panX));
+ const clampedY =
+ contentHeight <= viewportHeight
+ ? 0
+ : Math.min(0, Math.max(viewportHeight - contentHeight, pan.panY));
+
+ return { panX: clampedX, panY: clampedY };
+}
+
+/**
+ * Zoom toward a buffer-space point (relative to the Stage container top-left)
+ * so the design point under the cursor stays fixed.
+ */
+export function zoomAtPoint(
+ options: {
+ currentZoom: number;
+ nextZoom: number;
+ pan: ViewportPan;
+ fitScale: number;
+ /** Pointer position in Stage buffer / container pixels. */
+ pointerBuffer: { x: number; y: number };
+ canvasWidth: number;
+ canvasHeight: number;
+ viewportWidth: number;
+ viewportHeight: number;
+ minZoom?: number;
+ maxZoom?: number;
+ },
+): CanvasViewportState {
+ const {
+ currentZoom,
+ fitScale,
+ pointerBuffer,
+ canvasWidth,
+ canvasHeight,
+ viewportWidth,
+ viewportHeight,
+ minZoom = DEFAULT_MIN_USER_ZOOM,
+ maxZoom = DEFAULT_MAX_USER_ZOOM,
+ } = options;
+
+ const zoom = clampUserZoom(options.nextZoom, minZoom, maxZoom);
+ const safeFit = fitScale > 0 ? fitScale : 1;
+ const oldScale = safeFit * clampUserZoom(currentZoom, minZoom, maxZoom);
+ const newScale = safeFit * zoom;
+
+ if (oldScale <= 0 || newScale <= 0 || zoom === clampUserZoom(currentZoom, minZoom, maxZoom)) {
+ if (zoom <= 1) {
+ return { zoom, panX: 0, panY: 0 };
+ }
+ const pan = clampPan(options.pan, {
+ userZoom: zoom,
+ fitScale: safeFit,
+ canvasWidth,
+ canvasHeight,
+ viewportWidth,
+ viewportHeight,
+ });
+ return { zoom, ...pan };
+ }
+
+ // Design point under pointer before zoom.
+ const designX = (pointerBuffer.x - options.pan.panX) / oldScale;
+ const designY = (pointerBuffer.y - options.pan.panY) / oldScale;
+
+ // New pan keeps that design point under the same buffer pixel.
+ const nextPan: ViewportPan = {
+ panX: pointerBuffer.x - designX * newScale,
+ panY: pointerBuffer.y - designY * newScale,
+ };
+
+ if (zoom <= 1) {
+ return { zoom, panX: 0, panY: 0 };
+ }
+
+ const pan = clampPan(nextPan, {
+ userZoom: zoom,
+ fitScale: safeFit,
+ canvasWidth,
+ canvasHeight,
+ viewportWidth,
+ viewportHeight,
+ });
+
+ return { zoom, ...pan };
+}
+
+export function composeStageTransform(
+ fitScale: number,
+ userZoom: number,
+ pan: ViewportPan,
+): StageTransform {
+ const safeFit = fitScale > 0 ? fitScale : 1;
+ const zoom = clampUserZoom(userZoom);
+ const scale = safeFit * zoom;
+ if (zoom <= 1) {
+ return { scaleX: scale, scaleY: scale, x: 0, y: 0 };
+ }
+ return {
+ scaleX: scale,
+ scaleY: scale,
+ x: pan.panX,
+ y: pan.panY,
+ };
+}
diff --git a/packages/react-canvas-designer/src/index.ts b/packages/react-canvas-designer/src/index.ts
index 93a325c..15350a5 100644
--- a/packages/react-canvas-designer/src/index.ts
+++ b/packages/react-canvas-designer/src/index.ts
@@ -3,6 +3,7 @@ export {
type CanvasDesignerHandle,
type CanvasDesignerProps,
type CanvasInteractionMode,
+ type CanvasViewportState,
type DuplicateFillHandleOptions,
type ImageSourceFromUrl,
type LayoutLoadInput,
@@ -10,6 +11,15 @@ export {
type SetSelectedCutLineOffsetOptions,
type SetSelectedSizeOptions,
} from "./CanvasDesigner";
+export {
+ clampPan,
+ clampUserZoom,
+ composeStageTransform,
+ DEFAULT_MAX_USER_ZOOM,
+ DEFAULT_MIN_USER_ZOOM,
+ zoomAtPoint,
+} from "./canvasViewport";
+export type { ViewportPan, StageTransform } from "./canvasViewport";
export type { AutoArrangeOptions } from "./autoArrange";
export {
verifyItemOverlaps,
diff --git a/packages/react-canvas-designer/src/selectedStickerPinch.test.ts b/packages/react-canvas-designer/src/selectedStickerPinch.test.ts
index 0d5becd..7581b4f 100644
--- a/packages/react-canvas-designer/src/selectedStickerPinch.test.ts
+++ b/packages/react-canvas-designer/src/selectedStickerPinch.test.ts
@@ -11,7 +11,9 @@ import {
localPointToParentOffset,
parentPointToLocal,
scaleFromPinchSession,
+ touchClientToDesign,
touchClientToStage,
+ touchPairCentroidToDesign,
touchPairCentroidToStage,
transformFromPinchSession,
} from "./selectedStickerPinch";
@@ -54,6 +56,25 @@ describe("selectedStickerPinch", () => {
).toEqual({ x: 50, y: 100 });
});
+ it("maps client touches to design coords under scale and pan", () => {
+ // buffer (100, 200), pan (-20, -40), scale 0.5 → design (240, 480)
+ expect(
+ touchClientToDesign({ clientX: 100, clientY: 200 }, containerRect, 0.5, -20, -40),
+ ).toEqual({ x: 240, y: 480 });
+ expect(
+ touchPairCentroidToDesign(
+ [
+ { clientX: 80, clientY: 160 },
+ { clientX: 120, clientY: 240 },
+ ],
+ containerRect,
+ 0.5,
+ -20,
+ -40,
+ ),
+ ).toEqual({ x: 240, y: 480 });
+ });
+
it("scales around a local anchor so the pivot stays fixed", () => {
const session = beginPinchTransformSession(
100,
diff --git a/packages/react-canvas-designer/src/selectedStickerPinch.ts b/packages/react-canvas-designer/src/selectedStickerPinch.ts
index 91bade6..54ec5ae 100644
--- a/packages/react-canvas-designer/src/selectedStickerPinch.ts
+++ b/packages/react-canvas-designer/src/selectedStickerPinch.ts
@@ -71,6 +71,40 @@ export function touchPairCentroidToStage(
return touchClientToStage(centroid, containerRect, stageWidth, stageHeight);
}
+/**
+ * Map a client touch into design-canvas coordinates under Stage scale + pan.
+ * Prefer this over {@link touchClientToStage} when the viewport can zoom/pan.
+ */
+export function touchClientToDesign(
+ touch: TouchPoint,
+ containerRect: Pick,
+ effectiveScale: number,
+ panX = 0,
+ panY = 0,
+): StagePoint {
+ if (!Number.isFinite(effectiveScale) || effectiveScale === 0) {
+ return { x: 0, y: 0 };
+ }
+
+ const bufferX = touch.clientX - containerRect.left;
+ const bufferY = touch.clientY - containerRect.top;
+ return {
+ x: (bufferX - panX) / effectiveScale,
+ y: (bufferY - panY) / effectiveScale,
+ };
+}
+
+export function touchPairCentroidToDesign(
+ pair: [TouchPoint, TouchPoint],
+ containerRect: Pick,
+ effectiveScale: number,
+ panX = 0,
+ panY = 0,
+): StagePoint {
+ const centroid = getTouchPairCentroid(pair[0], pair[1]);
+ return touchClientToDesign(centroid, containerRect, effectiveScale, panX, panY);
+}
+
/** Maps a local sticker point to parent offset after scale + rotation (Konva order). */
export function localPointToParentOffset(
local: StagePoint,
diff --git a/packages/react-canvas-designer/src/stagePointer.test.ts b/packages/react-canvas-designer/src/stagePointer.test.ts
index 1b2169f..a64769f 100644
--- a/packages/react-canvas-designer/src/stagePointer.test.ts
+++ b/packages/react-canvas-designer/src/stagePointer.test.ts
@@ -6,16 +6,32 @@ describe("stagePointerToDesign", () => {
getPointerPosition: () => ({ x: 50, y: 100 }),
scaleX: () => 0.5,
scaleY: () => 0.5,
+ x: () => 0,
+ y: () => 0,
};
expect(stagePointerToDesign(stage)).toEqual({ x: 100, y: 200 });
});
+ it("subtracts stage pan before dividing by scale", () => {
+ const stage = {
+ getPointerPosition: () => ({ x: 50, y: 100 }),
+ scaleX: () => 0.5,
+ scaleY: () => 0.5,
+ x: () => -20,
+ y: () => -40,
+ };
+
+ expect(stagePointerToDesign(stage)).toEqual({ x: 140, y: 280 });
+ });
+
it("returns null when the pointer position is missing", () => {
const stage = {
getPointerPosition: () => null,
scaleX: () => 1,
scaleY: () => 1,
+ x: () => 0,
+ y: () => 0,
};
expect(stagePointerToDesign(stage)).toBeNull();
diff --git a/packages/react-canvas-designer/src/stagePointer.ts b/packages/react-canvas-designer/src/stagePointer.ts
index ad8ec66..ad5569d 100644
--- a/packages/react-canvas-designer/src/stagePointer.ts
+++ b/packages/react-canvas-designer/src/stagePointer.ts
@@ -1,8 +1,8 @@
import type Konva from "konva";
-/** Map stage pointer position to design-canvas coordinates (accounts for stage scale). */
+/** Map stage pointer position to design-canvas coordinates (accounts for stage scale + pan). */
export function stagePointerToDesign(
- stage: Pick,
+ stage: Pick,
): { x: number; y: number } | null {
const pos = stage.getPointerPosition();
if (!pos) {
@@ -16,7 +16,7 @@ export function stagePointerToDesign(
}
return {
- x: pos.x / scaleX,
- y: pos.y / scaleY,
+ x: (pos.x - stage.x()) / scaleX,
+ y: (pos.y - stage.y()) / scaleY,
};
}