# @blinksgg/canvas

**v3.0 — React Compiler**

A touch-first canvas library for node-based editors with drag, resize, pan/zoom, input-aware gestures, configurable event-action mappings, command palette, and optional Supabase sync.

**[Core Principles](./PRINCIPLES.md)** | **[Changelog](./CHANGELOG.md)** | **[API Stability](./docs/api-stability.md)** | **[Migration Guide](./docs/migration-v1.md)** | **[TODO / Roadmap](./TODO.md)**

## Quick Start

```tsx
import { Provider as JotaiProvider } from 'jotai';
import { Canvas, CanvasStyleProvider, registerBuiltinCommands } from '@blinksgg/canvas';

registerBuiltinCommands();

function App() {
  return (
    <JotaiProvider>
      <CanvasStyleProvider isDark={true}>
        <Canvas
          renderNode={({ node, isSelected }) => (
            <div className={isSelected ? 'ring-2 ring-blue-500' : ''}>
              {node.label}
            </div>
          )}
        />
      </CanvasStyleProvider>
    </JotaiProvider>
  );
}
```

## Architecture

### Package Structure

```
packages/canvas/src/
├── core/                          # Headless state (Jotai + Graphology)
│   ├── types.ts                   # Core type definitions
│   ├── graph-store.ts             # graphAtom, positions, edges, drag state
│   ├── viewport-store.ts          # zoom, pan, coordinate transforms
│   ├── selection-store.ts         # selectedNodeIdsAtom, selectedEdgeIdAtom
│   ├── input-classifier.ts        # finger/pencil/mouse classification
│   ├── input-store.ts             # Active pointer tracking, stylus detection
│   ├── gesture-resolver.ts        # Gesture intent resolution + palm rejection
│   ├── interaction-store.ts       # Input modes (picking, text), feedback
│   ├── action-executor.ts         # Execute actions by ID with context
│   ├── action-registry.ts         # Global action registry (built-in + custom)
│   ├── settings-types.ts          # Event types, action IDs, presets
│   ├── settings-store.ts          # Event-action mappings, localStorage persistence
│   ├── history-store.ts           # Delta-based undo/redo (50 entries)
│   ├── clipboard-store.ts         # Local-first copy/cut/paste/duplicate
│   ├── snap-store.ts              # Snap-to-grid with configurable size
│   ├── virtualization-store.ts    # Viewport culling for large graphs
│   ├── spatial-index.ts           # SpatialGrid for O(visible) culling
│   ├── perf.ts                    # Performance instrumentation (opt-in)
│   ├── sync-store.ts              # Sync status, mutation queue
│   ├── locked-node-store.ts       # Node detail/lock view
│   ├── group-store.ts             # Node grouping, collapse/expand
│   ├── search-store.ts            # Search query, results, navigation
│   ├── port-types.ts              # Connection port definitions
│   ├── gesture-rules.ts           # Composable gesture rule system
│   ├── gesture-rule-store.ts      # Gesture rule state management
│   └── node-type-registry.tsx     # Custom node type registration
│
├── components/                    # React UI
│   ├── Canvas.tsx                 # Main orchestrator
│   ├── Viewport.tsx               # Pan/zoom with inertia + gesture handling
│   ├── Node.tsx                   # Draggable/resizable node
│   ├── GroupNode.tsx              # Collapsible container for grouped nodes
│   ├── CanvasAnimations.tsx       # CSS keyframe injection for search pulse + edge transitions
│   ├── EdgeLabelEditor.tsx        # Inline edge label editing overlay
│   ├── NodeRenderer.tsx           # Renders visible nodes (virtualized)
│   ├── EdgeRenderer.tsx           # Renders visible edges (+ departing edge animations)
│   ├── Grid.tsx                   # Background grid + crosshairs
│   ├── ViewportControls.tsx       # Floating zoom +/- buttons
│   ├── SettingsPanel.tsx          # Headless settings (className-based)
│   ├── NodeContextMenu.tsx        # Adaptive: dialog (desktop) / bottom sheet (touch)
│   ├── NodePorts.tsx              # Expandable connection ports
│   ├── EdgeOverlay.tsx            # Edge creation drag overlay
│   ├── ResizeHandle.tsx           # 8-directional, 44px touch targets
│   ├── NodeErrorBoundary.tsx      # Per-node error isolation
│   ├── CommandLine/               # Command palette components
│   └── CommandFeedbackOverlay.tsx  # Visual feedback during commands
│
├── hooks/                         # React hooks
├── commands/                      # Command palette system
├── gestures/                      # Gesture System v2 pipeline
├── db/                            # Optional Supabase integration
├── providers/                     # CanvasProvider, CanvasStyleProvider
├── styles/                        # CSS variable theming
├── nodes/                         # Pre-built node types (NoteNode)
└── utils/                         # Layout, edge paths, debug helpers
```

### Layer Diagram

