# Managing Canvas Operations

> Managing canvas operations in StickerNest including pan, zoom, selection, viewport, and canvas interactions. Use when implementing canvas navigation, selection tools, drag operations, viewport manipulation, or working with canvas coordinates. Covers useCanvasStore viewport, selection state, and gesture handling.

- Skill: `majiayu000/managing-canvas-operations` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds add majiayu000/managing-canvas-operations`
- Raw SKILL.md: https://api.skillmd.com/api/skills/majiayu000/managing-canvas-operations/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: majiayu000 (https://skillmd.com/u/majiayu000)
- Updated: 2026-09-09
- Page: https://skillmd.com/skills/majiayu000/managing-canvas-operations

---


# Managing Canvas Operations

StickerNest's canvas is the core workspace where widgets and stickers live. This skill covers viewport manipulation, selection, and canvas interactions.

## Canvas Architecture Overview

```
┌─────────────────────────────────────────────────────────────┐
│  CanvasPage                                                 │
│  ┌───────────────────────────────────────────────────────┐  │
│  │  CanvasRenderer (DOM mode)                            │  │
│  │  ┌─────────────────────────────────────────────────┐  │  │
│  │  │  Viewport Transform Layer                       │  │  │
│  │  │  transform: translate(panX, panY) scale(zoom)   │  │  │
│  │  │  ┌─────────────────────────────────────────┐    │  │  │
│  │  │  │  Canvas Content                         │    │  │  │
│  │  │  │  - Widgets                              │    │  │  │
│  │  │  │  - Stickers                             │    │  │  │
│  │  │  │  - Grid                                 │    │  │  │
│  │  │  └─────────────────────────────────────────┘    │  │  │
│  │  └─────────────────────────────────────────────────┘  │  │
│  └───────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────┘
```

## Viewport State

The viewport is managed in `useCanvasStore`:

```typescript
interface ViewportState {
  panX: number;      // Horizontal offset (pixels)
  panY: number;      // Vertical offset (pixels)
  zoom: number;      // Zoom level (1 = 100%, 0.5 = 50%, 2 = 200%)
  width: number;     // Canvas width
  height: number;    // Canvas height
}

// Access viewport
const viewport = useCanvasStore((s) => s.viewport);
const { panX, panY, zoom } = viewport;

// Update viewport
useCanvasStore.getState().setViewport({ zoom: 1.5 });
```

## Pan Operations

### Programmatic Pan

```typescript
// Relative pan (add to current position)
useCanvasStore.getState().pan(deltaX, deltaY);

// Absolute pan (set position)
useCanvasStore.getState().setViewport({ panX: 100, panY: 200 });

// Pan to center on a point
function panToCenter(targetX: number, targetY: number) {
  const { width, height, zoom } = useCanvasStore.getState().viewport;
  useCanvasStore.getState().setViewport({
    panX: (width / 2) - (targetX * zoom),
    panY: (height / 2) - (targetY * zoom),
  });
}
```

### Mouse/Touch Pan

```typescript
function usePanGesture() {
  const [isPanning, setIsPanning] = useState(false);
  const lastPos = useRef({ x: 0, y: 0 });

  const onPointerDown = (e: React.PointerEvent) => {
    if (e.button === 1 || e.button === 2 || spaceHeld) { // Middle click, right click, or space
      setIsPanning(true);
      lastPos.current = { x: e.clientX, y: e.clientY };
      e.currentTarget.setPointerCapture(e.pointerId);
    }
  };

  const onPointerMove = (e: React.PointerEvent) => {
    if (!isPanning) return;
    const deltaX = e.clientX - lastPos.current.x;
    const deltaY = e.clientY - lastPos.current.y;
    useCanvasStore.getState().pan(deltaX, deltaY);
    lastPos.current = { x: e.clientX, y: e.clientY };
  };

  const onPointerUp = () => setIsPanning(false);

  return { onPointerDown, onPointerMove, onPointerUp, isPanning };
}
```

## Zoom Operations

### Programmatic Zoom

```typescript
// Zoom to level
useCanvasStore.getState().setViewport({ zoom: 1.5 });

// Zoom relative (multiply current)
const { zoom } = useCanvasStore.getState().viewport;
useCanvasStore.getState().setViewport({ zoom: zoom * 1.1 });

