canvas/README.md
2026-03-11 18:42:08 -07:00

1050 lines
35 KiB
Markdown

# @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