```mermaid
flowchart TB
    subgraph Input["Input Layer"]
        PE["Pointer Events"] --> IC["classifyPointer()"]
        IC --> IS["input-store<br/>(active pointers, stylus detection)"]
        IS --> GR["resolveGestureIntent()<br/>(palm rejection, modifier keys)"]
    end

    subgraph Actions["Action Layer"]
        GR --> VP["Viewport<br/>(pan, zoom, pinch)"]
        GR --> ND["Node<br/>(drag, resize, select)"]
        GR --> CT["Canvas Events<br/>(double-click, right-click, long-press)"]
        CT --> AE["Action Executor<br/>(settings-driven)"]
        AE --> AR["Action Registry<br/>(17 built-in + custom)"]
    end

    subgraph State["State Layer (Jotai)"]
        AR --> GS["graph-store<br/>(Graphology)"]
        AR --> SS["selection-store"]
        AR --> VS["viewport-store"]
        AR --> HS["history-store<br/>(delta undo/redo)"]
        GS --> VZ["virtualization-store<br/>(viewport culling)"]
    end

    subgraph UI["UI Layer"]
        VZ --> NR["NodeRenderer"]
        VZ --> ER["EdgeRenderer"]
        VS --> Grid["Grid + Crosshairs"]
    end
```

### State Management

**Jotai** atoms for reactive state, **Graphology** as the graph data structure.

| Store | Key Atoms | Purpose |
|-------|-----------|---------|
| graph-store | `graphAtom`, `nodePositionAtomFamily(id)`, `draggingNodeIdAtom` | Node/edge data, positions, drag state |
| viewport-store | `zoomAtom`, `panAtom`, `screenToWorldAtom`, `worldToScreenAtom` | Viewport transforms |
| selection-store | `selectedNodeIdsAtom`, `selectedEdgeIdAtom` | Selection state |
| input-store | `primaryInputSourceAtom`, `isStylusActiveAtom`, `isMultiTouchAtom` | Input device tracking |
| settings-store | `eventMappingsAtom`, `activePresetIdAtom`, `canvasSettingsAtom` | Event-action config (localStorage) |
| history-store | `canUndoAtom`, `canRedoAtom`, `pushDeltaAtom`, `undoAtom`, `redoAtom` | Delta-based undo/redo |
| virtualization-store | `visibleNodeKeysAtom`, `visibleEdgeKeysAtom` | Viewport culling |
| clipboard-store | `clipboardAtom`, `copyToClipboardAtom`, `pasteFromClipboardAtom` | Copy/paste |
| snap-store | `snapEnabledAtom`, `snapGridSizeAtom` | Grid snapping |
| sync-store | `syncStatusAtom`, `pendingMutationsCountAtom` | DB sync state |
| group-store | `collapsedGroupsAtom`, `nodeChildrenAtom`, `setNodeParentAtom` | Node grouping/nesting |
| virtualization-store | `visibleNodeKeysAtom`, `visibleEdgeKeysAtom`, `spatialIndexAtom` | Viewport culling (SpatialGrid) |
| gesture-rule-store | `gestureRulesAtom`, `gestureRuleIndexAtom`, `consumerGestureRulesAtom` | Gesture v2 rule management |

---

## Input System

The canvas uses a touch-first input pipeline. Every pointer event flows through classification, gesture resolution, then intent execution.

### Input Classification

`classifyPointer(event)` maps PointerEvents to one of three sources with device-specific thresholds:

| Source | Drag Threshold | Long Press | Hit Target | Notes |
|--------|---------------|------------|------------|-------|
| `finger` | 12px | 600ms | 44px | Apple HIG minimum |
| `pencil` | 3px | 600ms | 24px | Pressure/tilt tracked |
| `mouse` | 3px | N/A (uses right-click) | 16px | Most precise |

### Gesture Resolution

`resolveGestureIntent(context)` maps `{source, type, target, modifiers}` to `GestureIntent`:

| Gesture | On Node | On Background |
|---------|---------|---------------|
| Finger drag | Move node | Pan (with inertia) |
| Pencil drag | Move node | Lasso select |
| Mouse drag | Move node | Pan |
| Shift+drag | Move node | Rectangle select |
| Long-press | Context menu callback | Background long-press callback |
| Right-click | Context menu callback | Background right-click callback |
| Double-tap | Double-click callback | Background double-click callback |
| Pinch | - | Zoom |

### Palm Rejection

When a stylus is active (`isStylusActiveAtom === true`), finger inputs are demoted:
- Finger taps: ignored (stylus tap takes precedence)
- Finger drags: pan only (never move nodes)
- Pencil inputs: unaffected

---

## Event-Action System

Canvas events (double-click, right-click, long-press) are mapped to configurable actions. Users can change what happens on each event type.

### Event Types

```ts
enum CanvasEventType {
  NodeClick, NodeDoubleClick, NodeTripleClick, NodeRightClick, NodeLongPress,
  BackgroundClick, BackgroundDoubleClick, BackgroundRightClick, BackgroundLongPress,
  EdgeClick, EdgeDoubleClick, EdgeRightClick,
}
```

### Built-in Actions (17)