// Zoom centered on point
function zoomAtPoint(factor: number, centerX: number, centerY: number) {
  const { panX, panY, zoom } = useCanvasStore.getState().viewport;
  const newZoom = Math.max(0.1, Math.min(5, zoom * factor)); // Clamp 10%-500%

  // Adjust pan to keep point stationary
  const scale = newZoom / zoom;
  const newPanX = centerX - (centerX - panX) * scale;
  const newPanY = centerY - (centerY - panY) * scale;

  useCanvasStore.getState().setViewport({
    zoom: newZoom,
    panX: newPanX,
    panY: newPanY,
  });
}
```

### Mouse Wheel Zoom

```typescript
function useWheelZoom() {
  const onWheel = useCallback((e: WheelEvent) => {
    e.preventDefault();
    const factor = e.deltaY > 0 ? 0.9 : 1.1; // Zoom out/in
    zoomAtPoint(factor, e.clientX, e.clientY);
  }, []);

  useEffect(() => {
    const canvas = document.getElementById('canvas');
    canvas?.addEventListener('wheel', onWheel, { passive: false });
    return () => canvas?.removeEventListener('wheel', onWheel);
  }, [onWheel]);
}
```

### Pinch-to-Zoom (Touch)

```typescript
function usePinchZoom() {
  const initialDistance = useRef(0);
  const initialZoom = useRef(1);

  const onTouchStart = (e: TouchEvent) => {
    if (e.touches.length === 2) {
      const dx = e.touches[0].clientX - e.touches[1].clientX;
      const dy = e.touches[0].clientY - e.touches[1].clientY;
      initialDistance.current = Math.hypot(dx, dy);
      initialZoom.current = useCanvasStore.getState().viewport.zoom;
    }
  };

  const onTouchMove = (e: TouchEvent) => {
    if (e.touches.length === 2) {
      const dx = e.touches[0].clientX - e.touches[1].clientX;
      const dy = e.touches[0].clientY - e.touches[1].clientY;
      const distance = Math.hypot(dx, dy);
      const scale = distance / initialDistance.current;
      const newZoom = Math.max(0.1, Math.min(5, initialZoom.current * scale));

      // Center point between fingers
      const centerX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
      const centerY = (e.touches[0].clientY + e.touches[1].clientY) / 2;

      zoomAtPoint(newZoom / useCanvasStore.getState().viewport.zoom, centerX, centerY);
    }
  };
}
```

## Selection State

```typescript
interface SelectionState {
  selectedIds: Set<string>;      // All selected widget IDs
  primaryId: string | null;      // Primary selection (for single-select ops)
  mode: 'single' | 'multi';      // Selection mode
  isSelecting: boolean;          // Currently drag-selecting
  selectionBox: {                // Drag selection rectangle
    startX: number;
    startY: number;
    endX: number;
    endY: number;
  } | null;
}
```

### Selection Operations

```typescript
// Select single widget
useCanvasStore.getState().selectWidget(widgetId);

// Add to selection (multi-select)
useCanvasStore.getState().addToSelection(widgetId);

// Toggle selection
useCanvasStore.getState().toggleSelection(widgetId);

// Clear selection
useCanvasStore.getState().clearSelection();

// Select multiple
useCanvasStore.getState().setSelection(new Set(['id1', 'id2', 'id3']));

