# @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 (
);
}
```
## 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
(active pointers, stylus detection)"]
IS --> GR["resolveGestureIntent()
(palm rejection, modifier keys)"]
end
subgraph Actions["Action Layer"]
GR --> VP["Viewport
(pan, zoom, pinch)"]
GR --> ND["Node
(drag, resize, select)"]
GR --> CT["Canvas Events
(double-click, right-click, long-press)"]
CT --> AE["Action Executor
(settings-driven)"]
AE --> AR["Action Registry
(17 built-in + custom)"]
end
subgraph State["State Layer (Jotai)"]
AR --> GS["graph-store
(Graphology)"]
AR --> SS["selection-store"]
AR --> VS["viewport-store"]
AR --> HS["history-store
(delta undo/redo)"]
GS --> VZ["virtualization-store
(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 (