| ID | Category | Description |
|----|----------|-------------|
| `none` | - | No-op |
| `clear-selection` | Selection | Deselect all |
| `select-all` | Selection | Select all nodes |
| `invert-selection` | Selection | Invert selection |
| `select-edge` | Selection | Select clicked edge |
| `fit-to-view` | Viewport | Zoom to fit selection |
| `fit-all-to-view` | Viewport | Zoom to fit entire graph |
| `center-on-node` | Viewport | Center viewport on node |
| `reset-viewport` | Viewport | Reset zoom/pan to default |
| `create-node` | Creation | Create new node at position |
| `delete-node` | Node | Delete selected nodes |
| `open-context-menu` | Node | Open context menu |
| `toggle-lock` | Node | Toggle lock state |
| `apply-force-layout` | Layout | Run force-directed layout |
| `undo` / `redo` | History | Undo/redo |

### Presets

| Preset | Node Dbl-Click | Node Triple-Click | Bg Dbl-Click | Bg Long-Press |
|--------|------|------|------|------|
| **Default** | Fit to View | Toggle Lock | Fit All to View | Create Node |
| **Minimal** | None | None | None | None |
| **Power User** | Toggle Lock | Delete Selected | Create Node | Force Layout |

### Using the Action System

```tsx
import { useActionExecutor, CanvasEventType, createActionContext } from '@blinksgg/canvas';

function MyCanvas() {
  const { executeEventAction } = useActionExecutor({
    onCreateNode: async (pos) => { /* create node at pos */ },
    onDeleteNode: async (id) => { /* delete node */ },
    onOpenContextMenu: (pos, nodeId) => { /* show menu */ },
  });

  return (
    <Canvas
      renderNode={renderNode}
      onNodeDoubleClick={(nodeId, nodeData) => {
        executeEventAction(
          CanvasEventType.NodeDoubleClick,
          createActionContext(CanvasEventType.NodeDoubleClick,
            { clientX: nodeData.x, clientY: nodeData.y },
            { x: nodeData.x, y: nodeData.y },
            { nodeId }
          )
        );
      }}
    />
  );
}
```

### Custom Actions

```tsx
import { registerAction, ActionCategory } from '@blinksgg/canvas';

registerAction({
  id: 'my-custom-action',
  label: 'My Action',
  description: 'Does something custom',
  category: ActionCategory.Custom,
  handler: (context, helpers) => {
    if (context.nodeId) {
      helpers.selectNode(context.nodeId);
    }
  },
});
```

---

## Command Palette

A slash-command system with fuzzy search, sequential input collection, and keyboard shortcuts.

### Setup

```tsx
import {
  CommandProvider, CommandLine, CommandFeedbackOverlay,
  registerBuiltinCommands, useGlobalKeyboard,
} from '@blinksgg/canvas';

registerBuiltinCommands();

function KeyboardHandler() {
  useGlobalKeyboard();
  return null;
}

function App() {
  return (
    <CommandProvider
      onCreateNode={async (payload) => { /* ... */ }}
      onDeleteNode={async (nodeId) => { /* ... */ }}
    >
      <KeyboardHandler />
      <Canvas renderNode={renderNode}>
        <CommandFeedbackOverlay />
      </Canvas>
      <CommandLine />
    </CommandProvider>
  );
}
```

### Built-in Commands

| Command | Shortcut | Category |
|---------|----------|----------|
| Fit to View | - | Viewport |
| Fit Selection | - | Viewport |
| Reset Viewport | - | Viewport |
| Zoom In / Out | - | Viewport |
| Select All | `Ctrl+A` | Selection |
| Clear Selection | `Escape` | Selection |
| Invert Selection | - | Selection |
| Undo / Redo | `Ctrl+Z` / `Ctrl+Shift+Z` | History |
| Copy / Cut / Paste | `Ctrl+C` / `Ctrl+X` / `Ctrl+V` | Clipboard |
| Duplicate | `Ctrl+D` | Clipboard |
| Delete Selected | `Delete` / `Backspace` | Clipboard |
| Force Layout | - | Layout |
| Tree Layout | - | Layout |
| Grid Layout | - | Layout |
| Horizontal Layout | - | Layout |

### Custom Commands

```tsx
import { registerCommand } from '@blinksgg/canvas';

registerCommand({
  id: 'my-command',
  label: 'My Custom Command',
  category: 'custom',
  inputs: [
    { type: 'pickNode', prompt: 'Select a node' },
  ],
  execute: async (inputs, ctx) => {
    const nodeId = inputs[0];
    // do something with nodeId
  },
});
```

---

## Components

### Canvas

Main orchestrator combining Viewport + NodeRenderer + EdgeRenderer.

