# @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 ( (
{node.label}
)} />
); } ``` ## Architecture ### Package Structure ``` packages/canvas/src/ ├── core/ # Headless state (Jotai + Graphology) │ ├── types.ts # Core type definitions │ ├── graph-store.ts # graphAtom, positions, edges, drag state │ ├── viewport-store.ts # zoom, pan, coordinate transforms │ ├── selection-store.ts # selectedNodeIdsAtom, selectedEdgeIdAtom │ ├── input-classifier.ts # finger/pencil/mouse classification │ ├── input-store.ts # Active pointer tracking, stylus detection │ ├── gesture-resolver.ts # Gesture intent resolution + palm rejection │ ├── interaction-store.ts # Input modes (picking, text), feedback │ ├── action-executor.ts # Execute actions by ID with context │ ├── action-registry.ts # Global action registry (built-in + custom) │ ├── settings-types.ts # Event types, action IDs, presets │ ├── settings-store.ts # Event-action mappings, localStorage persistence │ ├── history-store.ts # Delta-based undo/redo (50 entries) │ ├── clipboard-store.ts # Local-first copy/cut/paste/duplicate │ ├── snap-store.ts # Snap-to-grid with configurable size │ ├── virtualization-store.ts # Viewport culling for large graphs │ ├── spatial-index.ts # SpatialGrid for O(visible) culling │ ├── perf.ts # Performance instrumentation (opt-in) │ ├── sync-store.ts # Sync status, mutation queue │ ├── locked-node-store.ts # Node detail/lock view │ ├── group-store.ts # Node grouping, collapse/expand │ ├── search-store.ts # Search query, results, navigation │ ├── port-types.ts # Connection port definitions │ ├── gesture-rules.ts # Composable gesture rule system │ ├── gesture-rule-store.ts # Gesture rule state management │ └── node-type-registry.tsx # Custom node type registration │ ├── components/ # React UI │ ├── Canvas.tsx # Main orchestrator │ ├── Viewport.tsx # Pan/zoom with inertia + gesture handling │ ├── Node.tsx # Draggable/resizable node │ ├── GroupNode.tsx # Collapsible container for grouped nodes │ ├── CanvasAnimations.tsx # CSS keyframe injection for search pulse + edge transitions │ ├── EdgeLabelEditor.tsx # Inline edge label editing overlay │ ├── NodeRenderer.tsx # Renders visible nodes (virtualized) │ ├── EdgeRenderer.tsx # Renders visible edges (+ departing edge animations) │ ├── Grid.tsx # Background grid + crosshairs │ ├── ViewportControls.tsx # Floating zoom +/- buttons │ ├── SettingsPanel.tsx # Headless settings (className-based) │ ├── NodeContextMenu.tsx # Adaptive: dialog (desktop) / bottom sheet (touch) │ ├── NodePorts.tsx # Expandable connection ports │ ├── EdgeOverlay.tsx # Edge creation drag overlay │ ├── ResizeHandle.tsx # 8-directional, 44px touch targets │ ├── NodeErrorBoundary.tsx # Per-node error isolation │ ├── CommandLine/ # Command palette components │ └── CommandFeedbackOverlay.tsx # Visual feedback during commands │ ├── hooks/ # React hooks ├── commands/ # Command palette system ├── gestures/ # Gesture System v2 pipeline ├── db/ # Optional Supabase integration ├── providers/ # CanvasProvider, CanvasStyleProvider ├── styles/ # CSS variable theming ├── nodes/ # Pre-built node types (NoteNode) └── utils/ # Layout, edge paths, debug helpers ``` ### Layer Diagram ```mermaid flowchart TB subgraph Input["Input Layer"] PE["Pointer Events"] --> IC["classifyPointer()"] IC --> IS["input-store
(active pointers, stylus detection)"] IS --> GR["resolveGestureIntent()
(palm rejection, modifier keys)"] end subgraph Actions["Action Layer"] GR --> VP["Viewport
(pan, zoom, pinch)"] GR --> ND["Node
(drag, resize, select)"] GR --> CT["Canvas Events
(double-click, right-click, long-press)"] CT --> AE["Action Executor
(settings-driven)"] AE --> AR["Action Registry
(17 built-in + custom)"] end subgraph State["State Layer (Jotai)"] AR --> GS["graph-store
(Graphology)"] AR --> SS["selection-store"] AR --> VS["viewport-store"] AR --> HS["history-store
(delta undo/redo)"] GS --> VZ["virtualization-store
(viewport culling)"] end subgraph UI["UI Layer"] VZ --> NR["NodeRenderer"] VZ --> ER["EdgeRenderer"] VS --> Grid["Grid + Crosshairs"] end ``` ### State Management **Jotai** atoms for reactive state, **Graphology** as the graph data structure. | Store | Key Atoms | Purpose | |-------|-----------|---------| | graph-store | `graphAtom`, `nodePositionAtomFamily(id)`, `draggingNodeIdAtom` | Node/edge data, positions, drag state | | viewport-store | `zoomAtom`, `panAtom`, `screenToWorldAtom`, `worldToScreenAtom` | Viewport transforms | | selection-store | `selectedNodeIdsAtom`, `selectedEdgeIdAtom` | Selection state | | input-store | `primaryInputSourceAtom`, `isStylusActiveAtom`, `isMultiTouchAtom` | Input device tracking | | settings-store | `eventMappingsAtom`, `activePresetIdAtom`, `canvasSettingsAtom` | Event-action config (localStorage) | | history-store | `canUndoAtom`, `canRedoAtom`, `pushDeltaAtom`, `undoAtom`, `redoAtom` | Delta-based undo/redo | | virtualization-store | `visibleNodeKeysAtom`, `visibleEdgeKeysAtom` | Viewport culling | | clipboard-store | `clipboardAtom`, `copyToClipboardAtom`, `pasteFromClipboardAtom` | Copy/paste | | snap-store | `snapEnabledAtom`, `snapGridSizeAtom` | Grid snapping | | sync-store | `syncStatusAtom`, `pendingMutationsCountAtom` | DB sync state | | group-store | `collapsedGroupsAtom`, `nodeChildrenAtom`, `setNodeParentAtom` | Node grouping/nesting | | virtualization-store | `visibleNodeKeysAtom`, `visibleEdgeKeysAtom`, `spatialIndexAtom` | Viewport culling (SpatialGrid) | | gesture-rule-store | `gestureRulesAtom`, `gestureRuleIndexAtom`, `consumerGestureRulesAtom` | Gesture v2 rule management | --- ## Input System The canvas uses a touch-first input pipeline. Every pointer event flows through classification, gesture resolution, then intent execution. ### Input Classification `classifyPointer(event)` maps PointerEvents to one of three sources with device-specific thresholds: | Source | Drag Threshold | Long Press | Hit Target | Notes | |--------|---------------|------------|------------|-------| | `finger` | 12px | 600ms | 44px | Apple HIG minimum | | `pencil` | 3px | 600ms | 24px | Pressure/tilt tracked | | `mouse` | 3px | N/A (uses right-click) | 16px | Most precise | ### Gesture Resolution `resolveGestureIntent(context)` maps `{source, type, target, modifiers}` to `GestureIntent`: | Gesture | On Node | On Background | |---------|---------|---------------| | Finger drag | Move node | Pan (with inertia) | | Pencil drag | Move node | Lasso select | | Mouse drag | Move node | Pan | | Shift+drag | Move node | Rectangle select | | Long-press | Context menu callback | Background long-press callback | | Right-click | Context menu callback | Background right-click callback | | Double-tap | Double-click callback | Background double-click callback | | Pinch | - | Zoom | ### Palm Rejection When a stylus is active (`isStylusActiveAtom === true`), finger inputs are demoted: - Finger taps: ignored (stylus tap takes precedence) - Finger drags: pan only (never move nodes) - Pencil inputs: unaffected --- ## Event-Action System Canvas events (double-click, right-click, long-press) are mapped to configurable actions. Users can change what happens on each event type. ### Event Types ```ts enum CanvasEventType { NodeClick, NodeDoubleClick, NodeTripleClick, NodeRightClick, NodeLongPress, BackgroundClick, BackgroundDoubleClick, BackgroundRightClick, BackgroundLongPress, EdgeClick, EdgeDoubleClick, EdgeRightClick, } ``` ### Built-in Actions (17) | ID | Category | Description | |----|----------|-------------| | `none` | - | No-op | | `clear-selection` | Selection | Deselect all | | `select-all` | Selection | Select all nodes | | `invert-selection` | Selection | Invert selection | | `select-edge` | Selection | Select clicked edge | | `fit-to-view` | Viewport | Zoom to fit selection | | `fit-all-to-view` | Viewport | Zoom to fit entire graph | | `center-on-node` | Viewport | Center viewport on node | | `reset-viewport` | Viewport | Reset zoom/pan to default | | `create-node` | Creation | Create new node at position | | `delete-node` | Node | Delete selected nodes | | `open-context-menu` | Node | Open context menu | | `toggle-lock` | Node | Toggle lock state | | `apply-force-layout` | Layout | Run force-directed layout | | `undo` / `redo` | History | Undo/redo | ### Presets | Preset | Node Dbl-Click | Node Triple-Click | Bg Dbl-Click | Bg Long-Press | |--------|------|------|------|------| | **Default** | Fit to View | Toggle Lock | Fit All to View | Create Node | | **Minimal** | None | None | None | None | | **Power User** | Toggle Lock | Delete Selected | Create Node | Force Layout | ### Using the Action System ```tsx import { useActionExecutor, CanvasEventType, createActionContext } from '@blinksgg/canvas'; function MyCanvas() { const { executeEventAction } = useActionExecutor({ onCreateNode: async (pos) => { /* create node at pos */ }, onDeleteNode: async (id) => { /* delete node */ }, onOpenContextMenu: (pos, nodeId) => { /* show menu */ }, }); return ( { 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 ( { /* ... */ }} onDeleteNode={async (nodeId) => { /* ... */ }} > ); } ``` ### 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; 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

Settings

} /> ``` ### 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` 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 `` for performance. ```tsx import { Minimap } from '@blinksgg/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'; ``` - **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 `` 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 `` 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 `` 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'; { 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) ``` ### 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); ``` ### Without Any Provider Use `JotaiProvider` + `CanvasStyleProvider` directly: ```tsx { 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; createNode(graphId: string, node: Partial): Promise; updateNode(nodeId: string, updates: Partial): Promise; deleteNode(nodeId: string): Promise; // ... 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 `` 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 `` directly instead of ``. 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