// Check if selected
const isSelected = useCanvasStore((s) => s.selection.selectedIds.has(widgetId));
```

### Drag Selection Box

```typescript
function useDragSelection() {
  const startSelection = (x: number, y: number) => {
    useCanvasStore.getState().setSelectionBox({
      startX: x, startY: y, endX: x, endY: y
    });
  };

  const updateSelection = (x: number, y: number) => {
    const box = useCanvasStore.getState().selection.selectionBox;
    if (box) {
      useCanvasStore.getState().setSelectionBox({
        ...box, endX: x, endY: y
      });
    }
  };

  const finishSelection = () => {
    const box = useCanvasStore.getState().selection.selectionBox;
    if (box) {
      // Find widgets inside box
      const widgets = useCanvasStore.getState().widgets;
      const selected = new Set<string>();

      const minX = Math.min(box.startX, box.endX);
      const maxX = Math.max(box.startX, box.endX);
      const minY = Math.min(box.startY, box.endY);
      const maxY = Math.max(box.startY, box.endY);

      widgets.forEach((widget, id) => {
        if (widget.x >= minX && widget.x + widget.width <= maxX &&
            widget.y >= minY && widget.y + widget.height <= maxY) {
          selected.add(id);
        }
      });

      useCanvasStore.getState().setSelection(selected);
      useCanvasStore.getState().setSelectionBox(null);
    }
  };
}
```

## Coordinate Conversions

### Screen to Canvas Coordinates

```typescript
function screenToCanvas(screenX: number, screenY: number): { x: number; y: number } {
  const { panX, panY, zoom } = useCanvasStore.getState().viewport;
  const canvasEl = document.getElementById('canvas');
  const rect = canvasEl?.getBoundingClientRect() ?? { left: 0, top: 0 };

  return {
    x: (screenX - rect.left - panX) / zoom,
    y: (screenY - rect.top - panY) / zoom,
  };
}
```

### Canvas to Screen Coordinates

```typescript
function canvasToScreen(canvasX: number, canvasY: number): { x: number; y: number } {
  const { panX, panY, zoom } = useCanvasStore.getState().viewport;
  const canvasEl = document.getElementById('canvas');
  const rect = canvasEl?.getBoundingClientRect() ?? { left: 0, top: 0 };

  return {
    x: canvasX * zoom + panX + rect.left,
    y: canvasY * zoom + panY + rect.top,
  };
}
```

## Grid Snapping

```typescript
interface GridSettings {
  snapToGrid: boolean;
  gridSize: number;        // e.g., 20 pixels
  showGrid: boolean;
  snapToCenter: boolean;
  showCenterGuides: boolean;
}

// Snap position to grid
function snapToGrid(pos: { x: number; y: number }): { x: number; y: number } {
  const { snapToGrid, gridSize } = useCanvasStore.getState().grid;
  if (!snapToGrid) return pos;

  return {
    x: Math.round(pos.x / gridSize) * gridSize,
    y: Math.round(pos.y / gridSize) * gridSize,
  };
}
```

## Keyboard Shortcuts

```typescript
// Common canvas shortcuts
useEffect(() => {
  const handleKeyDown = (e: KeyboardEvent) => {
    // Zoom shortcuts
    if (e.ctrlKey || e.metaKey) {
      if (e.key === '=' || e.key === '+') {
        e.preventDefault();
        zoomIn();
      } else if (e.key === '-') {
        e.preventDefault();
        zoomOut();
      } else if (e.key === '0') {
        e.preventDefault();
        resetZoom(); // 100%
      }
    }

    // Selection shortcuts
    if (e.key === 'Escape') {
      clearSelection();
    }
    if (e.key === 'a' && (e.ctrlKey || e.metaKey)) {
      e.preventDefault();
      selectAll();
    }
    if (e.key === 'Delete' || e.key === 'Backspace') {
      deleteSelected();
    }
  };

  window.addEventListener('keydown', handleKeyDown);
  return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
```

## Fit to Content

```typescript
function fitToContent() {
  const widgets = useCanvasStore.getState().widgets;
  if (widgets.size === 0) return;

  // Find bounding box of all widgets
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;

  widgets.forEach((widget) => {
    minX = Math.min(minX, widget.x);
    minY = Math.min(minY, widget.y);
    maxX = Math.max(maxX, widget.x + widget.width);
    maxY = Math.max(maxY, widget.y + widget.height);
  });

  const contentWidth = maxX - minX;
  const contentHeight = maxY - minY;
  const { width: viewWidth, height: viewHeight } = useCanvasStore.getState().viewport;

  // Calculate zoom to fit with padding
  const padding = 50;
  const zoomX = (viewWidth - padding * 2) / contentWidth;
  const zoomY = (viewHeight - padding * 2) / contentHeight;
  const newZoom = Math.min(zoomX, zoomY, 2); // Cap at 200%

  // Center content
  const centerX = (minX + maxX) / 2;
  const centerY = (minY + maxY) / 2;

  useCanvasStore.getState().setViewport({
    zoom: newZoom,
    panX: viewWidth / 2 - centerX * newZoom,
    panY: viewHeight / 2 - centerY * newZoom,
  });
}
```

## Reference Files

| File | Purpose |
|------|---------|
| `src/state/useCanvasStore.ts` | Viewport and selection state |
| `src/components/canvas/CanvasRenderer.tsx` | DOM canvas rendering |
| `src/components/canvas/CanvasViewport.tsx` | Viewport transform layer |
| `src/hooks/useCanvasGestures.ts` | Pan/zoom gesture handling |
| `src/utils/canvasCoordinates.ts` | Coordinate conversion utilities |