```tsx
interface CanvasProps {
  renderNode: (props: NodeRenderProps) => ReactNode;  // Required

  // Node events (7)
  onNodeClick?: (nodeId, nodeData) => void;
  onNodeDoubleClick?: (nodeId, nodeData) => void;
  onNodeTripleClick?: (nodeId, nodeData) => void;
  onNodeRightClick?: (nodeId, nodeData, event) => void;
  onNodeLongPress?: (nodeId, nodeData, position) => void;
  onNodeHover?: (nodeId, nodeData) => void;
  onNodeLeave?: (nodeId) => void;

  // Edge events (5)
  onEdgeClick?: (edgeKey, edgeData, event) => void;
  onEdgeDoubleClick?: (edgeKey, edgeData, event) => void;
  onEdgeRightClick?: (edgeKey, edgeData, event) => void;
  onEdgeHover?: (edgeKey, edgeData) => void;
  onEdgeLeave?: (edgeKey) => void;

  // Background events (4)
  onBackgroundClick?: (worldPos) => void;
  onBackgroundDoubleClick?: (worldPos) => void;
  onBackgroundRightClick?: (worldPos, event) => void;
  onBackgroundLongPress?: (worldPos) => void;

  // Observability callbacks
  onSelectionChange?: (selectedNodeIds, selectedEdgeId) => void;
  onViewportChange?: (viewport: { zoom, pan }) => void;
  onDragStart?: (nodeIds) => void;
  onDragEnd?: (nodeIds, positions) => void;

  // Persistence
  onNodePersist?: (nodeId, graphId, uiProperties) => Promise<void>;
  nodeWrapper?: ComponentType<{ children, nodeData }>;
  children?: ReactNode;

  // Viewport config
  minZoom?: number;    // Default: 0.1
  maxZoom?: number;    // Default: 5
  enablePan?: boolean;
  enableZoom?: boolean;
}
```

### SettingsPanel

Headless settings panel. All layout controlled via className props.

```tsx
<SettingsPanel
  className="flex flex-col gap-2"
  selectClassName="border rounded px-2 py-1 text-xs"
  buttonClassName="border rounded px-2 py-1 text-xs"
  onClose={togglePanel}
  renderHeader={() => <h2>Settings</h2>}
/>
```

### Other Components

| Component | Purpose |
|-----------|---------|
| `Viewport` | Pan/zoom container with gesture support |
| `Node` | Draggable/resizable node wrapper |
| `NodeRenderer` | Renders visible nodes (virtualized) |
| `EdgeRenderer` | Renders edges with configurable path types |
| `EdgeOverlay` | Edge creation preview line |
| `Grid` | Background grid with axes |
| `ViewportControls` | Zoom +/- and fit buttons |
| `CommandLine` | Command palette search bar |
| `CommandFeedbackOverlay` | Visual feedback during command input |
| `NodeContextMenu` | Right-click context menu |
| `LockedNodeOverlay` | Full-screen locked node view |
| `NodePorts` | Port connectors on nodes |
| `NodeTypeCombobox` | Node type selector dropdown |
| `ResizeHandle` | Corner resize handles |
| `SelectionOverlay` | Lasso/rect selection path rendering |
| `Minimap` | Canvas-based graph overview with draggable viewport rect |

---

## Hooks

### State Hooks

| Hook | Returns |
|------|---------|
| `useCanvasSelection()` | `{ selectedNodeIds, selectedEdgeId, count, hasSelection, hasEdgeSelection }` |
| `useCanvasViewport()` | `{ zoom, pan, screenToWorld, worldToScreen, isZoomTransitioning, viewportRect }` |
| `useCanvasDrag()` | `{ draggingNodeId, isDragging }` |

### Node Hooks

| Hook | Returns |
|------|---------|
| `useNodeSelection(id)` | `{ isSelected }` |
| `useNodeDrag(id, options)` | `{ bind(), updateNodePositions() }` |
| `useNodeResize(id, nodeData, options)` | `{ localWidth, localHeight, isResizing, createResizeStart, handleResizeMove, handleResizeEnd }` |
| `useTapGesture(options)` | `{ handleTap, cleanup }` |

### Feature Hooks

| Hook | Returns |
|------|---------|
| `useCanvasSettings()` | `{ mappings, activePresetId, setEventMapping, applyPreset, isPanelOpen, togglePanel }` |
| `useActionExecutor(options)` | `{ executeActionById, executeEventAction, getActionForEvent, mappings, helpers }` |
| `useVirtualization()` | `{ enabled, totalNodes, visibleNodes, culledNodes, toggle }` |
| `useCanvasHistory(options)` | `{ undo, redo, canUndo, canRedo, recordSnapshot }` |
| `useFitToBounds()` | `{ fitToBounds(mode, padding) }` |
| `useForceLayout()` | Force-directed layout via d3-force |
| `useTreeLayout(opts)` | Hierarchical tree layout (top-down or left-right) |
| `useGridLayout(opts)` | Uniform grid layout (auto columns, spatial sort) |
| `useAnimatedLayout(opts)` | Shared animated position interpolation hook |
| `useLayout()` | `{ fitToBounds, graphBounds, selectionBounds }` |
| `useCommandLine()` | `{ visible, state, open, close, updateQuery, selectCommand }` |
| `useGlobalKeyboard()` | Registers `/`, `Cmd+K`, `Cmd+C/V/D/A`, `Del`, `Escape` handlers |
| `useZoomTransition()` | `{ isAnimating, progress, cancel }` — drives animated zoom/pan |

