Compare commits
3 Commits
4bd2f1b458
...
a0bf9c6b70
| Author | SHA1 | Date | |
|---|---|---|---|
| a0bf9c6b70 | |||
| 51c96641f0 | |||
| 923bf4bff0 |
185
frontend/docs/CANVAS_PERFORMANCE_OPTIONS.md
Normal file
185
frontend/docs/CANVAS_PERFORMANCE_OPTIONS.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# Canvas performance: options and refactoring for scalability
|
||||
|
||||
This document lists **concrete options** to improve canvas (React Flow graph editor) performance, with design patterns and refactoring for scalability. Implement in order of impact vs effort; measure before/after where possible.
|
||||
|
||||
---
|
||||
|
||||
## 1. Stabilize `nodes` passed to React Flow (high impact, low effort)
|
||||
|
||||
**Problem:** In `CanvasPage.tsx`, nodes are passed as:
|
||||
|
||||
```ts
|
||||
nodes={nodes.map((n) => ({ ...n, className: [n.className, 'nowheel'].filter(Boolean).join(' ') }))}
|
||||
```
|
||||
|
||||
Every render creates a **new array and new object references** for every node. React Flow may re-render or reconcile more than needed.
|
||||
|
||||
**Options:**
|
||||
|
||||
- **A. Compute derived props in a `useMemo`**
|
||||
Memoize the mapped nodes; depend only on `nodes` and a stable `className` suffix so the reference changes only when `nodes` actually changes.
|
||||
|
||||
- **B. Avoid mutating node shape in the parent**
|
||||
Apply the `nowheel` class via `nodeTypes` default or a wrapper so you can pass `nodes={nodes}` directly. Keeps a single source of truth and avoids creating new node objects every render.
|
||||
|
||||
**Pattern:** **Single source of truth** — avoid deriving a new structure on every render when the same structure can be expressed at the data or type level.
|
||||
|
||||
---
|
||||
|
||||
## 2. Split FlowContext to reduce consumer re-renders (high impact, medium effort)
|
||||
|
||||
**Problem:** `FlowContext` holds graph state (`nodes`, `edges`, setters), connection path state (trigger/updating/paused/error sets and callbacks), and UI state (renaming, fullscreen, connectionFrom, etc.). Any change to any part creates a **new context value**, so **every node and every edge** that uses `useContext(FlowContext)` re-renders on any graph or path update.
|
||||
|
||||
**Options:**
|
||||
|
||||
- **A. Split into multiple contexts (recommended)**
|
||||
- **GraphContext:** `nodes`, `edges`, `setNodes`, `setEdges`, `applyGraph`, etc.
|
||||
- **ConnectionPathContext:** path node IDs, trigger/updating/paused/error state and callbacks.
|
||||
- **FlowUIContext:** `renamingNodeId`, `fullscreenNodeId`, `connectionFrom`, `flowActionsRef`, `isValidConnection`.
|
||||
|
||||
Nodes that only need graph + UI (e.g. for `updateData`, `setFullscreenNodeId`) don’t re-render when only connection path state changes. Edges that only need connection path state don’t re-render when only `nodes`/`edges` change (e.g. drag position).
|
||||
|
||||
- **B. Context + selectors**
|
||||
Keep a single store (e.g. Zustand) and have nodes/edges subscribe with selectors (e.g. `useStore(selector)`). Only re-render when the selected slice changes. This is a larger refactor but scales well.
|
||||
|
||||
**Pattern:** **Segregation of concerns** — separate contexts (or stores) by update frequency and by consumer type so that high-frequency updates (e.g. connection path ticks) don’t force re-renders of all nodes/edges.
|
||||
|
||||
---
|
||||
|
||||
## 3. Memoize edge component and narrow its context usage (high impact, low effort)
|
||||
|
||||
**Problem:** `AnimatedEdge` uses `useContext(FlowContext)` and reads `nodes`, `connectionPathNodeIds`, `connectionPathPausedSegmentNodeIds`, `connectionPathActiveSegmentNodeIds`, `connectionPathErrorNodeIds`. So every edge re-renders whenever the whole context value changes (e.g. any node move or path update).
|
||||
|
||||
**Options:**
|
||||
|
||||
- **A. Wrap `AnimatedEdge` in `React.memo`**
|
||||
Use a custom comparison that returns true when `id`, `source`, `target`, positions, and style haven’t changed. React Flow already passes stable edge props; memo avoids re-renders when parent re-renders with the same props.
|
||||
|
||||
- **B. Feed only connection path data into edges**
|
||||
After splitting context (see §2), edges consume only **ConnectionPathContext**. Then they re-render only when path/trigger/updating/paused/error state changes, not on every `nodes`/`edges` change.
|
||||
|
||||
- **C. Pass path state via React Flow edge `data`**
|
||||
Compute per-edge “connection status” (or relevant path IDs) in the canvas and pass it as `edge.data`. Edges become pure in terms of context: they only need `data` and standard edge props. Pushing the computation to the parent is one place to update when path state changes.
|
||||
|
||||
**Pattern:** **Minimize subscriber set** — each component should subscribe only to the minimal state it needs (context split or selectors), and be memoized so parent re-renders don’t force unnecessary work.
|
||||
|
||||
---
|
||||
|
||||
## 4. Reduce history and save cost (medium impact, medium effort)
|
||||
|
||||
**Problem:** In `useGraphStateWithHistory`, every `setNodes` / `setEdges` call runs `cloneState()`, which deep-clones all nodes and edges. With many nodes or large `node.data`, this is expensive. In `useCanvasGraph`, the save effect runs on every `nodes`/`edges` change (with a 500 ms debounce), so frequent updates (e.g. drag) still schedule many saves.
|
||||
|
||||
**Options:**
|
||||
|
||||
- **A. Structural sharing / copy-on-write**
|
||||
Keep history as immutable updates (e.g. only store changed nodes/edges or patches) instead of full clones. On undo/redo, apply patches or merge with previous state. This reduces both memory and CPU for large graphs.
|
||||
|
||||
- **B. Throttle or idle-based save**
|
||||
Besides debouncing, only call `saveGraphToStorage` when the graph hasn’t changed for N seconds or when `requestIdleCallback` fires. Reduces work during continuous interaction.
|
||||
|
||||
- **C. Limit history size and clone only when necessary**
|
||||
You already cap history (e.g. `MAX_HISTORY`). Ensure `cloneState` is only called when pushing to history, not on silent updates (e.g. drag). You already use `setNodesSilent` for drag; double-check that no accidental pushes happen during drag.
|
||||
|
||||
**Pattern:** **Immutability with minimal copying** — use structural sharing or patch-based history so that only changed parts of the graph are copied and stored.
|
||||
|
||||
---
|
||||
|
||||
## 5. Contextual zoom and viewport (medium impact, low–medium effort)
|
||||
|
||||
**Problem:** `ContextualZoomNode` uses `useViewport()` from React Flow. Viewport (zoom/pan) updates can trigger re-renders of **every** contextual node when zoom crosses the compact threshold.
|
||||
|
||||
**Options:**
|
||||
|
||||
- **A. Subscribe only near threshold**
|
||||
If the library allows, subscribe to viewport only when zoom is near `CONTEXTUAL_ZOOM_THRESHOLD` so small zoom changes don’t re-render all nodes. If not, consider throttling viewport updates before passing to context.
|
||||
|
||||
- **B. Use a single “display mode” in context**
|
||||
One component (e.g. canvas container) subscribes to viewport and sets a value like `displayMode: 'compact' | 'full'` in context. Nodes only consume that enum; they don’t subscribe to raw zoom. Fewer subscribers and simpler logic.
|
||||
|
||||
- **C. Keep current behavior but ensure nodes are memoized**
|
||||
With `createContextualNode`, the inner node is already wrapped (e.g. via `createAbstractNodeComponent`). Ensure the outer wrapper doesn’t break memo (e.g. avoid passing new object/function refs from viewport into the inner node).
|
||||
|
||||
**Pattern:** **Facade / single subscriber** — one place turns “viewport” into a coarse decision (e.g. compact vs full); the rest of the tree depends only on that decision.
|
||||
|
||||
---
|
||||
|
||||
## 6. React Flow configuration (medium impact, low effort)
|
||||
|
||||
**Problem:** Defaults may render or update more than needed for large graphs.
|
||||
|
||||
**Options:**
|
||||
|
||||
- **A. Enable `onlyRenderVisibleElements`**
|
||||
When supported and stable in your version, enable it so nodes (and optionally edges) outside the viewport are not rendered. Best combined with fixed node dimensions and explicit handle positions so edges still draw correctly when nodes are off-screen.
|
||||
|
||||
- **B. Set `nodeOrigin`**
|
||||
If you use a consistent origin (e.g. top-left) for all nodes, set `nodeOrigin` so React Flow doesn’t have to infer it; can help with layout and hit-testing.
|
||||
|
||||
- **C. Reduce MiniMap/Background work**
|
||||
If the minimap or background is expensive, consider making them optional (e.g. only when node count > N) or simplifying their rendering (e.g. simpler background pattern, minimap with lower refresh rate).
|
||||
|
||||
**Pattern:** **Use platform features** — lean on React Flow’s built-in options (visibility culling, nodeOrigin) before adding custom virtualization.
|
||||
|
||||
---
|
||||
|
||||
## 7. Heavy node components (e.g. RenderingNode) (medium impact, medium effort)
|
||||
|
||||
**Problem:** Nodes that run the resolve → render pipeline (e.g. `useRenderingNodeState`) do a lot of work: signatures, config resolution, rendering, connection path lifecycle. They already use `useAbstractNode` and memo via `createAbstractNodeComponent`; the main cost is internal (hooks, effects, possibly re-renders when context changes).
|
||||
|
||||
**Options:**
|
||||
|
||||
- **A. Ensure they only get connection path updates when relevant**
|
||||
After splitting context (§2), these nodes should only subscribe to graph + UI (and maybe a minimal connection path slice if they need to report “updating”/“paused”/“error”). They shouldn’t re-render on every path tick.
|
||||
|
||||
- **B. Lazy or deferred computation**
|
||||
For heavy useMemos (e.g. `buildSourceSignatures`), consider `useDeferredValue` or moving work off the critical path (e.g. in a worker or requestIdleCallback) if the UI can show a stale state briefly.
|
||||
|
||||
- **C. Virtualize or hide when not visible**
|
||||
Combined with `onlyRenderVisibleElements` or a custom “nodes in viewport” list, heavy nodes that are off-screen don’t mount at all, so their hooks and effects don’t run.
|
||||
|
||||
**Pattern:** **Defer and cull** — avoid running expensive logic for nodes that are not visible or not relevant to the current interaction.
|
||||
|
||||
---
|
||||
|
||||
## 8. Node types and handlers (lower impact, low effort)
|
||||
|
||||
**Problem:** `nodeTypes` and `edgeTypes` are already built with `useMemo` in `CanvasPage`. Handlers are mostly `useCallback`. Remaining issues are usually unstable references or unnecessary dependencies.
|
||||
|
||||
**Options:**
|
||||
|
||||
- **A. Keep `nodeTypes` / `edgeTypes` stable**
|
||||
Ensure `getRegisteredNodeTypes()` isn’t returning new array/object references every time; if it is, memoize at the registry level or in the hook that builds `nodeTypes`.
|
||||
|
||||
- **B. Ensure `isValidConnection` doesn’t close over changing `nodes`**
|
||||
You already use `nodes` in `isValidConnection`; that’s correct for validation. If this callback is passed in context and triggers many re-renders, consider moving it to a ref (e.g. `isValidConnectionRef.current`) so the context value doesn’t change when `nodes` changes, and the validator always reads the latest nodes from the ref. Then nodes/edges that only need `isValidConnection` don’t re-render on every graph change.
|
||||
|
||||
- **C. Memoize `defaultEdgeOptions` and `snapGrid`**
|
||||
You already have `defaultEdgeOptions = useMemo(() => ({ type: 'animated' }), [])` and `SNAP_GRID` constant. Keep that pattern for any other object/array props passed to `<ReactFlow>`.
|
||||
|
||||
**Pattern:** **Stable references** — any prop or context value that is an object or function should be memoized or stored in a ref so that consumers don’t re-render unnecessarily.
|
||||
|
||||
---
|
||||
|
||||
## 9. Connection path hook (lower impact, already partially optimized)
|
||||
|
||||
**Problem:** `useCanvasConnectionPath` derives `connectionPathNodeIds`, `connectionPathPausedSegmentNodeIds`, and `connectionPathActiveSegmentNodeIds` with `useMemo`; batching with `requestAnimationFrame` is already used for trigger updates. The main cost is that when this state changes, every edge (and possibly nodes using path role) re-renders if they all consume the same context.
|
||||
|
||||
**Options:**
|
||||
|
||||
- **A. After splitting context (§2), only edges (and path-aware nodes) subscribe to connection path context**
|
||||
Then path updates no longer force re-renders of nodes that don’t care about path.
|
||||
|
||||
- **B. Keep derived sets in refs for equality checks**
|
||||
If the same set of IDs is produced repeatedly, avoid updating state (or context) when the set content is equal (e.g. compare `Array.from(set).sort().join(',')` or use a stable serialization) so that consumers don’t see a “new” reference and re-render.
|
||||
|
||||
**Pattern:** **Stable outputs** — when feeding context or state, avoid new object/set/array references when the logical value hasn’t changed.
|
||||
|
||||
---
|
||||
|
||||
## Suggested order of implementation
|
||||
|
||||
1. **Quick wins:** §1 (stabilize `nodes`), §3A (memoize `AnimatedEdge`), §6 (React Flow options), §8 (stable refs/callbacks).
|
||||
2. **High leverage:** §2 (split FlowContext), then §3B/3C (edges consume only path state or `data`).
|
||||
3. **Scalability:** §4 (history/save), §5 (viewport/contextual zoom), §7 (heavy nodes and visibility).
|
||||
|
||||
This order keeps design patterns (context split, minimal subscription, stable references) consistent and sets you up for further scalability (e.g. more nodes, more edges, more complex node content) without large rewrites.
|
||||
@@ -23,7 +23,11 @@ import {
|
||||
type EdgeChange,
|
||||
} from '@xyflow/react'
|
||||
import { AnimatedEdge } from '@/components/graph/AnimatedEdge'
|
||||
import FlowContext from '@/lib/graph/flowContext'
|
||||
import {
|
||||
GraphContext,
|
||||
ConnectionPathContext,
|
||||
FlowUIContext,
|
||||
} from '@/lib/graph/flowContext'
|
||||
import { useTheme } from '@/lib/themeContext'
|
||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||
import { useCanvasGraph } from '@/app/canvas/useCanvasGraph'
|
||||
@@ -33,6 +37,7 @@ import { CanvasContextMenuContent } from '@/app/canvas/CanvasContextMenuContent'
|
||||
import { useCanvasConnectionPath } from '@/app/canvas/useCanvasConnectionPath'
|
||||
import { CanvasMenubar } from '@/app/canvas/CanvasMenubar'
|
||||
import { createContextualNode } from '@/app/canvas/ContextualZoomNode'
|
||||
import { ViewportDisplayProvider } from '@/app/canvas/ViewportDisplayContext'
|
||||
import { FlowKeyboardShortcuts } from '@/components/graph/FlowKeyboardShortcuts'
|
||||
import {
|
||||
Empty,
|
||||
@@ -55,6 +60,7 @@ import {
|
||||
getRegisteredNodeTypeIds,
|
||||
getDefaultStyle,
|
||||
getNodeType,
|
||||
getConnectionLabelForTarget,
|
||||
isConnectionAllowed,
|
||||
} from '@/lib/graph/nodeRegistry'
|
||||
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||
@@ -242,24 +248,22 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
[setEdges]
|
||||
)
|
||||
|
||||
const isValidConnection = useCallback(
|
||||
(connection: Connection | AppEdge) => {
|
||||
const src = 'source' in connection ? connection.source : undefined
|
||||
const tgt = 'target' in connection ? connection.target : undefined
|
||||
if (typeof src !== 'string' || typeof tgt !== 'string') return false
|
||||
const sourceNode = nodes.find((n) => n.id === src)
|
||||
const targetNode = nodes.find((n) => n.id === tgt)
|
||||
const sourceType = sourceNode?.type
|
||||
const targetType = targetNode?.type
|
||||
if (!sourceType || !targetType) return false
|
||||
if (sourceType === 'render' && targetType === 'config') {
|
||||
const targetData = targetNode?.data as { configType?: string } | undefined
|
||||
if (targetData?.configType !== 'markdown') return false
|
||||
}
|
||||
return isConnectionAllowed(sourceType, targetType, src, tgt)
|
||||
},
|
||||
[nodes]
|
||||
)
|
||||
const isValidConnection = useCallback((connection: Connection | AppEdge) => {
|
||||
const src = 'source' in connection ? connection.source : undefined
|
||||
const tgt = 'target' in connection ? connection.target : undefined
|
||||
if (typeof src !== 'string' || typeof tgt !== 'string') return false
|
||||
const currentNodes = nodesRef.current
|
||||
const sourceNode = currentNodes.find((n) => n.id === src)
|
||||
const targetNode = currentNodes.find((n) => n.id === tgt)
|
||||
const sourceType = sourceNode?.type
|
||||
const targetType = targetNode?.type
|
||||
if (!sourceType || !targetType) return false
|
||||
if (sourceType === 'render' && targetType === 'config') {
|
||||
const targetData = targetNode?.data as { configType?: string } | undefined
|
||||
if (targetData?.configType !== 'markdown') return false
|
||||
}
|
||||
return isConnectionAllowed(sourceType, targetType, src, tgt)
|
||||
}, [])
|
||||
|
||||
const onConnectStart = useCallback(
|
||||
(
|
||||
@@ -388,20 +392,12 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
flowActionsRef.current?.pasteAtViewportCenter?.()
|
||||
}, [])
|
||||
|
||||
const flowContextValue = useMemo(
|
||||
const graphContextValue = useMemo(
|
||||
() => ({ nodes, setNodes, edges, setEdges }),
|
||||
[nodes, setNodes, edges, setEdges]
|
||||
)
|
||||
const connectionPathContextValue = useMemo(
|
||||
() => ({
|
||||
nodes,
|
||||
setNodes,
|
||||
edges,
|
||||
setEdges,
|
||||
renamingNodeId,
|
||||
setRenamingNodeId,
|
||||
connectionFrom,
|
||||
setConnectionFrom,
|
||||
isValidConnection,
|
||||
flowActionsRef,
|
||||
fullscreenNodeId,
|
||||
setFullscreenNodeId,
|
||||
connectionPathUpdatingNodeIds: connectionPath.connectionPathUpdatingNodeIds,
|
||||
connectionPathTriggerNodeIds: connectionPath.connectionPathTriggerNodeIds,
|
||||
addConnectionPathTrigger: connectionPath.addConnectionPathTrigger,
|
||||
@@ -417,11 +413,20 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
startConnectionPathUpdate: connectionPath.startConnectionPathUpdate,
|
||||
endConnectionPathUpdate: connectionPath.endConnectionPathUpdate,
|
||||
}),
|
||||
[
|
||||
nodes,
|
||||
setNodes,
|
||||
edges,
|
||||
setEdges,
|
||||
[connectionPath]
|
||||
)
|
||||
const flowUIContextValue = useMemo(
|
||||
() => ({
|
||||
renamingNodeId,
|
||||
setRenamingNodeId,
|
||||
connectionFrom,
|
||||
setConnectionFrom,
|
||||
isValidConnection,
|
||||
flowActionsRef,
|
||||
fullscreenNodeId,
|
||||
setFullscreenNodeId,
|
||||
}),
|
||||
[
|
||||
renamingNodeId,
|
||||
setRenamingNodeId,
|
||||
connectionFrom,
|
||||
@@ -430,10 +435,32 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
flowActionsRef,
|
||||
fullscreenNodeId,
|
||||
setFullscreenNodeId,
|
||||
connectionPath,
|
||||
]
|
||||
)
|
||||
|
||||
const nodesForFlow = useMemo(
|
||||
() =>
|
||||
nodes.map((n) => ({
|
||||
...n,
|
||||
className: [n.className, 'nowheel'].filter(Boolean).join(' '),
|
||||
})),
|
||||
[nodes]
|
||||
)
|
||||
const edgesForFlow = useMemo(
|
||||
() =>
|
||||
edges.map((e) => {
|
||||
const targetType = nodes.find((nd) => nd.id === e.target)?.type ?? ''
|
||||
const connectionLabel = getConnectionLabelForTarget(targetType)
|
||||
const baseData =
|
||||
typeof e.data === 'object' && e.data !== null ? (e.data as Record<string, unknown>) : {}
|
||||
return {
|
||||
...e,
|
||||
data: { ...baseData, connectionLabel },
|
||||
}
|
||||
}),
|
||||
[edges, nodes]
|
||||
)
|
||||
|
||||
const onContextMenuCapture = useCallback((ev: React.MouseEvent) => {
|
||||
const target = ev.target as HTMLElement
|
||||
if (target.closest('.react-flow__node')) {
|
||||
@@ -586,7 +613,9 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
)}
|
||||
<div className="flex-1 min-h-0 relative flex flex-col">
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<FlowContext.Provider value={flowContextValue}>
|
||||
<GraphContext.Provider value={graphContextValue}>
|
||||
<ConnectionPathContext.Provider value={connectionPathContextValue}>
|
||||
<FlowUIContext.Provider value={flowUIContextValue}>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
@@ -625,15 +654,13 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
</div>
|
||||
)}
|
||||
<ReactFlowProvider initialNodes={nodes} initialEdges={edges} fitView>
|
||||
<FlowFitViewOnLoad />
|
||||
<FlowKeyboardShortcuts />
|
||||
<ReactFlow
|
||||
<ViewportDisplayProvider>
|
||||
<FlowFitViewOnLoad />
|
||||
<FlowKeyboardShortcuts />
|
||||
<ReactFlow
|
||||
className={isPanning ? 'react-flow--panning' : isSelecting ? 'react-flow--selecting' : undefined}
|
||||
nodes={nodes.map((n) => ({
|
||||
...n,
|
||||
className: [n.className, 'nowheel'].filter(Boolean).join(' '),
|
||||
}))}
|
||||
edges={edges}
|
||||
nodes={nodesForFlow}
|
||||
edges={edgesForFlow}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
@@ -657,6 +684,8 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
fitView
|
||||
onInit={onInit}
|
||||
nodeDragThreshold={1}
|
||||
onlyRenderVisibleElements
|
||||
nodeOrigin={[0, 0]}
|
||||
nodesDraggable
|
||||
nodesConnectable
|
||||
elementsSelectable
|
||||
@@ -671,12 +700,13 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
<div role="group" aria-label="Canvas controls: zoom and fit view">
|
||||
<Controls />
|
||||
</div>
|
||||
{showMinimap && (
|
||||
{showMinimap && nodes.length > 5 && (
|
||||
<div role="region" aria-label="Minimap: overview of the graph">
|
||||
<MiniMap />
|
||||
</div>
|
||||
)}
|
||||
</ReactFlow>
|
||||
</ViewportDisplayProvider>
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
@@ -689,7 +719,9 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
onClose={() => setFullscreenNodeId(null)}
|
||||
/>
|
||||
)}
|
||||
</FlowContext.Provider>
|
||||
</FlowUIContext.Provider>
|
||||
</ConnectionPathContext.Provider>
|
||||
</GraphContext.Provider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
* @see https://reactflow.dev/examples/interaction/contextual-zoom
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { useViewport } from '@xyflow/react'
|
||||
import React, { useContext } from 'react'
|
||||
import { getNodeType, getDefaultStyle } from '@/lib/graph/nodeRegistry'
|
||||
import { ViewportDisplayContext } from '@/app/canvas/ViewportDisplayContext'
|
||||
import { InputHandle, OutputHandle } from '@/components/graph/NodeHandles'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
@@ -86,8 +86,8 @@ export function createContextualNode<P extends NodeProps>(
|
||||
Inner: React.ComponentType<P>
|
||||
): React.ComponentType<P> {
|
||||
function ContextualZoomNode(props: P) {
|
||||
const { zoom } = useViewport()
|
||||
const showCompact = zoom <= CONTEXTUAL_ZOOM_THRESHOLD
|
||||
const displayMode = useContext(ViewportDisplayContext)
|
||||
const showCompact = displayMode === 'compact'
|
||||
const p = props as NodeProps
|
||||
const type = p.type
|
||||
const defaultStyle = type ? getDefaultStyle(type) : { width: 320, height: 320 }
|
||||
|
||||
46
frontend/src/app/canvas/ViewportDisplayContext.tsx
Normal file
46
frontend/src/app/canvas/ViewportDisplayContext.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Single subscriber for viewport zoom: one component (ViewportDisplayProvider)
|
||||
* subscribes to useViewport() and provides displayMode so contextual nodes
|
||||
* don't each subscribe to viewport and re-render on every pan/zoom.
|
||||
*/
|
||||
|
||||
import React, { useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useViewport } from '@xyflow/react'
|
||||
import { CONTEXTUAL_ZOOM_THRESHOLD } from '@/app/canvas/ContextualZoomNode'
|
||||
|
||||
export type ViewportDisplayMode = 'compact' | 'full'
|
||||
|
||||
const HYSTERESIS = 0.02
|
||||
|
||||
const ViewportDisplayContext = React.createContext<ViewportDisplayMode>('full')
|
||||
export { ViewportDisplayContext }
|
||||
|
||||
/**
|
||||
* Must be rendered inside ReactFlowProvider. Subscribes to viewport once,
|
||||
* maps zoom to displayMode with hysteresis, and provides it to descendants.
|
||||
*/
|
||||
export function ViewportDisplayProvider({ children }: { children: React.ReactNode }) {
|
||||
const { zoom } = useViewport()
|
||||
const [displayMode, setDisplayMode] = useState<ViewportDisplayMode>(() =>
|
||||
zoom <= CONTEXTUAL_ZOOM_THRESHOLD ? 'compact' : 'full'
|
||||
)
|
||||
const lastRef = useRef(displayMode)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const low = CONTEXTUAL_ZOOM_THRESHOLD - HYSTERESIS
|
||||
const high = CONTEXTUAL_ZOOM_THRESHOLD + HYSTERESIS
|
||||
let next: ViewportDisplayMode = lastRef.current
|
||||
if (zoom <= low) next = 'compact'
|
||||
else if (zoom >= high) next = 'full'
|
||||
if (next !== lastRef.current) {
|
||||
lastRef.current = next
|
||||
setDisplayMode(next)
|
||||
}
|
||||
}, [zoom])
|
||||
|
||||
return (
|
||||
<ViewportDisplayContext.Provider value={displayMode}>
|
||||
{children}
|
||||
</ViewportDisplayContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,11 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { getPathNodeIds, getPausedSegmentNodeIds } from '@/lib/graph/graphPath'
|
||||
|
||||
/** Serialize set to a stable string for equality. */
|
||||
function setToStableKey(s: Set<string>): string {
|
||||
return Array.from(s).sort().join(',')
|
||||
}
|
||||
|
||||
export type EdgeLike = { source: string; target: string }
|
||||
|
||||
/** Minimum time (ms) the connection ant trail runs when a path update is in progress. */
|
||||
@@ -111,7 +116,7 @@ export function useCanvasConnectionPath(edges: EdgeLike[]): UseCanvasConnectionP
|
||||
[]
|
||||
)
|
||||
|
||||
const connectionPathNodeIds = useMemo(
|
||||
const connectionPathNodeIdsRaw = useMemo(
|
||||
() =>
|
||||
getPathNodeIds(
|
||||
edges,
|
||||
@@ -121,8 +126,18 @@ export function useCanvasConnectionPath(edges: EdgeLike[]): UseCanvasConnectionP
|
||||
),
|
||||
[edges, connectionPathUpdatingNodeIds, connectionPathTriggerNodeIds, connectionPathPausedNodeIds]
|
||||
)
|
||||
const connectionPathNodeIdsRef = useRef<Set<string>>(connectionPathNodeIdsRaw)
|
||||
const connectionPathNodeIdsKeyRef = useRef<string>('')
|
||||
const connectionPathNodeIds =
|
||||
setToStableKey(connectionPathNodeIdsRaw) === connectionPathNodeIdsKeyRef.current
|
||||
? connectionPathNodeIdsRef.current
|
||||
: (() => {
|
||||
connectionPathNodeIdsKeyRef.current = setToStableKey(connectionPathNodeIdsRaw)
|
||||
connectionPathNodeIdsRef.current = connectionPathNodeIdsRaw
|
||||
return connectionPathNodeIdsRaw
|
||||
})()
|
||||
|
||||
const connectionPathPausedSegmentNodeIds = useMemo(
|
||||
const connectionPathPausedSegmentNodeIdsRaw = useMemo(
|
||||
() =>
|
||||
getPausedSegmentNodeIds(
|
||||
edges,
|
||||
@@ -132,12 +147,42 @@ export function useCanvasConnectionPath(edges: EdgeLike[]): UseCanvasConnectionP
|
||||
),
|
||||
[edges, connectionPathNodeIds, connectionPathTriggerNodeIds, connectionPathPausedNodeIds]
|
||||
)
|
||||
const connectionPathPausedSegmentNodeIdsRef = useRef<Set<string>>(
|
||||
connectionPathPausedSegmentNodeIdsRaw
|
||||
)
|
||||
const connectionPathPausedSegmentNodeIdsKeyRef = useRef<string>('')
|
||||
const connectionPathPausedSegmentNodeIds =
|
||||
setToStableKey(connectionPathPausedSegmentNodeIdsRaw) ===
|
||||
connectionPathPausedSegmentNodeIdsKeyRef.current
|
||||
? connectionPathPausedSegmentNodeIdsRef.current
|
||||
: (() => {
|
||||
connectionPathPausedSegmentNodeIdsKeyRef.current = setToStableKey(
|
||||
connectionPathPausedSegmentNodeIdsRaw
|
||||
)
|
||||
connectionPathPausedSegmentNodeIdsRef.current = connectionPathPausedSegmentNodeIdsRaw
|
||||
return connectionPathPausedSegmentNodeIdsRaw
|
||||
})()
|
||||
|
||||
const connectionPathActiveSegmentNodeIds = useMemo(() => {
|
||||
const connectionPathActiveSegmentNodeIdsRaw = useMemo(() => {
|
||||
const active = new Set(connectionPathNodeIds)
|
||||
connectionPathPausedSegmentNodeIds.forEach((id) => active.delete(id))
|
||||
return active
|
||||
}, [connectionPathNodeIds, connectionPathPausedSegmentNodeIds])
|
||||
const connectionPathActiveSegmentNodeIdsRef = useRef<Set<string>>(
|
||||
connectionPathActiveSegmentNodeIdsRaw
|
||||
)
|
||||
const connectionPathActiveSegmentNodeIdsKeyRef = useRef<string>('')
|
||||
const connectionPathActiveSegmentNodeIds =
|
||||
setToStableKey(connectionPathActiveSegmentNodeIdsRaw) ===
|
||||
connectionPathActiveSegmentNodeIdsKeyRef.current
|
||||
? connectionPathActiveSegmentNodeIdsRef.current
|
||||
: (() => {
|
||||
connectionPathActiveSegmentNodeIdsKeyRef.current = setToStableKey(
|
||||
connectionPathActiveSegmentNodeIdsRaw
|
||||
)
|
||||
connectionPathActiveSegmentNodeIdsRef.current = connectionPathActiveSegmentNodeIdsRaw
|
||||
return connectionPathActiveSegmentNodeIdsRaw
|
||||
})()
|
||||
|
||||
const addConnectionPathPausedNode = useCallback((nodeId: string) => {
|
||||
setConnectionPathPausedNodeIds((prev) => (prev.includes(nodeId) ? prev : [...prev, nodeId]))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Hook for canvas graph state and persistence. Wraps useGraphStateWithHistory
|
||||
* with initial graph from project storage (or example) and debounced save.
|
||||
* with initial graph from project storage (or example) and debounced + idle-based save.
|
||||
* Keeps CanvasPage focused on composition and layout.
|
||||
*/
|
||||
|
||||
@@ -12,20 +12,65 @@ import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||
|
||||
export type UseCanvasGraphResult = ReturnType<typeof useGraphStateWithHistory>
|
||||
|
||||
/** Debounce delay (ms) before we schedule a save. */
|
||||
const SAVE_DEBOUNCE_MS = 800
|
||||
/** Max wait (ms) for requestIdleCallback before falling back to setTimeout. */
|
||||
const SAVE_IDLE_TIMEOUT_MS = 2000
|
||||
|
||||
export function useCanvasGraph(projectId: string | undefined): UseCanvasGraphResult {
|
||||
const initialGraph = useMemo(() => getInitialGraph(projectId), [projectId])
|
||||
const result = useGraphStateWithHistory(initialGraph.nodes, initialGraph.edges)
|
||||
const { nodes, edges } = result
|
||||
|
||||
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const idleCallbackRef = useRef<number | null>(null)
|
||||
const pendingSaveRef = useRef<{ projectId: string; nodes: AppNode[]; edges: AppEdge[] } | null>(
|
||||
null
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) return
|
||||
const save = () => {
|
||||
saveGraphToStorage(projectId, { version: PROJECT_VERSION, nodes, edges })
|
||||
|
||||
const scheduleSave = () => {
|
||||
pendingSaveRef.current = { projectId, nodes, edges }
|
||||
|
||||
const doSave = () => {
|
||||
const pending = pendingSaveRef.current
|
||||
pendingSaveRef.current = null
|
||||
if (pending && pending.projectId === projectId) {
|
||||
saveGraphToStorage(pending.projectId, {
|
||||
version: PROJECT_VERSION,
|
||||
nodes: pending.nodes,
|
||||
edges: pending.edges,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof requestIdleCallback !== 'undefined') {
|
||||
idleCallbackRef.current = requestIdleCallback(doSave, {
|
||||
timeout: SAVE_IDLE_TIMEOUT_MS,
|
||||
})
|
||||
} else {
|
||||
idleCallbackRef.current = window.setTimeout(doSave, 0) as unknown as number
|
||||
}
|
||||
}
|
||||
saveTimeoutRef.current = setTimeout(save, 500)
|
||||
|
||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
|
||||
saveTimeoutRef.current = setTimeout(scheduleSave, SAVE_DEBOUNCE_MS)
|
||||
|
||||
return () => {
|
||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current)
|
||||
saveTimeoutRef.current = null
|
||||
}
|
||||
if (idleCallbackRef.current != null) {
|
||||
if (typeof cancelIdleCallback !== 'undefined') {
|
||||
cancelIdleCallback(idleCallbackRef.current)
|
||||
} else {
|
||||
clearTimeout(idleCallbackRef.current)
|
||||
}
|
||||
idleCallbackRef.current = null
|
||||
}
|
||||
}
|
||||
}, [projectId, nodes, edges])
|
||||
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import React, { useContext, useMemo } from 'react'
|
||||
import React, { useContext, useMemo, memo } from 'react'
|
||||
import {
|
||||
BaseEdge,
|
||||
getBezierPath,
|
||||
type EdgeProps,
|
||||
} from '@xyflow/react'
|
||||
import FlowContext from '@/lib/graph/flowContext'
|
||||
import { ConnectionPathContext } from '@/lib/graph/flowContext'
|
||||
import { getConnectionStatus, CONNECTION_STATUS_CLASS } from '@/lib/graph/connectionStatus'
|
||||
import { getConnectionLabelForTarget } from '@/lib/graph/nodeRegistry'
|
||||
|
||||
const EDGE_STROKE_WIDTH = 2
|
||||
const DOT_MARKER_R = 1.5
|
||||
const EMPTY_PATH_NODE_IDS = new Set<string>()
|
||||
|
||||
export function AnimatedEdge({
|
||||
function AnimatedEdgeInner({
|
||||
id,
|
||||
source,
|
||||
sourceX,
|
||||
@@ -25,9 +24,9 @@ export function AnimatedEdge({
|
||||
label: labelProp,
|
||||
interactionWidth,
|
||||
target,
|
||||
data,
|
||||
}: EdgeProps) {
|
||||
const ctx = useContext(FlowContext)
|
||||
const nodes = ctx?.nodes ?? []
|
||||
const ctx = useContext(ConnectionPathContext)
|
||||
const pathNodeIds = ctx?.connectionPathNodeIds ?? EMPTY_PATH_NODE_IDS
|
||||
const pausedSegmentNodeIds = ctx?.connectionPathPausedSegmentNodeIds ?? EMPTY_PATH_NODE_IDS
|
||||
const activeSegmentNodeIds = ctx?.connectionPathActiveSegmentNodeIds ?? EMPTY_PATH_NODE_IDS
|
||||
@@ -35,12 +34,8 @@ export function AnimatedEdge({
|
||||
() => new Set(ctx?.connectionPathErrorNodeIds ?? []),
|
||||
[ctx?.connectionPathErrorNodeIds]
|
||||
)
|
||||
const targetNode = useMemo(() => nodes.find((n: any) => n.id === target), [nodes, target])
|
||||
const derivedLabel = useMemo(
|
||||
() => (targetNode?.type != null ? getConnectionLabelForTarget(targetNode.type) : undefined),
|
||||
[targetNode?.type]
|
||||
)
|
||||
const label = labelProp ?? derivedLabel
|
||||
|
||||
const label = labelProp ?? (data as { connectionLabel?: string } | undefined)?.connectionLabel
|
||||
|
||||
const connectionStatus = useMemo(
|
||||
() =>
|
||||
@@ -146,3 +141,23 @@ export function AnimatedEdge({
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function edgePropsAreEqual(prev: EdgeProps, next: EdgeProps): boolean {
|
||||
return (
|
||||
prev.id === next.id &&
|
||||
prev.source === next.source &&
|
||||
prev.target === next.target &&
|
||||
prev.sourceX === next.sourceX &&
|
||||
prev.sourceY === next.sourceY &&
|
||||
prev.targetX === next.targetX &&
|
||||
prev.targetY === next.targetY &&
|
||||
prev.sourcePosition === next.sourcePosition &&
|
||||
prev.targetPosition === next.targetPosition &&
|
||||
prev.style === next.style &&
|
||||
prev.label === next.label &&
|
||||
(prev.data as { connectionLabel?: string } | undefined)?.connectionLabel ===
|
||||
(next.data as { connectionLabel?: string } | undefined)?.connectionLabel
|
||||
)
|
||||
}
|
||||
|
||||
export const AnimatedEdge = memo(AnimatedEdgeInner, edgePropsAreEqual)
|
||||
|
||||
@@ -2,8 +2,7 @@ import type { ComponentProps, ReactNode } from "react";
|
||||
import { NodeResizer } from "@xyflow/react";
|
||||
import { useContext } from "react";
|
||||
|
||||
import FlowContext from "@/lib/graph/flowContext";
|
||||
import { useConnectionPathRole } from "@/lib/graph/flowContext";
|
||||
import { FlowUIContext, useConnectionPathRole } from "@/lib/graph/flowContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Default min size for resizable nodes (used by NodeResizer). */
|
||||
@@ -37,8 +36,8 @@ export function BaseNode({
|
||||
resizeConstraints,
|
||||
...props
|
||||
}: BaseNodeProps) {
|
||||
const flowContext = useContext(FlowContext);
|
||||
const isFullscreenInstance = Boolean(nodeId && flowContext?.fullscreenNodeId === nodeId);
|
||||
const flowUIContext = useContext(FlowUIContext);
|
||||
const isFullscreenInstance = Boolean(nodeId && flowUIContext?.fullscreenNodeId === nodeId);
|
||||
const connectionPathRole = useConnectionPathRole(nodeId);
|
||||
const hasSize =
|
||||
dimensions &&
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useCallback, useContext, useEffect } from 'react'
|
||||
import { useReactFlow } from '@xyflow/react'
|
||||
import type { Node } from '@xyflow/react'
|
||||
import FlowContext from '@/lib/graph/flowContext'
|
||||
import { GraphContext, FlowUIContext } from '@/lib/graph/flowContext'
|
||||
import { getNextNodeId, getDefaultDataForType } from '@/lib/graph/flowUtils'
|
||||
import { getDefaultStyle, getRegisteredNodeTypeIds } from '@/lib/graph/nodeRegistry'
|
||||
|
||||
@@ -14,11 +14,12 @@ function isMod(ev: KeyboardEvent) {
|
||||
/** Must be rendered inside ReactFlowProvider and FlowContext. Handles Escape, ⌘C, ⌘V, ⌘D, ⌘0. */
|
||||
export function FlowKeyboardShortcuts() {
|
||||
const { fitView, screenToFlowPosition } = useReactFlow()
|
||||
const ctx = useContext(FlowContext)
|
||||
const nodes = ctx?.nodes ?? []
|
||||
const setNodes = ctx?.setNodes
|
||||
const setConnectionFrom = ctx?.setConnectionFrom
|
||||
const flowActionsRef = ctx?.flowActionsRef
|
||||
const graphCtx = useContext(GraphContext)
|
||||
const uiCtx = useContext(FlowUIContext)
|
||||
const nodes = graphCtx?.nodes ?? []
|
||||
const setNodes = graphCtx?.setNodes
|
||||
const setConnectionFrom = uiCtx?.setConnectionFrom
|
||||
const flowActionsRef = uiCtx?.flowActionsRef
|
||||
|
||||
const pasteAtViewportCenter = useCallback(async () => {
|
||||
if (!setNodes || !screenToFlowPosition) return
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useContext, useMemo } from 'react'
|
||||
import FlowContext from '@/lib/graph/flowContext'
|
||||
import { GraphContext } from '@/lib/graph/flowContext'
|
||||
import { ArrowDownLeft, ArrowUpRight } from 'lucide-react'
|
||||
import { NodeHelpPopover } from '@/components/graph/NodeHelpPopover'
|
||||
import { getNodeType, getNodeClassificationLabel } from '@/lib/graph/nodeRegistry'
|
||||
@@ -11,7 +11,7 @@ type Props = {
|
||||
}
|
||||
|
||||
export function NodeFooterEdgeIndicators({ nodeId, nodeType, children }: Props) {
|
||||
const ctx = useContext(FlowContext)
|
||||
const ctx = useContext(GraphContext)
|
||||
const edges = ctx?.edges ?? []
|
||||
|
||||
const { inputs, outputs } = useMemo(() => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useContext } from 'react'
|
||||
import { Handle, Position } from '@xyflow/react'
|
||||
import { ArrowDownLeft, ArrowUpRight } from 'lucide-react'
|
||||
import FlowContext from '@/lib/graph/flowContext'
|
||||
import { FlowUIContext } from '@/lib/graph/flowContext'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type NodeHandleProps = {
|
||||
@@ -11,7 +11,7 @@ type NodeHandleProps = {
|
||||
}
|
||||
|
||||
export function InputHandle({ id, nodeId }: NodeHandleProps) {
|
||||
const ctx = useContext(FlowContext)
|
||||
const ctx = useContext(FlowUIContext)
|
||||
const connectionFrom = ctx?.connectionFrom ?? null
|
||||
const isValidConnection = ctx?.isValidConnection
|
||||
const isConnecting = Boolean(nodeId && connectionFrom && connectionFrom.nodeId !== nodeId)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||
import FlowContext from '@/lib/graph/flowContext'
|
||||
import { GraphContext, FlowUIContext } from '@/lib/graph/flowContext'
|
||||
import type { AppNode } from '@/lib/graph/nodeTypes'
|
||||
import { replaceNodeIdInGraph } from '@/lib/graph/flowUtils'
|
||||
|
||||
@@ -9,13 +9,14 @@ type Props = {
|
||||
}
|
||||
|
||||
export function NodeHeaderTitle({ nodeId, displayTitle }: Props) {
|
||||
const ctx = useContext(FlowContext)
|
||||
const nodes = ctx?.nodes ?? []
|
||||
const setNodes = ctx?.setNodes
|
||||
const edges = ctx?.edges ?? []
|
||||
const setEdges = ctx?.setEdges
|
||||
const renamingNodeId = ctx?.renamingNodeId ?? null
|
||||
const setRenamingNodeId = ctx?.setRenamingNodeId
|
||||
const graphCtx = useContext(GraphContext)
|
||||
const uiCtx = useContext(FlowUIContext)
|
||||
const nodes = graphCtx?.nodes ?? []
|
||||
const setNodes = graphCtx?.setNodes
|
||||
const edges = graphCtx?.edges ?? []
|
||||
const setEdges = graphCtx?.setEdges
|
||||
const renamingNodeId = uiCtx?.renamingNodeId ?? null
|
||||
const setRenamingNodeId = uiCtx?.setRenamingNodeId
|
||||
|
||||
const [inputValue, setInputValue] = useState(nodeId)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* it is taken from getNodeType(nodeType)?.getNodeMenuExtraContent?.(nodeId, data).
|
||||
*/
|
||||
import React, { useCallback, useContext, useMemo } from 'react'
|
||||
import FlowContext from '@/lib/graph/flowContext'
|
||||
import { GraphContext, FlowUIContext } from '@/lib/graph/flowContext'
|
||||
import { getNodeType } from '@/lib/graph/nodeRegistry'
|
||||
import {
|
||||
Menubar,
|
||||
@@ -43,12 +43,13 @@ type Props = {
|
||||
}
|
||||
|
||||
export function NodeMenubar({ nodeId, nodeType, editInputsContent, inputsMenuContent, insertTagsContent, insertMenuLabel = 'Insert', insertContentDirect, insertTagsLabel = 'Tags', nodeMenuExtraContent: nodeMenuExtraContentProp, outputMenuContent, dataMenuContent }: Props) {
|
||||
const ctx = useContext(FlowContext)
|
||||
const nodes = ctx?.nodes ?? []
|
||||
const setNodes = ctx?.setNodes
|
||||
const setEdges = ctx?.setEdges
|
||||
const graphCtx = useContext(GraphContext)
|
||||
const uiCtx = useContext(FlowUIContext)
|
||||
const nodes = graphCtx?.nodes ?? []
|
||||
const setNodes = graphCtx?.setNodes
|
||||
const setEdges = graphCtx?.setEdges
|
||||
|
||||
const edges = ctx?.edges ?? []
|
||||
const edges = graphCtx?.edges ?? []
|
||||
const node = nodes.find((n: any) => n.id === nodeId)
|
||||
const nodeMenuExtraContent = useMemo(
|
||||
() => nodeMenuExtraContentProp ?? getNodeType(nodeType)?.getNodeMenuExtraContent?.(nodeId, node?.data ?? {}),
|
||||
@@ -64,8 +65,8 @@ export function NodeMenubar({ nodeId, nodeType, editInputsContent, inputsMenuCon
|
||||
}, [nodeId, setNodes, setEdges])
|
||||
|
||||
const onRename = useCallback(() => {
|
||||
ctx?.setRenamingNodeId?.(nodeId)
|
||||
}, [nodeId, ctx])
|
||||
uiCtx?.setRenamingNodeId?.(nodeId)
|
||||
}, [nodeId, uiCtx])
|
||||
|
||||
return (
|
||||
<Menubar className="h-auto min-h-0 flex items-center bg-none p-1 border-t-0 border-l-0 border-r-0 border-b border-b-secondary shadow-none rounded-none text-muted-foreground">
|
||||
|
||||
@@ -14,7 +14,7 @@ import { NodeMenubar } from '@/components/graph/NodeMenubar'
|
||||
import { NodeFooterEdgeIndicators } from '@/components/graph/NodeFooterEdgeIndicators'
|
||||
import { NodeHeaderTitle } from '@/components/graph/NodeHeaderTitle'
|
||||
import { InputHandle, OutputHandle } from '@/components/graph/NodeHandles'
|
||||
import FlowContext from '@/lib/graph/flowContext'
|
||||
import { FlowUIContext } from '@/lib/graph/flowContext'
|
||||
import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle'
|
||||
import { getNodeType } from '@/lib/graph/nodeRegistry'
|
||||
import { Bot } from 'lucide-react'
|
||||
@@ -34,8 +34,8 @@ export type AgentNodeData = {
|
||||
type Props = AbstractNodeProps<AgentNodeData>
|
||||
|
||||
function AgentNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
const flowContext = useContext(FlowContext)
|
||||
const setFullscreenNodeId = flowContext?.setFullscreenNodeId
|
||||
const flowUIContext = useContext(FlowUIContext)
|
||||
const setFullscreenNodeId = flowUIContext?.setFullscreenNodeId
|
||||
const supportsFullscreen = getNodeType('agent')?.supportsFullscreen
|
||||
const { nodes, sourceIds, updateData } = useAbstractNode<AgentNodeData>(id, data ?? {})
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ import { InputHandle, OutputHandle } from '@/components/graph/NodeHandles'
|
||||
import { NodeFooterEdgeIndicators } from '@/components/graph/NodeFooterEdgeIndicators'
|
||||
import { NodeHeaderTitle } from '@/components/graph/NodeHeaderTitle'
|
||||
import { NodeMenubar } from '@/components/graph/NodeMenubar'
|
||||
import FlowContext from '@/lib/graph/flowContext'
|
||||
import { FlowUIContext } from '@/lib/graph/flowContext'
|
||||
import { getNodeType } from '@/lib/graph/nodeRegistry'
|
||||
|
||||
export type ConfigNodeData = { configType?: ConfigTypeId; content?: string; title?: string; /** @deprecated use content */ plantuml?: string }
|
||||
@@ -52,8 +52,8 @@ export type ConfigNodeData = { configType?: ConfigTypeId; content?: string; titl
|
||||
type Props = AbstractNodeProps<ConfigNodeData>
|
||||
|
||||
function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
const flowContext = useContext(FlowContext)
|
||||
const setFullscreenNodeId = flowContext?.setFullscreenNodeId
|
||||
const flowUIContext = useContext(FlowUIContext)
|
||||
const setFullscreenNodeId = flowUIContext?.setFullscreenNodeId
|
||||
const supportsFullscreen = getNodeType('config')?.supportsFullscreen
|
||||
const configTypeId = getConfigTypeId(data ?? {})
|
||||
const configType = getConfigType(configTypeId)
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { NodeFooterEdgeIndicators } from '@/components/graph/NodeFooterEdgeIndicators'
|
||||
import { NodeHeaderTitle } from '@/components/graph/NodeHeaderTitle'
|
||||
import { NodeMenubar } from '@/components/graph/NodeMenubar'
|
||||
import FlowContext from '@/lib/graph/flowContext'
|
||||
import { FlowUIContext } from '@/lib/graph/flowContext'
|
||||
import { getNodeType } from '@/lib/graph/nodeRegistry'
|
||||
import { OutputHandle } from '@/components/graph/NodeHandles'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
@@ -52,8 +52,8 @@ export type DataNodeData = {
|
||||
type Props = AbstractNodeProps<DataNodeData>
|
||||
|
||||
function DataNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
const flowContext = useContext(FlowContext)
|
||||
const setFullscreenNodeId = flowContext?.setFullscreenNodeId
|
||||
const flowUIContext = useContext(FlowUIContext)
|
||||
const setFullscreenNodeId = flowUIContext?.setFullscreenNodeId
|
||||
const supportsFullscreen = getNodeType('data')?.supportsFullscreen
|
||||
const { updateData } = useAbstractNode<DataNodeData>(id, data ?? {})
|
||||
const rows = data?.rows ?? []
|
||||
|
||||
@@ -19,7 +19,7 @@ import { InputHandle, OutputHandle } from '@/components/graph/NodeHandles'
|
||||
import { NodeFooterEdgeIndicators } from '@/components/graph/NodeFooterEdgeIndicators'
|
||||
import { NodeHeaderTitle } from '@/components/graph/NodeHeaderTitle'
|
||||
import { NodeMenubar } from '@/components/graph/NodeMenubar'
|
||||
import FlowContext from '@/lib/graph/flowContext'
|
||||
import { FlowUIContext } from '@/lib/graph/flowContext'
|
||||
import { getNodeType } from '@/lib/graph/nodeRegistry'
|
||||
import { MenubarItem, MenubarShortcut } from '@/components/ui/menubar'
|
||||
import { Kbd } from '@/components/ui/kbd'
|
||||
@@ -30,8 +30,8 @@ export type FunctionNodeData = { body?: string }
|
||||
type Props = AbstractNodeProps<FunctionNodeData>
|
||||
|
||||
function FunctionNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
const flowContext = useContext(FlowContext)
|
||||
const setFullscreenNodeId = flowContext?.setFullscreenNodeId
|
||||
const flowUIContext = useContext(FlowUIContext)
|
||||
const setFullscreenNodeId = flowUIContext?.setFullscreenNodeId
|
||||
const supportsFullscreen = getNodeType('function')?.supportsFullscreen
|
||||
const bodyValue = data?.body ?? ''
|
||||
const { theme } = useTheme()
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
BaseNodeFooter,
|
||||
BaseNodeHeaderRow,
|
||||
} from '@/components/graph/BaseNode'
|
||||
import FlowContext from '@/lib/graph/flowContext'
|
||||
import { FlowUIContext } from '@/lib/graph/flowContext'
|
||||
import { getNodeType } from '@/lib/graph/nodeRegistry'
|
||||
import { NodeFooterEdgeIndicators } from '@/components/graph/NodeFooterEdgeIndicators'
|
||||
import { NodeHeaderTitle } from '@/components/graph/NodeHeaderTitle'
|
||||
@@ -48,8 +48,8 @@ type Props = AbstractNodeProps<RenderingNodeData>
|
||||
type ViewMode = 'preview' | 'raw'
|
||||
|
||||
function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
const flowContext = useContext(FlowContext)
|
||||
const setFullscreenNodeId = flowContext?.setFullscreenNodeId
|
||||
const flowUIContext = useContext(FlowUIContext)
|
||||
const setFullscreenNodeId = flowUIContext?.setFullscreenNodeId
|
||||
const supportsFullscreen = getNodeType('render')?.supportsFullscreen
|
||||
|
||||
const state = useRenderingNodeState(id, data)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useAbstractNode } from '@/lib/graph/abstractNode'
|
||||
import FlowContext from '@/lib/graph/flowContext'
|
||||
import { ConnectionPathContext } from '@/lib/graph/flowContext'
|
||||
import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle'
|
||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||
import { getDefaultDataForType, getNextNodeId } from '@/lib/graph/flowUtils'
|
||||
@@ -187,8 +187,8 @@ export function useRenderingNodeState(
|
||||
const lastManualRunTriggerRef = useRef(0)
|
||||
const manualRunTriggerSyncedRef = useRef(false)
|
||||
|
||||
const flowContext = useContext(FlowContext)
|
||||
const triggerNodeIds = flowContext?.connectionPathTriggerNodeIds ?? []
|
||||
const pathCtx = useContext(ConnectionPathContext)
|
||||
const triggerNodeIds = pathCtx?.connectionPathTriggerNodeIds ?? []
|
||||
const hasPendingInputs =
|
||||
effectiveUpdateMode === 'manual' &&
|
||||
!loading &&
|
||||
|
||||
@@ -2,19 +2,158 @@ import { useCallback, useRef, useState } from 'react'
|
||||
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||
|
||||
/**
|
||||
* In-memory graph state. History is a past/future stack of GraphState snapshots.
|
||||
* setNodes/setEdges push current state to past; setNodesSilent/setEdgesSilent update without history.
|
||||
* setStateImmediate replaces state and clears history (e.g. load example, import).
|
||||
* In-memory graph state. History is a past/future stack of inverse deltas (patches).
|
||||
* setNodes/setEdges push an inverse delta to past so undo restores only what changed.
|
||||
* setNodesSilent/setEdgesSilent update without history. setStateImmediate clears history.
|
||||
*/
|
||||
export type GraphState = { nodes: AppNode[]; edges: AppEdge[] }
|
||||
|
||||
/** Clone a single node (shallow clone with one-level data copy). */
|
||||
function cloneNode(n: AppNode): AppNode {
|
||||
return {
|
||||
...n,
|
||||
data:
|
||||
n.data && typeof n.data === 'object'
|
||||
? { ...(n.data as Record<string, unknown>) }
|
||||
: n.data,
|
||||
}
|
||||
}
|
||||
|
||||
/** Clone a single edge. */
|
||||
function cloneEdge(e: AppEdge): AppEdge {
|
||||
return { ...e }
|
||||
}
|
||||
|
||||
/** Full state clone (used for drag snapshot and when delta would be larger than full state). */
|
||||
function cloneState(state: GraphState): GraphState {
|
||||
return {
|
||||
nodes: state.nodes.map((n) => ({ ...n, data: n.data && typeof n.data === 'object' ? { ...n.data } : n.data })),
|
||||
edges: state.edges.map((e) => ({ ...e })),
|
||||
nodes: state.nodes.map(cloneNode),
|
||||
edges: state.edges.map(cloneEdge),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse delta: what to restore on undo (from next back to prev).
|
||||
* We only store nodes/edges that were removed or modified, not the full graph.
|
||||
*/
|
||||
export type HistoryDelta = {
|
||||
addedNodeIds: string[]
|
||||
restoredNodes: AppNode[]
|
||||
addedEdgeIds: string[]
|
||||
restoredEdges: AppEdge[]
|
||||
}
|
||||
|
||||
function nodeEquals(a: AppNode, b: AppNode): boolean {
|
||||
if (a.id !== b.id) return false
|
||||
if (a.type !== b.type) return false
|
||||
if (a.position?.x !== b.position?.x || a.position?.y !== b.position?.y) return false
|
||||
if (a.data !== b.data) return false
|
||||
if (JSON.stringify(a.style) !== JSON.stringify(b.style)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function edgeEquals(a: AppEdge, b: AppEdge): boolean {
|
||||
return a.id === b.id && a.source === b.source && a.target === b.target
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute inverse delta from prev to next: what to store so that
|
||||
* applyInverseDelta(next, delta) restores prev. Restored nodes/edges are cloned.
|
||||
*/
|
||||
function computeInverseDelta(prev: GraphState, next: GraphState): HistoryDelta {
|
||||
const prevNodeIds = new Set(prev.nodes.map((n) => n.id))
|
||||
const nextNodeIds = new Set(next.nodes.map((n) => n.id))
|
||||
const nextNodesById = new Map(next.nodes.map((n) => [n.id, n]))
|
||||
|
||||
const addedNodeIds: string[] = []
|
||||
const restoredNodes: AppNode[] = []
|
||||
|
||||
for (const id of nextNodeIds) {
|
||||
if (!prevNodeIds.has(id)) addedNodeIds.push(id)
|
||||
}
|
||||
for (const pNode of prev.nodes) {
|
||||
const nNode = nextNodesById.get(pNode.id)
|
||||
if (!nNode) {
|
||||
restoredNodes.push(cloneNode(pNode))
|
||||
} else if (!nodeEquals(pNode, nNode)) {
|
||||
restoredNodes.push(cloneNode(pNode))
|
||||
}
|
||||
}
|
||||
|
||||
const prevEdgeIds = new Set(prev.edges.map((e) => e.id))
|
||||
const nextEdgeIds = new Set(next.edges.map((e) => e.id))
|
||||
const nextEdgesById = new Map(next.edges.map((e) => [e.id, e]))
|
||||
|
||||
const addedEdgeIds: string[] = []
|
||||
const restoredEdges: AppEdge[] = []
|
||||
|
||||
for (const id of nextEdgeIds) {
|
||||
if (!prevEdgeIds.has(id)) addedEdgeIds.push(id)
|
||||
}
|
||||
for (const pEdge of prev.edges) {
|
||||
const nEdge = nextEdgesById.get(pEdge.id)
|
||||
if (!nEdge) {
|
||||
restoredEdges.push(cloneEdge(pEdge))
|
||||
} else if (!edgeEquals(pEdge, nEdge)) {
|
||||
restoredEdges.push(cloneEdge(pEdge))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
addedNodeIds,
|
||||
restoredNodes,
|
||||
addedEdgeIds,
|
||||
restoredEdges,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an inverse delta to current state to get the previous state (undo).
|
||||
*/
|
||||
function applyInverseDelta(current: GraphState, delta: HistoryDelta): GraphState {
|
||||
const addedNodeIdsSet = new Set(delta.addedNodeIds)
|
||||
const restoredNodesById = new Map(delta.restoredNodes.map((n) => [n.id, n]))
|
||||
|
||||
const baseNodes = current.nodes.filter((n) => !addedNodeIdsSet.has(n.id))
|
||||
const nodeOrder = baseNodes.map((n) => n.id)
|
||||
const byId = new Map(baseNodes.map((n) => [n.id, n]))
|
||||
for (const r of delta.restoredNodes) {
|
||||
byId.set(r.id, r)
|
||||
}
|
||||
const nodes: AppNode[] = nodeOrder
|
||||
.map((id) => byId.get(id))
|
||||
.filter((n): n is AppNode => n != null)
|
||||
const restoredIdsInBase = new Set(nodes.map((n) => n.id))
|
||||
for (const r of delta.restoredNodes) {
|
||||
if (!restoredIdsInBase.has(r.id)) {
|
||||
nodes.push(r)
|
||||
restoredIdsInBase.add(r.id)
|
||||
}
|
||||
}
|
||||
|
||||
const addedEdgeIdsSet = new Set(delta.addedEdgeIds)
|
||||
const restoredEdgesById = new Map(delta.restoredEdges.map((e) => [e.id, e]))
|
||||
|
||||
const baseEdges = current.edges.filter((e) => !addedEdgeIdsSet.has(e.id))
|
||||
const edgeOrder = baseEdges.map((e) => e.id)
|
||||
const edgesById = new Map(baseEdges.map((e) => [e.id, e]))
|
||||
for (const r of delta.restoredEdges) {
|
||||
edgesById.set(r.id, r)
|
||||
}
|
||||
const edges: AppEdge[] = edgeOrder
|
||||
.map((id) => edgesById.get(id))
|
||||
.filter((e): e is AppEdge => e != null)
|
||||
const restoredEdgeIdsInBase = new Set(edges.map((e) => e.id))
|
||||
for (const r of delta.restoredEdges) {
|
||||
if (!restoredEdgeIdsInBase.has(r.id)) {
|
||||
edges.push(r)
|
||||
restoredEdgeIdsInBase.add(r.id)
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, edges }
|
||||
}
|
||||
|
||||
const MAX_HISTORY = 100
|
||||
|
||||
export function useGraphStateWithHistory(initialNodes: AppNode[], initialEdges: AppEdge[]) {
|
||||
@@ -22,30 +161,44 @@ export function useGraphStateWithHistory(initialNodes: AppNode[], initialEdges:
|
||||
const [edges, setEdgesState] = useState<AppEdge[]>(initialEdges)
|
||||
const [historySizes, setHistorySizes] = useState({ past: 0, future: 0 })
|
||||
|
||||
const pastRef = useRef<GraphState[]>([])
|
||||
const futureRef = useRef<GraphState[]>([])
|
||||
const pastRef = useRef<HistoryDelta[]>([])
|
||||
const futureRef = useRef<HistoryDelta[]>([])
|
||||
const preDragRef = useRef<GraphState | null>(null)
|
||||
const nodesRef = useRef(nodes)
|
||||
const edgesRef = useRef(edges)
|
||||
nodesRef.current = nodes
|
||||
edgesRef.current = edges
|
||||
|
||||
const pushToPast = useCallback((state: GraphState) => {
|
||||
const pushDeltaToPast = useCallback((delta: HistoryDelta) => {
|
||||
pastRef.current = pastRef.current.slice(-(MAX_HISTORY - 1))
|
||||
pastRef.current.push(cloneState(state))
|
||||
pastRef.current.push(delta)
|
||||
futureRef.current = []
|
||||
setHistorySizes({ past: pastRef.current.length, future: 0 })
|
||||
}, [])
|
||||
|
||||
const setNodes = useCallback((updater: AppNode[] | ((prev: AppNode[]) => AppNode[])) => {
|
||||
pushToPast({ nodes: nodesRef.current, edges: edgesRef.current })
|
||||
setNodesState(typeof updater === 'function' ? updater : () => updater)
|
||||
}, [pushToPast])
|
||||
const setNodes = useCallback(
|
||||
(updater: AppNode[] | ((prev: AppNode[]) => AppNode[])) => {
|
||||
const prev = { nodes: nodesRef.current, edges: edgesRef.current }
|
||||
const nextNodes = typeof updater === 'function' ? updater(prev.nodes) : updater
|
||||
const next = { nodes: nextNodes, edges: prev.edges }
|
||||
const delta = computeInverseDelta(prev, next)
|
||||
pushDeltaToPast(delta)
|
||||
setNodesState(nextNodes)
|
||||
},
|
||||
[pushDeltaToPast]
|
||||
)
|
||||
|
||||
const setEdges = useCallback((updater: AppEdge[] | ((prev: AppEdge[]) => AppEdge[])) => {
|
||||
pushToPast({ nodes: nodesRef.current, edges: edgesRef.current })
|
||||
setEdgesState(typeof updater === 'function' ? updater : () => updater)
|
||||
}, [pushToPast])
|
||||
const setEdges = useCallback(
|
||||
(updater: AppEdge[] | ((prev: AppEdge[]) => AppEdge[])) => {
|
||||
const prev = { nodes: nodesRef.current, edges: edgesRef.current }
|
||||
const nextEdges = typeof updater === 'function' ? updater(prev.edges) : updater
|
||||
const next = { nodes: prev.nodes, edges: nextEdges }
|
||||
const delta = computeInverseDelta(prev, next)
|
||||
pushDeltaToPast(delta)
|
||||
setEdgesState(nextEdges)
|
||||
},
|
||||
[pushDeltaToPast]
|
||||
)
|
||||
|
||||
const setNodesSilent = useCallback((updater: AppNode[] | ((prev: AppNode[]) => AppNode[])) => {
|
||||
setNodesState(typeof updater === 'function' ? updater : () => updater)
|
||||
@@ -55,12 +208,17 @@ export function useGraphStateWithHistory(initialNodes: AppNode[], initialEdges:
|
||||
setEdgesState(typeof updater === 'function' ? updater : () => updater)
|
||||
}, [])
|
||||
|
||||
const applyGraph = useCallback((updater: (state: GraphState) => GraphState) => {
|
||||
pushToPast({ nodes: nodesRef.current, edges: edgesRef.current })
|
||||
const next = updater({ nodes: nodesRef.current, edges: edgesRef.current })
|
||||
setNodesState(next.nodes)
|
||||
setEdgesState(next.edges)
|
||||
}, [pushToPast])
|
||||
const applyGraph = useCallback(
|
||||
(updater: (state: GraphState) => GraphState) => {
|
||||
const prev = { nodes: nodesRef.current, edges: edgesRef.current }
|
||||
const next = updater(prev)
|
||||
const delta = computeInverseDelta(prev, next)
|
||||
pushDeltaToPast(delta)
|
||||
setNodesState(next.nodes)
|
||||
setEdgesState(next.edges)
|
||||
},
|
||||
[pushDeltaToPast]
|
||||
)
|
||||
|
||||
const saveForDragEnd = useCallback(() => {
|
||||
preDragRef.current = cloneState({ nodes: nodesRef.current, edges: edgesRef.current })
|
||||
@@ -68,29 +226,36 @@ export function useGraphStateWithHistory(initialNodes: AppNode[], initialEdges:
|
||||
|
||||
const commitDragEnd = useCallback(() => {
|
||||
if (preDragRef.current) {
|
||||
pastRef.current = pastRef.current.slice(-(MAX_HISTORY - 1))
|
||||
pastRef.current.push(preDragRef.current)
|
||||
futureRef.current = []
|
||||
const snapshot = preDragRef.current
|
||||
preDragRef.current = null
|
||||
const current = { nodes: nodesRef.current, edges: edgesRef.current }
|
||||
const delta = computeInverseDelta(snapshot, current)
|
||||
pastRef.current = pastRef.current.slice(-(MAX_HISTORY - 1))
|
||||
pastRef.current.push(delta)
|
||||
futureRef.current = []
|
||||
setHistorySizes({ past: pastRef.current.length, future: 0 })
|
||||
}
|
||||
}, [])
|
||||
|
||||
const undo = useCallback(() => {
|
||||
if (pastRef.current.length === 0) return
|
||||
const prev = pastRef.current.pop()!
|
||||
futureRef.current.push(cloneState({ nodes: nodesRef.current, edges: edgesRef.current }))
|
||||
setNodesState(prev.nodes)
|
||||
setEdgesState(prev.edges)
|
||||
const delta = pastRef.current.pop()!
|
||||
const current = { nodes: nodesRef.current, edges: edgesRef.current }
|
||||
const prevState = applyInverseDelta(current, delta)
|
||||
futureRef.current = futureRef.current.slice(-(MAX_HISTORY - 1))
|
||||
futureRef.current.push(cloneState(current))
|
||||
setNodesState(prevState.nodes)
|
||||
setEdgesState(prevState.edges)
|
||||
setHistorySizes({ past: pastRef.current.length, future: futureRef.current.length })
|
||||
}, [])
|
||||
|
||||
const redo = useCallback(() => {
|
||||
if (futureRef.current.length === 0) return
|
||||
const next = futureRef.current.pop()!
|
||||
pastRef.current.push(cloneState({ nodes: nodesRef.current, edges: edgesRef.current }))
|
||||
setNodesState(next.nodes)
|
||||
setEdgesState(next.edges)
|
||||
const nextState = futureRef.current.pop()!
|
||||
const current = { nodes: nodesRef.current, edges: edgesRef.current }
|
||||
pastRef.current.push(computeInverseDelta(nextState, current))
|
||||
setNodesState(nextState.nodes)
|
||||
setEdgesState(nextState.edges)
|
||||
setHistorySizes({ past: pastRef.current.length, future: futureRef.current.length })
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
*/
|
||||
|
||||
import React, { useCallback, useContext, useMemo } from 'react'
|
||||
import FlowContext from './flowContext'
|
||||
import { GraphContext, ConnectionPathContext } from './flowContext'
|
||||
import { nodePropsAreEqual } from './flowUtils'
|
||||
import type { AppNode } from './nodeTypes'
|
||||
|
||||
@@ -71,13 +71,14 @@ export function useAbstractNode<TData = Record<string, unknown>>(
|
||||
id: string,
|
||||
data: TData
|
||||
): AbstractNodeContext<TData> {
|
||||
const ctx = useContext(FlowContext)
|
||||
const nodes = ctx?.nodes ?? []
|
||||
const edges = ctx?.edges ?? []
|
||||
const setNodes = ctx?.setNodes
|
||||
const setEdges = ctx?.setEdges
|
||||
const graphCtx = useContext(GraphContext)
|
||||
const pathCtx = useContext(ConnectionPathContext)
|
||||
const nodes = graphCtx?.nodes ?? []
|
||||
const edges = graphCtx?.edges ?? []
|
||||
const setNodes = graphCtx?.setNodes
|
||||
const setEdges = graphCtx?.setEdges
|
||||
|
||||
const addConnectionPathTrigger = ctx?.addConnectionPathTrigger
|
||||
const addConnectionPathTrigger = pathCtx?.addConnectionPathTrigger
|
||||
const updateData = useCallback(
|
||||
(partial: Partial<TData>) => {
|
||||
if (!setNodes) return
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
/**
|
||||
* FlowContext provides graph state, connection path state, and UI state to nodes and edges.
|
||||
* Flow state is split into three contexts to reduce re-renders:
|
||||
*
|
||||
* ## 1. Graph state (nodes, edges)
|
||||
* - nodes, setNodes, edges, setEdges – owned by useCanvasGraph (history + persistence).
|
||||
* - **GraphContext** – nodes, edges, setNodes, setEdges. Changes on every graph edit.
|
||||
* - **ConnectionPathContext** – path/trigger/updating/paused/error state and callbacks.
|
||||
* Only edges and path-aware nodes need this; graph edits don't change it.
|
||||
* - **FlowUIContext** – renaming, fullscreen, connectionFrom, flowActionsRef, isValidConnection.
|
||||
*
|
||||
* ## 2. Connection path state (edge visuals and path animation)
|
||||
* - Nodes report lifecycle (updating / error / paused) via useSyncConnectionStatus() in nodeLifecycle.
|
||||
* - Context holds: connectionPathUpdatingNodeIds, connectionPathTriggerNodeIds, connectionPathNodeIds,
|
||||
* connectionPathPausedSegmentNodeIds, connectionPathActiveSegmentNodeIds, connectionPathPausedNodeIds,
|
||||
* connectionPathErrorNodeIds, plus add/remove/start/end callbacks.
|
||||
* - Edges use these in getConnectionStatus() for color (default / updating / paused / error). See connectionStatus.ts.
|
||||
*
|
||||
* ## 3. UI state
|
||||
* - renamingNodeId, fullscreenNodeId, connectionFrom (drag-from handle), flowActionsRef, isValidConnection.
|
||||
* Consumers subscribe only to what they need so that e.g. connection path ticks
|
||||
* don't re-render every node, and node position changes don't re-render every edge.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react'
|
||||
@@ -21,58 +16,89 @@ import type { AppNode, AppEdge } from './nodeTypes'
|
||||
|
||||
export type ConnectionFrom = { nodeId: string; sourceHandle?: string } | null
|
||||
|
||||
/** Role of a node in the current connection path update: pushing data, receiving/loading, or just on path. */
|
||||
/** Role of a node in the current connection path update. */
|
||||
export type ConnectionPathRole = 'trigger' | 'updating' | 'on-path'
|
||||
|
||||
export type FlowActions = {
|
||||
pasteAtViewportCenter: () => void
|
||||
fitView: () => void
|
||||
pasteAtViewportCenter: () => void
|
||||
fitView: () => void
|
||||
}
|
||||
|
||||
export type FlowContextValue = {
|
||||
// ---- Graph state ----
|
||||
nodes: AppNode[]
|
||||
setNodes: (updater: AppNode[] | ((prev: AppNode[]) => AppNode[])) => void
|
||||
edges: AppEdge[]
|
||||
setEdges: (updater: AppEdge[] | ((prev: AppEdge[]) => AppEdge[])) => void
|
||||
// ---------------------------------------------------------------------------
|
||||
// Graph context (nodes, edges, setters)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---- UI state ----
|
||||
renamingNodeId: string | null
|
||||
setRenamingNodeId: (id: string | null) => void
|
||||
connectionFrom: ConnectionFrom
|
||||
setConnectionFrom: (v: ConnectionFrom) => void
|
||||
isValidConnection: (connection: Connection) => boolean
|
||||
flowActionsRef: React.MutableRefObject<FlowActions | null>
|
||||
fullscreenNodeId: string | null
|
||||
setFullscreenNodeId: (id: string | null) => void
|
||||
|
||||
// ---- Connection path state (edge status and path animation; see state.ts ConnectionPathState) ----
|
||||
connectionPathUpdatingNodeIds: string[]
|
||||
connectionPathTriggerNodeIds: string[]
|
||||
addConnectionPathTrigger: (nodeId: string) => void
|
||||
connectionPathNodeIds: Set<string>
|
||||
connectionPathPausedSegmentNodeIds: Set<string>
|
||||
connectionPathActiveSegmentNodeIds: Set<string>
|
||||
connectionPathPausedNodeIds: string[]
|
||||
addConnectionPathPausedNode: (nodeId: string) => void
|
||||
removeConnectionPathPausedNode: (nodeId: string) => void
|
||||
connectionPathErrorNodeIds: string[]
|
||||
addConnectionPathError: (nodeId: string) => void
|
||||
removeConnectionPathError: (nodeId: string) => void
|
||||
startConnectionPathUpdate: (nodeId: string) => void
|
||||
endConnectionPathUpdate: (nodeId: string) => void
|
||||
export type GraphContextValue = {
|
||||
nodes: AppNode[]
|
||||
setNodes: (updater: AppNode[] | ((prev: AppNode[]) => AppNode[])) => void
|
||||
edges: AppEdge[]
|
||||
setEdges: (updater: AppEdge[] | ((prev: AppEdge[]) => AppEdge[])) => void
|
||||
}
|
||||
|
||||
const GraphContext = React.createContext<GraphContextValue | null>(null)
|
||||
export { GraphContext }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connection path context (edge status and path animation)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ConnectionPathContextValue = {
|
||||
connectionPathUpdatingNodeIds: string[]
|
||||
connectionPathTriggerNodeIds: string[]
|
||||
addConnectionPathTrigger: (nodeId: string) => void
|
||||
connectionPathNodeIds: Set<string>
|
||||
connectionPathPausedSegmentNodeIds: Set<string>
|
||||
connectionPathActiveSegmentNodeIds: Set<string>
|
||||
connectionPathPausedNodeIds: string[]
|
||||
addConnectionPathPausedNode: (nodeId: string) => void
|
||||
removeConnectionPathPausedNode: (nodeId: string) => void
|
||||
connectionPathErrorNodeIds: string[]
|
||||
addConnectionPathError: (nodeId: string) => void
|
||||
removeConnectionPathError: (nodeId: string) => void
|
||||
startConnectionPathUpdate: (nodeId: string) => void
|
||||
endConnectionPathUpdate: (nodeId: string) => void
|
||||
}
|
||||
|
||||
const ConnectionPathContext = React.createContext<ConnectionPathContextValue | null>(null)
|
||||
export { ConnectionPathContext }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI context (renaming, fullscreen, connection drag, shortcuts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type FlowUIContextValue = {
|
||||
renamingNodeId: string | null
|
||||
setRenamingNodeId: (id: string | null) => void
|
||||
connectionFrom: ConnectionFrom
|
||||
setConnectionFrom: (v: ConnectionFrom) => void
|
||||
isValidConnection: (connection: Connection) => boolean
|
||||
flowActionsRef: React.MutableRefObject<FlowActions | null>
|
||||
fullscreenNodeId: string | null
|
||||
setFullscreenNodeId: (id: string | null) => void
|
||||
}
|
||||
|
||||
const FlowUIContext = React.createContext<FlowUIContextValue | null>(null)
|
||||
export { FlowUIContext }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Legacy single context (for gradual migration or components that need everything)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type FlowContextValue = GraphContextValue & ConnectionPathContextValue & FlowUIContextValue
|
||||
|
||||
const FlowContext = React.createContext<FlowContextValue | null>(null)
|
||||
|
||||
export default FlowContext
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hooks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns this node's role in the current path update for styling (pushing vs receiving).
|
||||
* Use with BaseNode's connectionPathRole prop or data-path-role for CSS.
|
||||
* Returns this node's role in the current path update for styling.
|
||||
* Uses only ConnectionPathContext so nodes don't re-render on graph changes.
|
||||
*/
|
||||
export function useConnectionPathRole(nodeId: string | undefined): ConnectionPathRole | null {
|
||||
const ctx = React.useContext(FlowContext)
|
||||
const ctx = React.useContext(ConnectionPathContext)
|
||||
return useMemo(() => {
|
||||
if (!nodeId) return null
|
||||
const triggers = ctx?.connectionPathTriggerNodeIds
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
*/
|
||||
|
||||
import { useContext, useEffect, useRef } from 'react'
|
||||
import FlowContext from './flowContext'
|
||||
import { ConnectionPathContext } from './flowContext'
|
||||
|
||||
export type NodeLifecyclePhase = 'idle' | 'trigger' | 'updating' | 'paused' | 'error'
|
||||
|
||||
@@ -53,7 +53,7 @@ export function useSyncConnectionStatus(
|
||||
nodeId: string,
|
||||
state: NodeConnectionStatusState
|
||||
): void {
|
||||
const ctx = useContext(FlowContext)
|
||||
const ctx = useContext(ConnectionPathContext)
|
||||
const { updating, error, paused } = state
|
||||
const prevRef = useRef({ updating: false, error: false, paused: false })
|
||||
|
||||
|
||||
Reference in New Issue
Block a user