1050 lines
35 KiB
Markdown
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
|