---

## Core Atoms (Advanced)

### Graph Store

| Atom | Purpose |
|------|---------|
| `graphAtom` | The graphology instance |
| `nodePositionAtomFamily(id)` | Per-node position atom (x, y, width, height) |
| `draggingNodeIdAtom` | Currently dragged node ID |
| `highestZIndexAtom` | Derived: max z-index across all nodes |
| `uiNodesAtom` | Derived: `UINodeState[]` from graph |
| `nodeKeysAtom` / `edgeKeysAtom` | Derived: sorted key arrays |

**Mutations:** `addNodeToLocalGraphAtom`, `optimisticDeleteNodeAtom`, `optimisticDeleteEdgeAtom`, `swapEdgeAtomicAtom`, `loadGraphFromDbAtom`

### Viewport Store

| Atom | Purpose |
|------|---------|
| `zoomAtom` | Current zoom level |
| `panAtom` | Current pan offset `{ x, y }` |
| `viewportRectAtom` | Viewport DOMRect |
| `screenToWorldAtom` | `(screenX, screenY) -> { x, y }` converter |
| `worldToScreenAtom` | `(worldX, worldY) -> { x, y }` converter |
| `setZoomAtom` | Set zoom with optional focal point |
| `resetViewportAtom` | Reset to zoom=1, pan=0,0 |

### Selection Store

| Atom | Purpose |
|------|---------|
| `selectedNodeIdsAtom` | `Set<string>` of selected nodes |
| `selectedEdgeIdAtom` | Single selected edge |
| `selectSingleNodeAtom` | Select one, clear others |
| `toggleNodeInSelectionAtom` | Shift-click toggle |
| `clearSelectionAtom` | Clear all selection |
| `addNodesToSelectionAtom` | Add to multi-selection |

### History Store

Delta-based undo/redo with 50-entry limit:

| Atom | Purpose |
|------|---------|
| `historyStateAtom` | Current history stack |
| `pushDeltaAtom` | Push a delta (partial node change) |
| `pushHistoryAtom` | Push a full snapshot |
| `undoAtom` / `redoAtom` | Undo/redo operations |
| `canUndoAtom` / `canRedoAtom` | Derived: availability |

Delta types: `move-node`, `resize-node`, `add-node`, `remove-node`, `add-edge`, `remove-edge`, `update-node-attr`, `batch`, `full-snapshot`.

### Clipboard Store

| Atom | Purpose |
|------|---------|
| `clipboardAtom` | Stored clipboard data |
| `copyToClipboardAtom` | Copy selected nodes + edges |
| `cutToClipboardAtom` | Cut (copy + delete) |
| `pasteFromClipboardAtom` | Paste with offset |
| `duplicateSelectionAtom` | Duplicate in-place |

### Virtualization Store

| Atom | Purpose |
|------|---------|
| `virtualizationEnabledAtom` | Toggle viewport culling |
| `visibleNodeKeysAtom` | Only nodes in viewport |
| `visibleEdgeKeysAtom` | Only edges in viewport |
| `virtualizationMetricsAtom` | Render vs total counts |

### Snap Store

| Atom | Purpose |
|------|---------|
| `snapEnabledAtom` | Boolean toggle |
| `snapGridSizeAtom` | Grid size in px (default 20) |
| `toggleSnapAtom` | Toggle snap on/off |

Utilities: `snapToGrid(value, gridSize)`, `conditionalSnap(value, gridSize, enabled)`, `getSnapGuides(pos, gridSize, tolerance)`

### Port System

| Export | Purpose |
|--------|---------|
| `PortDefinition` | Define ports on a node (type, side, capacity) |
| `calculatePortPosition()` | Position ports along node edges |
| `canPortAcceptConnection()` | Validate port connections |
| `arePortsCompatible()` | Check type compatibility |

---

## Features

### Virtualization

Only nodes within viewport bounds (+200px buffer) are rendered. Enabled by default.

```tsx
const { enabled, visibleNodes, totalNodes, culledNodes, toggle } = useVirtualization();
```

### Snap-to-Grid

Optional grid snapping during drag operations. Grid size: 5-200px (default: 20px).

```tsx
import { snapEnabledAtom, snapGridSizeAtom, toggleSnapAtom } from '@blinksgg/canvas';
```

### Clipboard

Local-first copy/cut/paste/duplicate. Pasted nodes exist in the local graph without DB calls.

| Operation | Shortcut | Behavior |
|-----------|----------|----------|
| Copy | `Ctrl+C` | Copy selected nodes + internal edges |
| Cut | `Ctrl+X` | Copy to clipboard (app handles deletion) |
| Paste | `Ctrl+V` | New IDs generated, edges remapped, offset by 50px |
| Duplicate | `Ctrl+D` | Copy + paste in place |

### Undo/Redo

Delta-based history with 50-entry limit. O(1) for moves, full-graph snapshots as fallback.

