35 KiB
@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 | Changelog | API Stability | Migration Guide | TODO / Roadmap
Quick Start
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
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
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
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
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
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
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.
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.
<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.
const { enabled, visibleNodes, totalNodes, culledNodes, toggle } = useVirtualization();
Snap-to-Grid
Optional grid snapping during drag operations. Grid size: 5-200px (default: 20px).
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.
const { undo, redo, canUndo, canRedo } = useCanvasHistory({
enableKeyboardShortcuts: true,
});
Connection Ports
Nodes can define input/output ports for edge connections.
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.
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.
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.
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.
import { GroupNode, setNodeParentAtom, toggleGroupCollapseAtom } from '@blinksgg/canvas';
// Set parent-child relationship
store.set(setNodeParentAtom, { nodeId: 'child1', parentId: 'group1' });
// Toggle collapse
store.set(toggleGroupCollapseAtom, 'group1');
parentIdattribute onGraphNodeAttributesdefines hierarchyGroupNodecomponent 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.
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.2withpointerEvents: none - Non-matching edges dimmed to
opacity: 0.2with 150ms CSS transition - Highlighted result gets animated amber box-shadow pulse (include
<CanvasAnimations />for the keyframes) - Keyboard shortcuts:
Ctrl+Fopens search,Enter/Shift+Entercycles results,Ctrl+G/Ctrl+Shift+Galternative navigation,Escapeclears 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.
import { removeEdgeWithAnimationAtom } from '@blinksgg/canvas';
// Remove an edge with a 300ms fade-out animation
store.set(removeEdgeWithAnimationAtom, 'edge-id');
- New edges:
.canvas-edge-enterclass with 300ms fade-in - Deleted edges: snapshot stored in
departingEdgesAtom, rendered with.canvas-edge-exitfade-out, cleaned up after 300ms - Use
removeEdgeWithAnimationAtominstead ofremoveEdgeFromLocalGraphAtomfor animated deletion
Edge Label Editing
Double-click an edge label to edit it inline. The EdgeLabelEditor component is included automatically in Canvas.
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
EdgeLabelEditoris included inCanvasautomatically; can also be used standalone
Zoom Transitions
Animated zoom-to-node and fit-to-bounds transitions with cubic ease-in-out.
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
InputEventtypes - 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
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.
import { spatialIndexAtom, SpatialGrid } from '@blinksgg/canvas/core';
Structural Equality Caching
edgeFamilyAtom— cached per edge key; returns previous object when all fields matchuiNodesAtom— returns previous array when entries match by id, position, isDraggingnodePositionAtomFamily— returns cached position when x/y unchanged
Performance Instrumentation (v0.15+)
Opt-in performance.mark/measure for DevTools profiling:
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
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
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:
<Canvas
renderNode={renderNode}
onNodePersist={async (nodeId, graphId, props) => {
await myApi.updateNode(nodeId, props);
}}
/>
Schema
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:
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:
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:
- React Compiler — All components and hooks are automatically memoized at build time via
babel-plugin-react-compiler. Zero manualuseCallback,useMemo, orReact.memoin the codebase. - Context as JSX — Providers use
<Context value={...}>directly instead of<Context.Provider value={...}>. - Ref cleanup functions — Ref callbacks return cleanup functions for lifecycle management.
useStatefor lazy init — ReplaceduseMemo(() => value, [])withuseState(() => value)for stable one-time initialization.
Development
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
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 for full details.
License
MIT