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

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');
  • 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.

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.

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.

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.

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

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 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:

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:

  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

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