```tsx
const { undo, redo, canUndo, canRedo } = useCanvasHistory({
  enableKeyboardShortcuts: true,
});
```

### Connection Ports

Nodes can define input/output ports for edge connections.

```ts
interface PortDefinition {
  id: string;
  label: string;
  type: 'input' | 'output' | 'bidirectional';
  side: 'top' | 'right' | 'bottom' | 'left';
  position?: number;  // 0-1 along the side (default: 0.5)
  color?: string;
}
```

### Edge Path Types

8 configurable path calculators: `bezier`, `bezier-vertical`, `bezier-smart`, `straight`, `step`, `step-vertical`, `step-smart`, `smooth-step`. Set via `styles.edges.pathType`.

```tsx
import { getEdgePathCalculator } from '@blinksgg/canvas/utils';
const calc = getEdgePathCalculator('bezier-smart');
const { path, labelPosition } = calc({ sourceX, sourceY, targetX, targetY });
```

### Minimap

Small overview showing all nodes with a draggable viewport rectangle. Uses `<canvas>` for performance.

```tsx
import { Minimap } from '@blinksgg/canvas';

<Canvas renderNode={renderNode}>
  <Minimap position="bottom-right" width={200} height={150} />
</Canvas>
```

Click or drag on the minimap to pan the viewport. Configurable: `position`, `width`, `height`, `backgroundColor`, `nodeColor`, `selectedNodeColor`, `viewportColor`.

### Lasso & Rect Selection

Pencil drag on background draws a freeform lasso path. Shift+drag draws a rectangular selection box.

```tsx
import { SelectionOverlay } from '@blinksgg/canvas';

<Canvas renderNode={renderNode}>
  <SelectionOverlay />
</Canvas>
```

- **Rect selection**: AABB intersection (nodes overlapping the rect are selected)
- **Lasso selection**: point-in-polygon test on node center
- Selection path state in `selectionPathAtom`, `selectionRectAtom`

### Node Grouping

Group nodes into collapsible containers with parent-child relationships.

```tsx
import { GroupNode, setNodeParentAtom, toggleGroupCollapseAtom } from '@blinksgg/canvas';

// Set parent-child relationship
store.set(setNodeParentAtom, { nodeId: 'child1', parentId: 'group1' });

// Toggle collapse
store.set(toggleGroupCollapseAtom, 'group1');
```

- `parentId` attribute on `GraphNodeAttributes` defines hierarchy
- `GroupNode` component renders header bar with collapse toggle and child count
- Collapsed groups hide children from `uiNodesAtom` (walks ancestor chain for nested groups)
- **Edge re-routing:** edges to/from collapsed children visually re-route to the group node (via `collapsedEdgeRemapAtom`); internal edges are hidden
- **Auto-resize:** group node resizes to fit children bounding box when a child drag ends
- **Nested drag:** dragging a group node moves all descendants together (via `getNodeDescendants`)
- Commands: `groupNodes`, `ungroupNodes`, `collapseGroup`, `expandGroup`

### Search & Filter

Search nodes by label, type, or ID with visual dimming of non-matching nodes.

```tsx
import { setSearchQueryAtom, clearSearchAtom, searchResultsAtom } from '@blinksgg/canvas';

// Set search query
store.set(setSearchQueryAtom, 'my node');

// Navigate results
store.set(nextSearchResultAtom);  // cycles + centers viewport
store.set(prevSearchResultAtom);

// Clear
store.set(clearSearchAtom);
```

- Case-insensitive substring match on `label`, `node_type`, `id`
- Non-matching nodes rendered at `opacity: 0.2` with `pointerEvents: none`
- Non-matching edges dimmed to `opacity: 0.2` with 150ms CSS transition
- Highlighted result gets animated amber box-shadow pulse (include `<CanvasAnimations />` for the keyframes)
- **Keyboard shortcuts:** `Ctrl+F` opens search, `Enter`/`Shift+Enter` cycles results, `Ctrl+G`/`Ctrl+Shift+G` alternative navigation, `Escape` clears search
- Commands: `searchNodes` (aliases: `find`, `search`), `clearSearch`

### Edge Animations

Edges animate on creation (fade-in) and deletion (fade-out). Include `<CanvasAnimations />` for the CSS keyframes.

```tsx
import { removeEdgeWithAnimationAtom } from '@blinksgg/canvas';

// Remove an edge with a 300ms fade-out animation
store.set(removeEdgeWithAnimationAtom, 'edge-id');
```

- New edges: `.canvas-edge-enter` class with 300ms fade-in
- Deleted edges: snapshot stored in `departingEdgesAtom`, rendered with `.canvas-edge-exit` fade-out, cleaned up after 300ms
- Use `removeEdgeWithAnimationAtom` instead of `removeEdgeFromLocalGraphAtom` for animated deletion

### Edge Label Editing

Double-click an edge label to edit it inline. The `EdgeLabelEditor` component is included automatically in `Canvas`.

