1451 lines
40 KiB
Text
1451 lines
40 KiB
Text
# @blinksgg/canvas - LLM API Reference (v3.0)
|
|
|
|
> A batteries-included canvas library for node-based editors with drag, resize, pan/zoom, selection, undo/redo, and Supabase sync. Uses **React 19.2+** with the **React Compiler** — zero manual `useCallback`/`useMemo` in the codebase.
|
|
|
|
## Quick Start
|
|
|
|
```tsx
|
|
import {
|
|
Canvas,
|
|
CanvasProvider,
|
|
CanvasDbProvider,
|
|
registerNodeTypes,
|
|
useDeleteNode,
|
|
useCreateNode,
|
|
} from '@blinksgg/canvas';
|
|
|
|
// 1. Register your node type components
|
|
registerNodeTypes({
|
|
'my-node': MyNodeComponent,
|
|
});
|
|
|
|
// 2. Wrap your app with providers
|
|
<CanvasDbProvider supabaseUrl={url} supabaseAnonKey={key}>
|
|
<CanvasProvider graphId={graphId}>
|
|
<Canvas />
|
|
</CanvasProvider>
|
|
</CanvasDbProvider>
|
|
```
|
|
|
|
---
|
|
|
|
## CRUD Operations (Database Hooks)
|
|
|
|
These hooks handle database operations with optimistic updates:
|
|
|
|
| Hook | Purpose | Usage |
|
|
|------|---------|-------|
|
|
| `useCreateNode` | Create a new node | `const { mutate } = useCreateNode()` |
|
|
| `useUpdateNode` | Update node properties | `const { mutate } = useUpdateNode()` |
|
|
| `useDeleteNode` | Delete a node | `const { mutate } = useDeleteNode()` |
|
|
| `useCreateEdge` | Create an edge between nodes | `const { mutate } = useCreateEdge()` |
|
|
| `useUpdateEdge` | Update edge properties | `const { mutate } = useUpdateEdge()` |
|
|
| `useDeleteEdge` | Delete an edge | `const { mutate } = useDeleteEdge()` |
|
|
| `useGraphNodes` | Fetch all nodes for a graph | `const { data } = useGraphNodes(graphId)` |
|
|
| `useGraphEdges` | Fetch all edges for a graph | `const { data } = useGraphEdges(graphId)` |
|
|
|
|
### Example: Delete a Node
|
|
|
|
```tsx
|
|
import { useDeleteNode } from '@blinksgg/canvas';
|
|
|
|
function MyComponent() {
|
|
const deleteNode = useDeleteNode();
|
|
|
|
const handleDelete = (nodeId: string, graphId: string) => {
|
|
deleteNode.mutate({ nodeId, graphId });
|
|
};
|
|
}
|
|
```
|
|
|
|
### Example: Create a Node
|
|
|
|
```tsx
|
|
import { useCreateNode } from '@blinksgg/canvas';
|
|
|
|
function MyComponent() {
|
|
const createNode = useCreateNode();
|
|
|
|
const handleCreate = () => {
|
|
createNode.mutate({
|
|
graphId: 'my-graph-id',
|
|
node: {
|
|
label: 'New Node',
|
|
node_type: 'my-node',
|
|
ui_properties: { x: 100, y: 100, width: 200, height: 150 },
|
|
},
|
|
});
|
|
};
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Selection Management
|
|
|
|
### Atoms (for Jotai)
|
|
|
|
| Atom | Purpose |
|
|
|------|---------|
|
|
| `selectedNodeIdsAtom` | Set of selected node IDs |
|
|
| `selectedEdgeIdAtom` | Currently selected edge ID |
|
|
| `hasSelectionAtom` | Boolean: any nodes selected? |
|
|
| `selectedNodesCountAtom` | Number of selected nodes |
|
|
|
|
### Actions
|
|
|
|
| Atom | Purpose |
|
|
|------|---------|
|
|
| `selectSingleNodeAtom` | Select only one node |
|
|
| `toggleNodeInSelectionAtom` | Toggle node in/out of selection |
|
|
| `addNodesToSelectionAtom` | Add nodes to selection |
|
|
| `removeNodesFromSelectionAtom` | Remove nodes from selection |
|
|
| `clearSelectionAtom` | Clear all selection |
|
|
| `selectEdgeAtom` | Select an edge |
|
|
| `clearEdgeSelectionAtom` | Clear edge selection |
|
|
|
|
### Hook
|
|
|
|
```tsx
|
|
import { useCanvasSelection } from '@blinksgg/canvas';
|
|
|
|
function MyComponent() {
|
|
const { selectedNodeIds, count, hasSelection, hasEdgeSelection } = useCanvasSelection();
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Virtualization (Performance)
|
|
|
|
Canvas uses viewport virtualization to only render nodes and edges visible on screen. This dramatically improves performance for large graphs (100+ nodes).
|
|
|
|
### How It Works
|
|
|
|
- Only nodes within the viewport bounds (plus 200px buffer) are rendered
|
|
- Edges are only rendered if both source and target nodes are visible
|
|
- Virtualization is enabled by default and persisted to localStorage
|
|
|
|
### Settings Integration
|
|
|
|
Virtualization is controlled through the canvas settings system, which persists user preferences:
|
|
|
|
```tsx
|
|
import { useCanvasSettings } from '@blinksgg/canvas';
|
|
|
|
function VirtualizationToggle() {
|
|
const settings = useCanvasSettings();
|
|
// Access virtualizationEnabled from settings
|
|
}
|
|
```
|
|
|
|
### Atoms
|
|
|
|
| Atom | Purpose |
|
|
|------|---------|
|
|
| `virtualizationEnabledAtom` | Whether virtualization is on (persisted) |
|
|
| `setVirtualizationEnabledAtom` | Set virtualization enabled/disabled |
|
|
| `toggleVirtualizationAtom` | Toggle virtualization on/off |
|
|
| `visibleBoundsAtom` | Current visible area in world coords |
|
|
| `visibleNodeKeysAtom` | Node IDs visible in viewport |
|
|
| `visibleEdgeKeysAtom` | Edge keys visible in viewport |
|
|
| `virtualizationMetricsAtom` | Debug metrics |
|
|
|
|
### Hook
|
|
|
|
```tsx
|
|
import { useVirtualization } from '@blinksgg/canvas';
|
|
|
|
function PerformanceMonitor() {
|
|
const {
|
|
enabled, // Is virtualization on?
|
|
totalNodes, // Total nodes in graph
|
|
visibleNodes, // Nodes being rendered
|
|
culledNodes, // Nodes not rendered (off-screen)
|
|
totalEdges,
|
|
visibleEdges,
|
|
culledEdges,
|
|
bounds, // Current visible bounds { minX, minY, maxX, maxY }
|
|
enable, // Enable virtualization
|
|
disable, // Disable (render all)
|
|
toggle, // Toggle on/off
|
|
} = useVirtualization();
|
|
|
|
return (
|
|
<div>
|
|
Rendering {visibleNodes}/{totalNodes} nodes
|
|
({culledNodes} culled)
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Disable Virtualization
|
|
|
|
For debugging or small graphs:
|
|
|
|
```tsx
|
|
import { useSetAtom } from 'jotai';
|
|
import { setVirtualizationEnabledAtom, toggleVirtualizationAtom } from '@blinksgg/canvas';
|
|
|
|
// Set directly
|
|
const setEnabled = useSetAtom(setVirtualizationEnabledAtom);
|
|
setEnabled(false); // Render all nodes
|
|
|
|
// Or toggle
|
|
const toggle = useSetAtom(toggleVirtualizationAtom);
|
|
toggle(); // Toggle on/off
|
|
```
|
|
|
|
---
|
|
|
|
## Viewport (Pan/Zoom)
|
|
|
|
### Atoms
|
|
|
|
| Atom | Purpose |
|
|
|------|---------|
|
|
| `zoomAtom` | Current zoom level (0.1 to 3) |
|
|
| `panAtom` | Current pan offset { x, y } |
|
|
| `viewportRectAtom` | Viewport dimensions |
|
|
| `screenToWorldAtom` | Convert screen coords to world |
|
|
| `worldToScreenAtom` | Convert world coords to screen |
|
|
|
|
### Actions
|
|
|
|
| Atom | Purpose |
|
|
|------|---------|
|
|
| `setZoomAtom` | Set zoom level |
|
|
| `resetViewportAtom` | Reset to default view |
|
|
|
|
### Hook
|
|
|
|
```tsx
|
|
import { useCanvasViewport } from '@blinksgg/canvas';
|
|
|
|
function MyComponent() {
|
|
const { zoom, pan, screenToWorld, worldToScreen } = useCanvasViewport();
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Layout (Fit-to-View, Force Layout, Tree, Grid)
|
|
|
|
### Layout Hooks
|
|
|
|
| Hook | Purpose |
|
|
|------|---------|
|
|
| `useLayout` | Combined layout utilities (fitToBounds, bounds) |
|
|
| `useFitToBounds` | Fit viewport to show nodes |
|
|
| `useGetGraphBounds` | Calculate bounding rect of all nodes |
|
|
| `useSelectionBounds` | Calculate bounding rect of selected nodes |
|
|
| `useForceLayout` | Apply force-directed layout to arrange nodes |
|
|
| `useTreeLayout` | Hierarchical tree layout (top-down or left-right) |
|
|
| `useGridLayout` | Uniform grid layout (auto columns, spatial sort) |
|
|
| `useAnimatedLayout` | Shared animated position interpolation hook |
|
|
|
|
### Fit to Bounds
|
|
|
|
Automatically zoom and pan to show all nodes or selection:
|
|
|
|
```tsx
|
|
import { useFitToBounds, FitToBoundsMode } from '@blinksgg/canvas/hooks';
|
|
|
|
function MyComponent() {
|
|
const { fitToBounds } = useFitToBounds();
|
|
|
|
// Fit all nodes in view with 50px padding
|
|
const handleFitAll = () => {
|
|
fitToBounds(FitToBoundsMode.Graph, 50);
|
|
};
|
|
|
|
// Fit only selected nodes
|
|
const handleFitSelection = () => {
|
|
fitToBounds(FitToBoundsMode.Selection, 20);
|
|
};
|
|
}
|
|
```
|
|
|
|
### Graph Bounds
|
|
|
|
Get the bounding rectangle of all nodes:
|
|
|
|
```tsx
|
|
import { useGetGraphBounds } from '@blinksgg/canvas/hooks';
|
|
|
|
function MyComponent() {
|
|
const { bounds, nodes } = useGetGraphBounds();
|
|
|
|
console.log(`Graph spans ${bounds.width}x${bounds.height}`);
|
|
console.log(`Top-left at (${bounds.x}, ${bounds.y})`);
|
|
}
|
|
```
|
|
|
|
### Force Layout
|
|
|
|
Apply D3 force-directed layout to arrange nodes without overlap:
|
|
|
|
```tsx
|
|
import { useForceLayout } from '@blinksgg/canvas/hooks';
|
|
|
|
function MyComponent() {
|
|
// Basic usage (no persistence)
|
|
const { applyForceLayout } = useForceLayout();
|
|
|
|
const handleArrange = async () => {
|
|
await applyForceLayout();
|
|
// Nodes are now arranged without overlap
|
|
};
|
|
}
|
|
```
|
|
|
|
With database persistence callback:
|
|
|
|
```tsx
|
|
import { useForceLayout, type NodePositionUpdate } from '@blinksgg/canvas/hooks';
|
|
|
|
function MyComponent() {
|
|
const { applyForceLayout } = useForceLayout({
|
|
onPositionsChanged: async (updates: NodePositionUpdate[]) => {
|
|
// Save all positions to your database
|
|
for (const { nodeId, position } of updates) {
|
|
await savePosition(nodeId, position);
|
|
}
|
|
},
|
|
maxIterations: 1000, // Default: 1000
|
|
chargeStrength: -6000, // Default: -6000 (more negative = stronger repulsion)
|
|
linkStrength: 0.03, // Default: 0.03
|
|
});
|
|
}
|
|
```
|
|
|
|
### Layout Utilities (Pure Functions)
|
|
|
|
Available from `@blinksgg/canvas/utils`:
|
|
|
|
| Function | Purpose |
|
|
|----------|---------|
|
|
| `calculateBounds(nodes)` | Calculate bounding rect from array of Rect |
|
|
| `checkNodesOverlap(node1, node2)` | Check if two nodes overlap |
|
|
| `getNodeCenter(node)` | Get center point of a node |
|
|
| `setNodeCenter(node, x, y)` | Set node position by center |
|
|
| `getNodeBounds(node)` | Get full bounds with edges |
|
|
| `areNodesClose(node1, node2, threshold)` | Check if nodes are within distance |
|
|
|
|
```tsx
|
|
import { calculateBounds, checkNodesOverlap, type Rect } from '@blinksgg/canvas/utils';
|
|
|
|
const nodes: Rect[] = [
|
|
{ x: 0, y: 0, width: 100, height: 100 },
|
|
{ x: 200, y: 150, width: 100, height: 100 },
|
|
];
|
|
|
|
const bounds = calculateBounds(nodes);
|
|
// bounds = { x: 0, y: 0, width: 300, height: 250 }
|
|
|
|
const overlaps = checkNodesOverlap(nodes[0], nodes[1]);
|
|
// overlaps = false
|
|
```
|
|
|
|
---
|
|
|
|
## Undo/Redo (History)
|
|
|
|
### Atoms
|
|
|
|
| Atom | Purpose |
|
|
|------|---------|
|
|
| `canUndoAtom` | Boolean: can undo? |
|
|
| `canRedoAtom` | Boolean: can redo? |
|
|
| `undoCountAtom` | Number of undo steps available |
|
|
| `redoCountAtom` | Number of redo steps available |
|
|
| `historyLabelsAtom` | Labels for history entries |
|
|
|
|
### Actions
|
|
|
|
| Atom | Purpose |
|
|
|------|---------|
|
|
| `pushHistoryAtom` | Push current state to history |
|
|
| `undoAtom` | Undo last action |
|
|
| `redoAtom` | Redo last undone action |
|
|
| `clearHistoryAtom` | Clear history stack |
|
|
|
|
### Hook
|
|
|
|
```tsx
|
|
import { useCanvasHistory } from '@blinksgg/canvas';
|
|
|
|
function MyComponent() {
|
|
const { undo, redo, canUndo, canRedo, pushHistory } = useCanvasHistory();
|
|
|
|
// Before making changes, save state
|
|
pushHistory('Move nodes');
|
|
|
|
// User can then undo/redo
|
|
if (canUndo) undo();
|
|
if (canRedo) redo();
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Node Drag & Resize
|
|
|
|
### Hooks
|
|
|
|
```tsx
|
|
import { useNodeDrag, useNodeResize } from '@blinksgg/canvas';
|
|
|
|
// For drag functionality
|
|
const { bind, isDragging } = useNodeDrag(nodeId, nodeData);
|
|
|
|
// For resize functionality
|
|
const { localWidth, localHeight, isResizing, createResizeStart } = useNodeResize({
|
|
id: nodeId,
|
|
nodeData,
|
|
updateNodePositions,
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Node Type Registry
|
|
|
|
Register custom components for different node types:
|
|
|
|
```tsx
|
|
import { registerNodeType, registerNodeTypes, getNodeTypeComponent } from '@blinksgg/canvas';
|
|
|
|
// Register single type
|
|
registerNodeType('note', NoteNodeComponent);
|
|
|
|
// Register multiple types
|
|
registerNodeTypes({
|
|
'note': NoteNodeComponent,
|
|
'image': ImageNodeComponent,
|
|
'code': CodeNodeComponent,
|
|
});
|
|
|
|
// Check if type exists
|
|
const hasType = hasNodeTypeComponent('note');
|
|
|
|
// Get all registered types
|
|
const types = getRegisteredNodeTypes(); // ['note', 'image', 'code']
|
|
```
|
|
|
|
---
|
|
|
|
## Bundled Node Components
|
|
|
|
### NoteNode
|
|
|
|
A rich text editor node using BlockNote:
|
|
|
|
```tsx
|
|
import { NoteNode, type NoteNodeStorage } from '@blinksgg/canvas';
|
|
|
|
// Implement storage adapter
|
|
const storage: NoteNodeStorage = {
|
|
content: htmlContent,
|
|
onChange: (html) => saveToBackend(html),
|
|
subscribe: (callback) => subscribeToChanges(callback),
|
|
isLoading: false,
|
|
};
|
|
|
|
<NoteNode nodeData={nodeData} storage={storage} theme="light" />
|
|
```
|
|
|
|
---
|
|
|
|
## Components
|
|
|
|
| Component | Purpose |
|
|
|-----------|---------|
|
|
| `Canvas` | Main canvas container |
|
|
| `CanvasProvider` | Provides canvas context |
|
|
| `CanvasDbProvider` | Provides Supabase client |
|
|
| `CanvasStyleProvider` | Theming and styles |
|
|
| `InputProvider` / `GestureProvider` | Gesture system v2 context |
|
|
| `Viewport` | Pan/zoom container |
|
|
| `NodeRenderer` | Renders all nodes |
|
|
| `Node` | Single node wrapper |
|
|
| `GroupNode` | Collapsible group container |
|
|
| `ConnectedNode` | Node with connections |
|
|
| `EdgeRenderer` | Renders all edges |
|
|
| `Grid` | Background grid |
|
|
| `Minimap` | Canvas overview with draggable viewport |
|
|
| `SelectionOverlay` | Lasso/rect selection |
|
|
| `ViewportControls` | Zoom +/- buttons |
|
|
| `ResizeHandle` | Node resize handles |
|
|
| `NodeContextMenu` | Right-click menu |
|
|
| `NodeErrorBoundary` | Error boundary for nodes |
|
|
| `LockedNodeOverlay` | Full-screen node view |
|
|
| `EdgeOverlay` | Edge creation overlay |
|
|
| `EdgeLabelEditor` | Inline edge label editing |
|
|
| `AlignmentGuides` | Snap alignment lines |
|
|
| `CanvasAnimations` | CSS animation injection |
|
|
| `NodeTypeCombobox` | Combobox to select and add nodes |
|
|
| `CommandLine` | Command palette UI |
|
|
| `CommandFeedbackOverlay` | Command input feedback |
|
|
|
|
---
|
|
|
|
## Graph State (Core Atoms)
|
|
|
|
| Atom | Purpose |
|
|
|------|---------|
|
|
| `graphAtom` | Graphology graph instance |
|
|
| `currentGraphIdAtom` | Active graph ID |
|
|
| `uiNodesAtom` | All nodes as UINodeState[] |
|
|
| `nodeKeysAtom` | Array of node IDs |
|
|
| `edgeKeysAtom` | Array of edge keys |
|
|
|
|
### Local Graph Mutations (Optimistic)
|
|
|
|
| Atom | Purpose |
|
|
|------|---------|
|
|
| `addNodeToLocalGraphAtom` | Add node to local graph |
|
|
| `addEdgeToLocalGraphAtom` | Add edge to local graph |
|
|
| `removeEdgeFromLocalGraphAtom` | Remove edge from local graph |
|
|
| `optimisticDeleteNodeAtom` | Optimistically delete node |
|
|
| `optimisticDeleteEdgeAtom` | Optimistically delete edge |
|
|
|
|
---
|
|
|
|
## Sync Status
|
|
|
|
| Atom | Purpose |
|
|
|------|---------|
|
|
| `syncStatusAtom` | 'synced' | 'syncing' | 'error' | 'offline' |
|
|
| `isOnlineAtom` | Boolean: is online? |
|
|
| `pendingMutationsCountAtom` | Count of pending mutations |
|
|
| `lastSyncErrorAtom` | Last sync error message |
|
|
| `mutationQueueAtom` | Queue of pending mutations |
|
|
|
|
---
|
|
|
|
## Locked Node (Full-screen View)
|
|
|
|
| Atom | Purpose |
|
|
|------|---------|
|
|
| `lockedNodeIdAtom` | ID of locked node |
|
|
| `lockedNodeDataAtom` | Data of locked node |
|
|
| `hasLockedNodeAtom` | Boolean: is a node locked? |
|
|
| `lockNodeAtom` | Lock a node (full-screen) |
|
|
| `unlockNodeAtom` | Unlock the node |
|
|
|
|
---
|
|
|
|
## Direct Query Functions
|
|
|
|
For advanced use cases, direct Supabase queries:
|
|
|
|
```tsx
|
|
import {
|
|
fetchGraphNodes,
|
|
fetchGraphNode,
|
|
createGraphNode,
|
|
updateGraphNode,
|
|
deleteGraphNode,
|
|
fetchGraphEdges,
|
|
createGraphEdge,
|
|
updateGraphEdge,
|
|
deleteGraphEdge,
|
|
} from '@blinksgg/canvas';
|
|
|
|
// Use with your own Supabase client
|
|
const nodes = await fetchGraphNodes(supabase, graphId);
|
|
await deleteGraphNode(supabase, nodeId);
|
|
```
|
|
|
|
---
|
|
|
|
## Types
|
|
|
|
Key TypeScript types:
|
|
|
|
```tsx
|
|
import type {
|
|
// Node types
|
|
DBGraphNode,
|
|
UINodeState,
|
|
NodeId,
|
|
GraphNodeAttributes,
|
|
NodePosition,
|
|
|
|
// Edge types
|
|
DBGraphEdge,
|
|
UIEdgeState,
|
|
EdgeKey,
|
|
GraphEdgeAttributes,
|
|
|
|
// Component props
|
|
NodeTypeComponentProps,
|
|
NodeRenderProps,
|
|
CanvasProps,
|
|
|
|
// Storage adapters
|
|
NoteNodeStorage,
|
|
NoteNodeProps,
|
|
} from '@blinksgg/canvas';
|
|
```
|
|
|
|
---
|
|
|
|
## Import Paths
|
|
|
|
```tsx
|
|
// Main entry (everything)
|
|
import { ... } from '@blinksgg/canvas';
|
|
|
|
// Specific subpaths
|
|
import { ... } from '@blinksgg/canvas/core'; // State atoms only
|
|
import { ... } from '@blinksgg/canvas/db'; // Database layer
|
|
import { ... } from '@blinksgg/canvas/hooks'; // React hooks
|
|
import { ... } from '@blinksgg/canvas/nodes'; // Bundled node components
|
|
import { ... } from '@blinksgg/canvas/utils'; // Layout utilities, geometry functions
|
|
import { ... } from '@blinksgg/canvas/gestures'; // Gesture system v2 pipeline
|
|
import { ... } from '@blinksgg/canvas/commands'; // Command palette system
|
|
```
|
|
|
|
### Peer Dependencies
|
|
|
|
| Package | Version |
|
|
|---------|---------|
|
|
| `react` / `react-dom` | ^19.2 |
|
|
| `jotai` | ^2.6 |
|
|
| `d3-force` | ^3.0 (optional) |
|
|
| `@tanstack/react-query` | ^5.17 (optional) |
|
|
|
|
> **React Compiler**: The codebase has zero manual `useCallback`/`useMemo`/`React.memo`. The React Compiler (`babel-plugin-react-compiler`) auto-memoizes everything at build time.
|
|
|
|
---
|
|
|
|
## Common Patterns
|
|
|
|
### Delete Selected Nodes
|
|
|
|
```tsx
|
|
import { useDeleteNode, useCanvasSelection } from '@blinksgg/canvas';
|
|
import { useAtomValue } from 'jotai';
|
|
import { currentGraphIdAtom } from '@blinksgg/canvas';
|
|
|
|
function DeleteButton() {
|
|
const deleteNode = useDeleteNode();
|
|
const { selectedNodeIds } = useCanvasSelection();
|
|
const graphId = useAtomValue(currentGraphIdAtom);
|
|
|
|
const handleDeleteSelected = () => {
|
|
selectedNodeIds.forEach(nodeId => {
|
|
deleteNode.mutate({ nodeId, graphId });
|
|
});
|
|
};
|
|
|
|
return <button onClick={handleDeleteSelected}>Delete Selected</button>;
|
|
}
|
|
```
|
|
|
|
### Create Node at Click Position
|
|
|
|
```tsx
|
|
import { useCreateNode, screenToWorldAtom } from '@blinksgg/canvas';
|
|
import { useAtomValue } from 'jotai';
|
|
|
|
function handleCanvasClick(e: React.MouseEvent, graphId: string) {
|
|
const screenToWorld = useAtomValue(screenToWorldAtom);
|
|
const createNode = useCreateNode();
|
|
|
|
const worldPos = screenToWorld({ x: e.clientX, y: e.clientY });
|
|
|
|
createNode.mutate({
|
|
graphId,
|
|
node: {
|
|
label: 'New Node',
|
|
node_type: 'default',
|
|
ui_properties: { x: worldPos.x, y: worldPos.y, width: 200, height: 150 },
|
|
},
|
|
});
|
|
}
|
|
```
|
|
|
|
### Undo/Redo with Keyboard
|
|
|
|
```tsx
|
|
import { useCanvasHistory } from '@blinksgg/canvas';
|
|
import { useEffect } from 'react';
|
|
|
|
function UndoRedoKeyboard() {
|
|
const { undo, redo, canUndo, canRedo } = useCanvasHistory();
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.metaKey || e.ctrlKey) {
|
|
if (e.key === 'z' && !e.shiftKey && canUndo) {
|
|
e.preventDefault();
|
|
undo();
|
|
}
|
|
if ((e.key === 'z' && e.shiftKey || e.key === 'y') && canRedo) {
|
|
e.preventDefault();
|
|
redo();
|
|
}
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
});
|
|
// React Compiler auto-tracks dependencies — no dep array needed
|
|
|
|
return null;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Plugin System
|
|
|
|
Register canvas plugins that provide custom node types, actions, or behaviors:
|
|
|
|
```tsx
|
|
import { usePlugin, usePlugins } from '@blinksgg/canvas';
|
|
import type { CanvasPlugin } from '@blinksgg/canvas';
|
|
|
|
const myPlugin: CanvasPlugin = {
|
|
id: 'my-plugin',
|
|
name: 'My Plugin',
|
|
nodeTypes: { 'custom': CustomNode },
|
|
};
|
|
|
|
// Single plugin
|
|
function App() {
|
|
usePlugin(myPlugin);
|
|
return <Canvas />;
|
|
}
|
|
|
|
// Multiple plugins (dependencies should come first)
|
|
function App() {
|
|
usePlugins([basePlugin, extensionPlugin]);
|
|
return <Canvas />;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Delete Operations
|
|
|
|
Delete nodes and edges using either database hooks (persisted) or optimistic atoms (local-only).
|
|
|
|
### Database Hooks (Persisted Delete)
|
|
|
|
| Hook | Purpose |
|
|
|------|---------|
|
|
| `useDeleteNode` | Delete node from database |
|
|
| `useDeleteEdge` | Delete edge from database |
|
|
|
|
```tsx
|
|
import { useDeleteNode, useDeleteEdge } from '@blinksgg/canvas';
|
|
|
|
function MyComponent() {
|
|
const deleteNode = useDeleteNode();
|
|
const deleteEdge = useDeleteEdge();
|
|
|
|
// Delete a node (also removes connected edges)
|
|
deleteNode.mutate({ nodeId: 'node-123', graphId: 'graph-id' });
|
|
|
|
// Delete an edge
|
|
deleteEdge.mutate({ edgeId: 'edge-456', graphId: 'graph-id' });
|
|
}
|
|
```
|
|
|
|
### Optimistic Atoms (Local-Only Delete)
|
|
|
|
For local-only operations without database sync:
|
|
|
|
| Atom | Purpose |
|
|
|------|---------|
|
|
| `optimisticDeleteNodeAtom` | Remove node from local graph instantly |
|
|
| `optimisticDeleteEdgeAtom` | Remove edge from local graph instantly |
|
|
|
|
```tsx
|
|
import { useSetAtom } from 'jotai';
|
|
import { optimisticDeleteNodeAtom, optimisticDeleteEdgeAtom } from '@blinksgg/canvas';
|
|
|
|
function MyComponent() {
|
|
const deleteNode = useSetAtom(optimisticDeleteNodeAtom);
|
|
const deleteEdge = useSetAtom(optimisticDeleteEdgeAtom);
|
|
|
|
// Delete node from local graph (no database sync)
|
|
deleteNode('node-123');
|
|
|
|
// Delete edge from local graph (no database sync)
|
|
deleteEdge('source-id', 'target-id');
|
|
}
|
|
```
|
|
|
|
### Delete with Selection
|
|
|
|
```tsx
|
|
import { useDeleteNode, useCanvasSelection, currentGraphIdAtom } from '@blinksgg/canvas';
|
|
import { useAtomValue } from 'jotai';
|
|
|
|
function DeleteSelectedButton() {
|
|
const deleteNode = useDeleteNode();
|
|
const { selectedNodeIds } = useCanvasSelection();
|
|
const graphId = useAtomValue(currentGraphIdAtom);
|
|
|
|
const handleDelete = () => {
|
|
selectedNodeIds.forEach(nodeId => {
|
|
deleteNode.mutate({ nodeId, graphId });
|
|
});
|
|
};
|
|
|
|
return <button onClick={handleDelete}>Delete Selected</button>;
|
|
}
|
|
```
|
|
|
|
### Keyboard Shortcut for Delete
|
|
|
|
```tsx
|
|
import { useDeleteNode, useCanvasSelection, currentGraphIdAtom } from '@blinksgg/canvas';
|
|
import { useAtomValue } from 'jotai';
|
|
import { useEffect } from 'react';
|
|
|
|
function DeleteKeyHandler() {
|
|
const deleteNode = useDeleteNode();
|
|
const { selectedNodeIds } = useCanvasSelection();
|
|
const graphId = useAtomValue(currentGraphIdAtom);
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedNodeIds.length > 0) {
|
|
e.preventDefault();
|
|
selectedNodeIds.forEach(nodeId => {
|
|
deleteNode.mutate({ nodeId, graphId });
|
|
});
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [deleteNode, selectedNodeIds, graphId]);
|
|
|
|
return null;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## NodeTypeCombobox (Add Node UI)
|
|
|
|
A searchable combobox for selecting and adding nodes to the canvas.
|
|
|
|
### Basic Usage
|
|
|
|
```tsx
|
|
import { NodeTypeCombobox } from '@blinksgg/canvas';
|
|
|
|
// Uses all registered node types automatically
|
|
<NodeTypeCombobox onNodeCreated={(id, type) => console.log('Created:', id, type)} />
|
|
```
|
|
|
|
### With Custom Node Types
|
|
|
|
```tsx
|
|
import { NodeTypeCombobox, type NodeTypeOption } from '@blinksgg/canvas';
|
|
|
|
const nodeTypes: NodeTypeOption[] = [
|
|
{ type: 'note', label: 'Note', icon: '📝', description: 'Rich text note' },
|
|
{ type: 'image', label: 'Image', icon: '🖼️', description: 'Image container' },
|
|
{ type: 'code', label: 'Code', icon: '💻', defaultWidth: 400, defaultHeight: 300 },
|
|
];
|
|
|
|
<NodeTypeCombobox
|
|
nodeTypes={nodeTypes}
|
|
placeholder="Add a node..."
|
|
createPosition="center" // or 'random' or { x: 100, y: 200 }
|
|
onNodeCreated={(id) => selectNode(id)}
|
|
/>
|
|
```
|
|
|
|
### Props
|
|
|
|
| Prop | Type | Default | Description |
|
|
|------|------|---------|-------------|
|
|
| `nodeTypes` | `NodeTypeOption[]` | registered types | Custom list of node types |
|
|
| `onNodeCreated` | `(id, type) => void` | - | Callback after creation |
|
|
| `createPosition` | `'center' \| 'random' \| {x,y}` | `'center'` | Where to place new node |
|
|
| `placeholder` | `string` | `'Add node...'` | Input placeholder |
|
|
| `clearOnCreate` | `boolean` | `true` | Clear search after create |
|
|
| `disabled` | `boolean` | `false` | Disable the combobox |
|
|
|
|
### NodeTypeOption Interface
|
|
|
|
```tsx
|
|
interface NodeTypeOption {
|
|
type: string; // Node type identifier
|
|
label?: string; // Display label
|
|
icon?: string; // Emoji or icon
|
|
description?: string; // Shown in dropdown
|
|
defaultWidth?: number; // Default: 200
|
|
defaultHeight?: number; // Default: 150
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Settings Panel (Event-Action Mappings)
|
|
|
|
Configure what actions trigger on canvas events (double-click node → fit to view, etc.).
|
|
|
|
### Basic Usage
|
|
|
|
```tsx
|
|
import { SettingsPanel, useCanvasSettings } from '@blinksgg/canvas';
|
|
|
|
function MyApp() {
|
|
const { isPanelOpen, togglePanel } = useCanvasSettings();
|
|
|
|
return (
|
|
<>
|
|
<button onClick={togglePanel}>Settings</button>
|
|
{isPanelOpen && <SettingsPanel onClose={togglePanel} />}
|
|
</>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Canvas Events
|
|
|
|
| Event | Description |
|
|
|-------|-------------|
|
|
| `node:double-click` | Double-click on a node |
|
|
| `node:triple-click` | Triple-click on a node |
|
|
| `node:right-click` | Right-click on a node |
|
|
| `node:long-press` | Long-press on a node (touch) |
|
|
| `background:click` | Click on canvas background |
|
|
| `background:double-click` | Double-click on background |
|
|
| `background:right-click` | Right-click on background |
|
|
| `background:long-press` | Long-press on background (touch) |
|
|
|
|
### Built-in Actions
|
|
|
|
| Action ID | Description |
|
|
|-----------|-------------|
|
|
| `none` | Do nothing |
|
|
| `select-node` | Select the node |
|
|
| `add-to-selection` | Add node to selection |
|
|
| `clear-selection` | Clear all selection |
|
|
| `delete-selected` | Delete selected nodes |
|
|
| `fit-to-view` | Fit node in viewport |
|
|
| `fit-all-to-view` | Fit all nodes |
|
|
| `center-on-node` | Center viewport on node |
|
|
| `lock-node` | Lock the node |
|
|
| `unlock-node` | Unlock the node |
|
|
| `toggle-lock` | Toggle node lock |
|
|
| `open-context-menu` | Open context menu |
|
|
| `apply-force-layout` | Apply D3 force layout |
|
|
| `undo` | Undo last action |
|
|
| `redo` | Redo last action |
|
|
| `create-node` | Create a new node |
|
|
|
|
### useCanvasSettings Hook
|
|
|
|
```tsx
|
|
import { useCanvasSettings } from '@blinksgg/canvas';
|
|
|
|
function SettingsManager() {
|
|
const {
|
|
mappings, // Current event-action mappings
|
|
activePresetId, // ID of active preset (null if modified)
|
|
allPresets, // Built-in + custom presets
|
|
hasUnsavedChanges, // True if mappings differ from preset
|
|
isPanelOpen, // Panel visibility state
|
|
setEventMapping, // Set action for an event
|
|
applyPreset, // Apply a preset
|
|
saveAsPreset, // Save current mappings as preset
|
|
deletePreset, // Delete a custom preset
|
|
resetSettings, // Reset to defaults
|
|
togglePanel, // Toggle panel visibility
|
|
} = useCanvasSettings();
|
|
}
|
|
```
|
|
|
|
### useActionExecutor Hook
|
|
|
|
Execute actions programmatically with access to canvas state:
|
|
|
|
```tsx
|
|
import { useActionExecutor, CanvasEventType } from '@blinksgg/canvas';
|
|
|
|
function MyCanvas() {
|
|
const { executeEventAction, helpers } = useActionExecutor({
|
|
onDeleteNode: async (nodeId) => await deleteNode({ nodeId, graphId }),
|
|
onOpenContextMenu: (pos, nodeId) => openMenu(pos, nodeId),
|
|
onCreateNode: async (pos) => await createNode(pos),
|
|
onApplyForceLayout: async () => await applyLayout(),
|
|
});
|
|
|
|
const handleDoubleClick = (e: React.MouseEvent, nodeId: string) => {
|
|
// Execute the action mapped to double-click
|
|
executeEventAction(CanvasEventType.NodeDoubleClick, {
|
|
eventType: CanvasEventType.NodeDoubleClick,
|
|
nodeId,
|
|
worldPosition: { x: 100, y: 100 },
|
|
screenPosition: { x: e.clientX, y: e.clientY },
|
|
modifiers: { shift: e.shiftKey, ctrl: e.ctrlKey, alt: e.altKey, meta: e.metaKey },
|
|
});
|
|
};
|
|
}
|
|
```
|
|
|
|
### Register Custom Actions
|
|
|
|
```tsx
|
|
import { registerAction, ActionCategory } from '@blinksgg/canvas';
|
|
|
|
registerAction({
|
|
id: 'my-custom-action',
|
|
label: 'My Action',
|
|
description: 'Does something custom',
|
|
category: ActionCategory.Custom,
|
|
icon: 'star',
|
|
handler: async (context, helpers) => {
|
|
// context has: eventType, nodeId, nodeData, worldPosition, screenPosition, modifiers
|
|
// helpers has: selectNode, clearSelection, fitToBounds, lockNode, undo, redo, etc.
|
|
if (context.nodeId) {
|
|
helpers.selectNode(context.nodeId);
|
|
}
|
|
},
|
|
});
|
|
```
|
|
|
|
### Built-in Presets
|
|
|
|
| Preset | Description |
|
|
|--------|-------------|
|
|
| `default` | Standard interactions (current behavior) |
|
|
| `minimal` | Only selection and context menu |
|
|
| `power-user` | Quick actions (double-click lock, triple-click delete) |
|
|
|
|
### Wiring Node/Viewport Callbacks
|
|
|
|
```tsx
|
|
import { Node, Viewport, useActionExecutor, CanvasEventType } from '@blinksgg/canvas';
|
|
|
|
function MyCanvas() {
|
|
const { executeEventAction, helpers } = useActionExecutor({
|
|
onDeleteNode: (id) => deleteMutation.mutate({ nodeId: id, graphId }),
|
|
onOpenContextMenu: (pos, nodeId) => setMenu({ pos, nodeId }),
|
|
});
|
|
|
|
return (
|
|
<Viewport
|
|
onBackgroundDoubleClick={(worldPos) => {
|
|
executeEventAction(CanvasEventType.BackgroundDoubleClick, {
|
|
eventType: CanvasEventType.BackgroundDoubleClick,
|
|
worldPosition: worldPos,
|
|
screenPosition: worldPos, // Approximate
|
|
modifiers: { shift: false, ctrl: false, alt: false, meta: false },
|
|
});
|
|
}}
|
|
>
|
|
<NodeRenderer
|
|
onRenderNode={(node) => (
|
|
<Node
|
|
nodeData={node}
|
|
onDoubleClick={(id, data) => {
|
|
executeEventAction(CanvasEventType.NodeDoubleClick, {
|
|
eventType: CanvasEventType.NodeDoubleClick,
|
|
nodeId: id,
|
|
nodeData: data,
|
|
worldPosition: { x: data.x, y: data.y },
|
|
screenPosition: { x: 0, y: 0 },
|
|
modifiers: { shift: false, ctrl: false, alt: false, meta: false },
|
|
});
|
|
}}
|
|
onRightClick={(id, data, e) => {
|
|
executeEventAction(CanvasEventType.NodeRightClick, {
|
|
eventType: CanvasEventType.NodeRightClick,
|
|
nodeId: id,
|
|
worldPosition: { x: data.x, y: data.y },
|
|
screenPosition: { x: e.clientX, y: e.clientY },
|
|
modifiers: { shift: e.shiftKey, ctrl: e.ctrlKey, alt: e.altKey, meta: e.metaKey },
|
|
});
|
|
}}
|
|
/>
|
|
)}
|
|
/>
|
|
</Viewport>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Settings Panel
|
|
|
|
Canvas provides a customizable settings system for mapping user interactions (events) to actions.
|
|
|
|
### useCanvasSettings Hook
|
|
|
|
```tsx
|
|
import { useCanvasSettings } from '@blinksgg/canvas';
|
|
|
|
function SettingsUI() {
|
|
const {
|
|
// Read-only state
|
|
mappings, // Current event-action mappings
|
|
activePresetId, // ID of active preset (null if modified)
|
|
activePreset, // Active preset object
|
|
allPresets, // All available presets (built-in + custom)
|
|
hasUnsavedChanges, // Whether mappings differ from preset
|
|
isPanelOpen, // Whether settings panel is visible
|
|
|
|
// Actions
|
|
setEventMapping, // Set action for a specific event
|
|
applyPreset, // Apply a preset by ID
|
|
saveAsPreset, // Save current mappings as new preset
|
|
updatePreset, // Update existing preset with current mappings
|
|
deletePreset, // Delete a custom preset
|
|
resetSettings, // Reset to default settings
|
|
togglePanel, // Toggle settings panel visibility
|
|
setPanelOpen, // Set panel open/closed directly
|
|
|
|
// Utilities
|
|
getActionForEvent, // Get action ID for an event
|
|
} = useCanvasSettings();
|
|
}
|
|
```
|
|
|
|
### Settings Panel Component
|
|
|
|
```tsx
|
|
import { SettingsPanel, useCanvasSettings } from '@blinksgg/canvas';
|
|
|
|
function MyApp() {
|
|
const { isPanelOpen, togglePanel } = useCanvasSettings();
|
|
|
|
return (
|
|
<>
|
|
<button onClick={togglePanel}>⚙️ Settings</button>
|
|
<SettingsPanel onClose={() => togglePanel()} />
|
|
</>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Event Types
|
|
|
|
Events that can be mapped to actions:
|
|
|
|
| Event Type | Description |
|
|
|------------|-------------|
|
|
| `NodeDoubleClick` | Double-click on a node |
|
|
| `NodeTripleClick` | Triple-click on a node |
|
|
| `NodeRightClick` | Right-click on a node |
|
|
| `NodeLongPress` | Long-press on a node (touch) |
|
|
| `BackgroundClick` | Click on canvas background |
|
|
| `BackgroundDoubleClick` | Double-click on background |
|
|
| `BackgroundRightClick` | Right-click on background |
|
|
| `BackgroundLongPress` | Long-press on background (touch) |
|
|
|
|
### Built-in Actions
|
|
|
|
| Action ID | Description |
|
|
|-----------|-------------|
|
|
| `none` | No action (disabled) |
|
|
| `select-node` | Select the clicked node |
|
|
| `add-to-selection` | Add node to selection |
|
|
| `clear-selection` | Clear all selection |
|
|
| `delete-selected` | Delete selected nodes |
|
|
| `fit-to-view` | Fit node to viewport |
|
|
| `fit-all-to-view` | Fit entire graph to viewport |
|
|
| `center-on-node` | Center viewport on node |
|
|
| `reset-viewport` | Reset viewport to default |
|
|
| `lock-node` | Lock node (full-screen view) |
|
|
| `unlock-node` | Unlock node |
|
|
| `toggle-lock` | Toggle node lock state |
|
|
| `open-context-menu` | Open right-click menu |
|
|
| `apply-force-layout` | Apply force-directed layout |
|
|
| `undo` | Undo last action |
|
|
| `redo` | Redo last undone action |
|
|
| `create-node` | Create a new node |
|
|
|
|
### Built-in Presets
|
|
|
|
| Preset | Description |
|
|
|--------|-------------|
|
|
| `default` | Standard canvas behavior |
|
|
| `productivity` | Optimized for quick editing |
|
|
| `presentation` | Clean presentation mode |
|
|
|
|
### Custom Presets
|
|
|
|
```tsx
|
|
const { saveAsPreset, applyPreset, deletePreset } = useCanvasSettings();
|
|
|
|
// Save current mappings as a new preset
|
|
const newPresetId = saveAsPreset('My Custom Preset', 'Optional description');
|
|
|
|
// Apply a preset
|
|
applyPreset('productivity');
|
|
applyPreset(newPresetId);
|
|
|
|
// Delete a custom preset (built-in presets cannot be deleted)
|
|
deletePreset(newPresetId);
|
|
```
|
|
|
|
### Settings Atoms (for Jotai)
|
|
|
|
| Atom | Purpose |
|
|
|------|---------|
|
|
| `canvasSettingsAtom` | Full settings state (persisted) |
|
|
| `eventMappingsAtom` | Current event-action mappings |
|
|
| `activePresetIdAtom` | Currently active preset ID |
|
|
| `allPresetsAtom` | All presets (built-in + custom) |
|
|
| `isPanelOpenAtom` | Settings panel visibility |
|
|
| `hasUnsavedChangesAtom` | Whether mappings differ from preset |
|
|
|
|
### Action Atoms
|
|
|
|
| Atom | Purpose |
|
|
|------|---------|
|
|
| `setEventMappingAtom` | Set a single event-action mapping |
|
|
| `applyPresetAtom` | Apply a preset by ID |
|
|
| `saveAsPresetAtom` | Save current mappings as new preset |
|
|
| `updatePresetAtom` | Update existing preset |
|
|
| `deletePresetAtom` | Delete a custom preset |
|
|
| `resetSettingsAtom` | Reset to default settings |
|
|
| `togglePanelAtom` | Toggle settings panel |
|
|
| `setPanelOpenAtom` | Set panel open state |
|
|
|
|
---
|
|
|
|
## Command Console
|
|
|
|
An extensible command palette with fuzzy search, sequential input collection, and keyboard shortcuts.
|
|
|
|
### Import Path
|
|
|
|
```tsx
|
|
import { ... } from '@blinksgg/canvas/commands';
|
|
```
|
|
|
|
### Quick Start
|
|
|
|
```tsx
|
|
import {
|
|
CommandProvider,
|
|
CommandLine,
|
|
CommandFeedbackOverlay,
|
|
registerBuiltinCommands,
|
|
registerCommand,
|
|
useCommandLine,
|
|
} from '@blinksgg/canvas/commands';
|
|
|
|
// 1. Register built-in commands once
|
|
registerBuiltinCommands();
|
|
|
|
// 2. Register custom commands
|
|
registerCommand({
|
|
name: 'createWidget',
|
|
description: 'Create a new widget',
|
|
category: 'custom',
|
|
inputs: [{ type: 'text', name: 'name', prompt: 'Widget name' }],
|
|
execute: async (inputs, ctx) => {
|
|
await ctx.mutations.createNode({ type: 'widget', label: inputs.name });
|
|
},
|
|
});
|
|
|
|
// 3. Wrap your app with CommandProvider
|
|
function App() {
|
|
return (
|
|
<CommandProvider
|
|
onCreateNode={createNodeMutation.mutateAsync}
|
|
onDeleteNode={deleteNodeMutation.mutateAsync}
|
|
>
|
|
<Canvas />
|
|
<CommandLine />
|
|
<CommandFeedbackOverlay />
|
|
</CommandProvider>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Built-in Commands
|
|
|
|
| Command | Aliases | Description |
|
|
|---------|---------|-------------|
|
|
| `fitToView` | `fit`, `fitAll` | Fit all nodes in viewport |
|
|
| `fitSelection` | `fitSel` | Fit selected nodes in viewport |
|
|
| `resetViewport` | `reset`, `home` | Reset zoom and pan |
|
|
| `zoomIn` | `+`, `in` | Zoom in |
|
|
| `zoomOut` | `-`, `out` | Zoom out |
|
|
| `selectAll` | `all` | Select all nodes |
|
|
| `clearSelection` | `deselect`, `clear` | Clear selection |
|
|
| `invertSelection` | `invert` | Invert selection |
|
|
| `undo` | `z` | Undo last action |
|
|
| `redo` | `y` | Redo last action |
|
|
| `forceLayout` | `force`, `autoLayout` | Apply force-directed layout |
|
|
|
|
### Keyboard Shortcuts
|
|
|
|
| Shortcut | Action |
|
|
|----------|--------|
|
|
| `/` | Toggle command line |
|
|
| `Cmd+K` / `Ctrl+K` | Open command line |
|
|
| `ESC` | Cancel current command / return to search |
|
|
| `↑` / `↓` | Navigate suggestions |
|
|
| `Enter` | Select command / provide input |
|
|
| `Tab` | Autocomplete command name |
|
|
|
|
### useCommandLine Hook
|
|
|
|
```tsx
|
|
import { useCommandLine } from '@blinksgg/canvas/hooks';
|
|
|
|
function CommandToggle() {
|
|
const {
|
|
visible, // Command line visibility
|
|
state, // Current state (searching, collecting, executing, error)
|
|
history, // Command history
|
|
progress, // Input collection progress { current, total }
|
|
open, // Open command line
|
|
close, // Close command line
|
|
isSearching, // Helper: state.phase === 'searching'
|
|
isCollecting, // Helper: state.phase === 'collecting'
|
|
isExecuting, // Helper: state.phase === 'executing'
|
|
hasError, // Helper: state.phase === 'error'
|
|
} = useCommandLine();
|
|
|
|
return <button onClick={open}>Commands (⌘K)</button>;
|
|
}
|
|
```
|
|
|
|
### Register Custom Commands
|
|
|
|
```tsx
|
|
import { registerCommand, type CommandDefinition } from '@blinksgg/canvas/commands';
|
|
|
|
const myCommand: CommandDefinition = {
|
|
name: 'myCommand',
|
|
description: 'Does something cool',
|
|
aliases: ['mc'], // Optional shortcuts
|
|
category: 'custom', // 'nodes' | 'edges' | 'viewport' | 'selection' | 'history' | 'layout' | 'custom'
|
|
inputs: [
|
|
{ type: 'text', name: 'title', prompt: 'Enter title' },
|
|
{ type: 'number', name: 'count', prompt: 'How many?', default: 1 },
|
|
{
|
|
type: 'select',
|
|
name: 'color',
|
|
prompt: 'Choose color',
|
|
options: [
|
|
{ value: 'red', label: 'Red', shortcut: 'r' },
|
|
{ value: 'blue', label: 'Blue', shortcut: 'b' },
|
|
],
|
|
},
|
|
{ type: 'point', name: 'position', prompt: 'Click location' },
|
|
{ type: 'node', name: 'target', prompt: 'Select target node' },
|
|
{ type: 'boolean', name: 'confirm', prompt: 'Are you sure?' },
|
|
],
|
|
execute: async (inputs, ctx) => {
|
|
// inputs: { title: string, count: number, color: string, position: {x,y}, target: string, confirm: boolean }
|
|
// ctx: CommandContext with mutations, layout, history, viewport, selectedNodeIds
|
|
await ctx.mutations.createNode({
|
|
graph_id: ctx.currentGraphId,
|
|
node_type: 'custom',
|
|
label: inputs.title,
|
|
ui_properties: { x: inputs.position.x, y: inputs.position.y },
|
|
});
|
|
},
|
|
};
|
|
|
|
registerCommand(myCommand);
|
|
```
|
|
|
|
### Input Types
|
|
|
|
| Type | Description | Value |
|
|
|------|-------------|-------|
|
|
| `text` | Text input | `string` |
|
|
| `number` | Numeric input | `number` |
|
|
| `boolean` | Yes/No choice | `boolean` |
|
|
| `select` | Dropdown options | `string` |
|
|
| `point` | Click on canvas | `{ x: number, y: number }` |
|
|
| `node` | Click on a node | `string` (nodeId) |
|
|
| `nodes` | Select multiple nodes | `string[]` (nodeIds) |
|
|
|
|
### CommandProvider Props
|
|
|
|
```tsx
|
|
interface CommandProviderProps {
|
|
children: React.ReactNode;
|
|
|
|
// Node mutations (app provides implementations)
|
|
onCreateNode?: (payload: CreateNodePayload) => Promise<unknown>;
|
|
onUpdateNode?: (nodeId: string, updates: Record<string, unknown>) => Promise<void>;
|
|
onDeleteNode?: (nodeId: string) => Promise<void>;
|
|
|
|
// Edge mutations
|
|
onCreateEdge?: (payload: CreateEdgePayload) => Promise<unknown>;
|
|
onDeleteEdge?: (edgeId: string) => Promise<void>;
|
|
|
|
// Force layout persistence
|
|
onForceLayoutPersist?: (positions: Array<{ nodeId: string; x: number; y: number }>) => Promise<void>;
|
|
}
|
|
```
|
|
|
|
### CommandContext (in execute function)
|
|
|
|
```tsx
|
|
interface CommandContext {
|
|
get: JotaiStore['get']; // Read atoms directly
|
|
set: JotaiStore['set']; // Write atoms directly
|
|
currentGraphId: string | null; // Active graph ID
|
|
selectedNodeIds: Set<string>; // Currently selected nodes
|
|
viewport: { zoom: number; pan: { x: number; y: number } };
|
|
|
|
mutations: {
|
|
createNode: (payload: CreateNodePayload) => Promise<unknown>;
|
|
updateNode: (nodeId: string, updates: Record<string, unknown>) => Promise<void>;
|
|
deleteNode: (nodeId: string) => Promise<void>;
|
|
createEdge: (payload: CreateEdgePayload) => Promise<unknown>;
|
|
deleteEdge: (edgeId: string) => Promise<void>;
|
|
};
|
|
|
|
layout: {
|
|
fitToBounds: (mode: 'graph' | 'selection', padding?: number) => void;
|
|
applyForceLayout: () => Promise<void>;
|
|
};
|
|
|
|
history: {
|
|
undo: () => void;
|
|
redo: () => void;
|
|
};
|
|
}
|
|
```
|
|
|
|
### Command Line UI Components
|
|
|
|
| Component | Purpose |
|
|
|-----------|---------|
|
|
| `CommandLine` | Main command bar (appears at bottom) |
|
|
| `CommandSearch` | Fuzzy search autocomplete |
|
|
| `CommandInputCollector` | Sequential input prompts |
|
|
| `CommandFeedbackOverlay` | Visual feedback (crosshairs, previews) |
|
|
|
|
### Store Atoms
|
|
|
|
| Atom | Purpose |
|
|
|------|---------|
|
|
| `commandLineVisibleAtom` | Command line visibility |
|
|
| `commandLineStateAtom` | Full state (phase, suggestions, etc.) |
|
|
| `commandFeedbackAtom` | Visual feedback state |
|
|
| `commandHistoryAtom` | Command execution history |
|
|
| `openCommandLineAtom` | Action: open command line |
|
|
| `closeCommandLineAtom` | Action: close command line |
|
|
| `selectCommandAtom` | Action: select a command |
|
|
| `isCommandActiveAtom` | Boolean: is command line active? |
|
|
| `currentInputAtom` | Current input being collected |
|
|
| `commandProgressAtom` | Progress: { current, total } |
|