```tsx
import { editingEdgeLabelAtom, updateEdgeLabelAtom } from '@blinksgg/canvas';

// Programmatically open label editor
store.set(editingEdgeLabelAtom, 'edge-id');

// Update label
store.set(updateEdgeLabelAtom, { edgeKey: 'edge-id', label: 'new label' });
```

- HTML `<input>` overlay positioned at the edge label's world coordinates
- Commits on blur or Enter; cancels on Escape
- `EdgeLabelEditor` is included in `Canvas` automatically; can also be used standalone

### Zoom Transitions

Animated zoom-to-node and fit-to-bounds transitions with cubic ease-in-out.

```tsx
import { animateZoomToNodeAtom, animateFitToBoundsAtom } from '@blinksgg/canvas/core';
import { useZoomTransition } from '@blinksgg/canvas/hooks';

// In a component:
const { isAnimating, progress } = useZoomTransition();

// From headless API:
store.set(animateZoomToNodeAtom, { nodeId: 'n1', targetZoom: 2, duration: 300 });
store.set(animateFitToBoundsAtom, { mode: 'graph', duration: 400 });
```

---

## Gesture System v2

The v2 gesture pipeline (v0.14+) replaces ad-hoc event handlers with a unified input system:

```
Normalize → Recognize → Resolve → Dispatch
```

- **Normalize**: Classify raw pointer/keyboard events into `InputEvent` types
- **Recognize**: Pattern-match against bindings using specificity scores
- **Resolve**: Pick the highest-scoring match from priority-sorted context stack
- **Dispatch**: Route to `PhaseHandler` (onStart/onMove/onEnd) or instant function handler

### Key Concepts

| Concept | Description |
|---------|-------------|
| **Binding** | Maps an `InputPattern` to an action ID |
| **Context** | Named group of bindings with a priority (lower = checked first) |
| **Specificity** | Score based on type (128), key (64), subjectKind (32), modifiers (16/8), source (4), button (2) |
| **`consumeInput`** | When true, prevents lower-priority contexts from matching |

### Usage

```tsx
import { Canvas } from '@blinksgg/canvas';

<Canvas
  renderNode={renderNode}
  gestureConfig={{
    // Add custom contexts, palm rejection, etc.
    palmRejection: true,
  }}
  onAction={(event, action) => {
    console.log('Action:', action.actionId, event);
  }}
/>
```

For advanced usage, import from `@blinksgg/canvas/gestures`.

---

## Performance

### Spatial Grid Index (v0.15+)

Viewport culling uses a `SpatialGrid` with fixed 500px cells for O(visible) node lookups instead of O(N) linear scans.

```tsx
import { spatialIndexAtom, SpatialGrid } from '@blinksgg/canvas/core';
```

### Structural Equality Caching

- **`edgeFamilyAtom`** — cached per edge key; returns previous object when all fields match
- **`uiNodesAtom`** — returns previous array when entries match by id, position, isDragging
- **`nodePositionAtomFamily`** — returns cached position when x/y unchanged

### Performance Instrumentation (v0.15+)

Opt-in `performance.mark`/`measure` for DevTools profiling:

```tsx
import { setPerfEnabled } from '@blinksgg/canvas/core';

// Enable from code
setPerfEnabled(true);

// Or from browser console
window.__canvasPerf?.(true);
```

Marks: `canvas:drag-frame`, `canvas:virtualization-cull`.

---

## Database (Optional)

The `db/` layer is optional. Core canvas is backend-agnostic. Use the `CanvasStorageAdapter` interface for any backend.

### With Custom Adapter

```tsx
import { CanvasProvider, InMemoryStorageAdapter } from '@blinksgg/canvas';

// Use the built-in in-memory adapter (no database)
<CanvasProvider adapter={new InMemoryStorageAdapter()} graphId="my-graph">
  <Canvas renderNode={renderNode} />
</CanvasProvider>
```

### With Supabase

```tsx
import { CanvasProvider, SupabaseStorageAdapter } from '@blinksgg/canvas';
import { createClient } from '@supabase/supabase-js';

const supabase = createClient(url, key);
const adapter = new SupabaseStorageAdapter(supabase);

<CanvasProvider adapter={adapter} graphId="your-graph-id">
  <Canvas renderNode={renderNode} />
</CanvasProvider>
```

### Without Any Provider

Use `JotaiProvider` + `CanvasStyleProvider` directly:

```tsx
<Canvas
  renderNode={renderNode}
  onNodePersist={async (nodeId, graphId, props) => {
    await myApi.updateNode(nodeId, props);
  }}
/>
```

### Schema

```mermaid
erDiagram
    graphs ||--o{ nodes : contains
    graphs ||--o{ edges : contains
    nodes ||--o{ edges : "source"
    nodes ||--o{ edges : "target"

    graphs {
        uuid id PK
        uuid owner_id FK
        string name
        text description
        jsonb data
    }

    nodes {
        uuid id PK
        uuid graph_id FK
        string label
        string node_type
        jsonb configuration
        jsonb ui_properties "x, y, width, height, zIndex"
        jsonb data
    }

    edges {
        uuid id PK
        uuid graph_id FK
        uuid source_node_id FK
        uuid target_node_id FK
        string edge_type
        jsonb filter_condition
        jsonb ui_properties
        jsonb data
    }
```

### Storage Adapter

Implement `CanvasStorageAdapter` for any backend:

```tsx
interface CanvasStorageAdapter {
  fetchNodes(graphId: string): Promise<CanvasNode[]>;
  createNode(graphId: string, node: Partial<CanvasNode>): Promise<CanvasNode>;
  updateNode(nodeId: string, updates: Partial<CanvasNode>): Promise<CanvasNode>;
  deleteNode(nodeId: string): Promise<void>;
  // ... edges, subscriptions
}
```

---

## Import Paths

| Import | Description |
|--------|-------------|
| `@blinksgg/canvas` | Everything (barrel export) |
| `@blinksgg/canvas/core` | Headless Jotai atoms, types, registries |
| `@blinksgg/canvas/hooks` | React hooks for state access |
| `@blinksgg/canvas/commands` | Command palette system |
| `@blinksgg/canvas/components` | React UI components |
| `@blinksgg/canvas/db` | Supabase storage adapter layer |
| `@blinksgg/canvas/utils` | Layout, edge paths, debug |
| `@blinksgg/canvas/nodes` | Pre-built node type components |

---

## Styles

Theming via `CanvasStyleGuide` and CSS variables scoped to the canvas container:

```tsx
import { defaultDarkStyles, mergeWithDefaults } from '@blinksgg/canvas/styles';

const customStyles = mergeWithDefaults({
  background: { color: '#0a0a0a' },
  grid: { lineColor: '#1a1a1a', spacing: 20 },
  nodes: { selectedBorderColor: '#6366f1' },
  edges: { pathType: 'bezier-smart', defaultColor: '#475569' },
});
```

Wrap with `<CanvasStyleProvider isDark={true}>` for runtime style context.

---

## React 19 + React Compiler

This library requires **React 19.2+** (`"react": "^19.2.0"`). It leverages:

1. **React Compiler** — All components and hooks are automatically memoized at build time via `babel-plugin-react-compiler`. **Zero** manual `useCallback`, `useMemo`, or `React.memo` in the codebase.
2. **Context as JSX** — Providers use `<Context value={...}>` directly instead of `<Context.Provider value={...}>`.
3. **Ref cleanup functions** — Ref callbacks return cleanup functions for lifecycle management.
4. **`useState` for lazy init** — Replaced `useMemo(() => value, [])` with `useState(() => value)` for stable one-time initialization.

---

## Development

```bash
pnpm --filter @blinksgg/canvas build         # Build package
pnpm --filter @blinksgg/canvas dev           # Watch mode
pnpm --filter @blinksgg/canvas check-types   # TypeScript validation
pnpm --filter @blinksgg/canvas test          # Run tests (vitest)
```

### Testing

Tests use Jotai's `createStore()` for isolated atom testing, and `renderHook` from `@testing-library/react` for hook integration tests.

**761 tests across 77 test files**, covering:
- Core stores, registries, and pure functions
- React hooks via `renderHook` (useActionExecutor, usePlugin, useLayout, easeInOutCubic)
- Enum/type integrity (ActionCategory, CanvasEventType, GestureRule shapes)
- Component rendering (Canvas, Node, Viewport, Minimap, GroupNode, SelectionOverlay)

### Peer Dependencies

| Package | Version |
|---------|---------|
| `react` / `react-dom` | ^19.2 |
| `jotai` | ^2.6 |
| `d3-force` | ^3.0 |
| `@tanstack/react-query` | ^5.17 |

---

## Version Checking

```tsx
import { canvasVersion, CANVAS_VERSION } from '@blinksgg/canvas';

console.log(`Canvas version: ${CANVAS_VERSION}`);

if (canvasVersion.isAtLeast(0, 6)) {
  // React 19 features available
}
```

## Changelog

| Version | Highlights |
|---------|------------|
| **3.0.0** | React Compiler integration, removed all manual useCallback/useMemo, peer deps ^19.2.0 |
| **2.5.0** | First renderHook integration tests for React hooks (24 tests) |
| **2.4.0** | Drag state machine, action/event type enum tests (36 tests) |
| **2.3.0** | Actions, modifiers, pointer bindings, plugin type tests (34 tests) |
| **2.2.0** | Split built-in-actions.ts, 6 new test suites (38 tests) |
| **2.1.0** | Command-line store, storage adapter tests (21 tests) |
| **2.0.0** | Renamed gesturesV2 → gestures, removed deprecated re-exports |
| **1.2.0** | Plugin system, auto-routing, unregisterNodeType |
| **1.1.0** | Split useRegisterInputActions/useCanvasGestures, 8 bug fixes |
| **1.0.0** | API stability audit, migration guide, peer dep cleanup, 761 tests |

See [CHANGELOG.md](./CHANGELOG.md) for full details.

## License

MIT
