@principal-ai/principal-view-react 0.6.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +111 -0
- package/dist/components/ConfigurationSelector.d.ts +37 -0
- package/dist/components/ConfigurationSelector.d.ts.map +1 -0
- package/dist/components/ConfigurationSelector.js +67 -0
- package/dist/components/ConfigurationSelector.js.map +1 -0
- package/dist/components/EdgeInfoPanel.d.ts +16 -0
- package/dist/components/EdgeInfoPanel.d.ts.map +1 -0
- package/dist/components/EdgeInfoPanel.js +85 -0
- package/dist/components/EdgeInfoPanel.js.map +1 -0
- package/dist/components/EventLog.d.ts +20 -0
- package/dist/components/EventLog.d.ts.map +1 -0
- package/dist/components/EventLog.js +13 -0
- package/dist/components/EventLog.js.map +1 -0
- package/dist/components/EventLog.test.d.ts +2 -0
- package/dist/components/EventLog.test.d.ts.map +1 -0
- package/dist/components/EventLog.test.js +73 -0
- package/dist/components/EventLog.test.js.map +1 -0
- package/dist/components/GraphRenderer.d.ts +121 -0
- package/dist/components/GraphRenderer.d.ts.map +1 -0
- package/dist/components/GraphRenderer.js +809 -0
- package/dist/components/GraphRenderer.js.map +1 -0
- package/dist/components/GraphRenderer.test.d.ts +2 -0
- package/dist/components/GraphRenderer.test.d.ts.map +1 -0
- package/dist/components/GraphRenderer.test.js +88 -0
- package/dist/components/GraphRenderer.test.js.map +1 -0
- package/dist/components/MetricsDashboard.d.ts +14 -0
- package/dist/components/MetricsDashboard.d.ts.map +1 -0
- package/dist/components/MetricsDashboard.js +13 -0
- package/dist/components/MetricsDashboard.js.map +1 -0
- package/dist/components/NodeInfoPanel.d.ts +21 -0
- package/dist/components/NodeInfoPanel.d.ts.map +1 -0
- package/dist/components/NodeInfoPanel.js +217 -0
- package/dist/components/NodeInfoPanel.js.map +1 -0
- package/dist/edges/CustomEdge.d.ts +16 -0
- package/dist/edges/CustomEdge.d.ts.map +1 -0
- package/dist/edges/CustomEdge.js +200 -0
- package/dist/edges/CustomEdge.js.map +1 -0
- package/dist/edges/GenericEdge.d.ts +18 -0
- package/dist/edges/GenericEdge.d.ts.map +1 -0
- package/dist/edges/GenericEdge.js +14 -0
- package/dist/edges/GenericEdge.js.map +1 -0
- package/dist/hooks/usePathBasedEvents.d.ts +42 -0
- package/dist/hooks/usePathBasedEvents.d.ts.map +1 -0
- package/dist/hooks/usePathBasedEvents.js +122 -0
- package/dist/hooks/usePathBasedEvents.js.map +1 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +41 -0
- package/dist/index.js.map +1 -0
- package/dist/nodes/CustomNode.d.ts +18 -0
- package/dist/nodes/CustomNode.d.ts.map +1 -0
- package/dist/nodes/CustomNode.js +298 -0
- package/dist/nodes/CustomNode.js.map +1 -0
- package/dist/nodes/GenericNode.d.ts +20 -0
- package/dist/nodes/GenericNode.d.ts.map +1 -0
- package/dist/nodes/GenericNode.js +24 -0
- package/dist/nodes/GenericNode.js.map +1 -0
- package/dist/utils/animationMapping.d.ts +53 -0
- package/dist/utils/animationMapping.d.ts.map +1 -0
- package/dist/utils/animationMapping.js +133 -0
- package/dist/utils/animationMapping.js.map +1 -0
- package/dist/utils/graphConverter.d.ts +22 -0
- package/dist/utils/graphConverter.d.ts.map +1 -0
- package/dist/utils/graphConverter.js +176 -0
- package/dist/utils/graphConverter.js.map +1 -0
- package/dist/utils/iconResolver.d.ts +29 -0
- package/dist/utils/iconResolver.d.ts.map +1 -0
- package/dist/utils/iconResolver.js +68 -0
- package/dist/utils/iconResolver.js.map +1 -0
- package/package.json +61 -0
- package/src/components/ConfigurationSelector.tsx +147 -0
- package/src/components/EdgeInfoPanel.tsx +198 -0
- package/src/components/EventLog.test.tsx +85 -0
- package/src/components/EventLog.tsx +51 -0
- package/src/components/GraphRenderer.test.tsx +118 -0
- package/src/components/GraphRenderer.tsx +1222 -0
- package/src/components/MetricsDashboard.tsx +40 -0
- package/src/components/NodeInfoPanel.tsx +425 -0
- package/src/edges/CustomEdge.tsx +344 -0
- package/src/edges/GenericEdge.tsx +40 -0
- package/src/hooks/usePathBasedEvents.ts +182 -0
- package/src/index.ts +67 -0
- package/src/nodes/CustomNode.tsx +432 -0
- package/src/nodes/GenericNode.tsx +54 -0
- package/src/stories/AnimationWorkshop.stories.tsx +608 -0
- package/src/stories/EventDrivenAnimations.stories.tsx +499 -0
- package/src/stories/EventLog.stories.tsx +161 -0
- package/src/stories/GraphRenderer.stories.tsx +628 -0
- package/src/stories/Introduction.mdx +51 -0
- package/src/stories/MetricsDashboard.stories.tsx +227 -0
- package/src/stories/MultiConfig.stories.tsx +531 -0
- package/src/stories/MultiDirectionalConnections.stories.tsx +345 -0
- package/src/stories/NodeShapes.stories.tsx +769 -0
- package/src/utils/animationMapping.ts +170 -0
- package/src/utils/graphConverter.ts +218 -0
- package/src/utils/iconResolver.tsx +49 -0
|
@@ -0,0 +1,1222 @@
|
|
|
1
|
+
import React, { useMemo, useState, useEffect, useCallback, useRef, useImperativeHandle, forwardRef } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
ReactFlow,
|
|
4
|
+
Background,
|
|
5
|
+
Controls,
|
|
6
|
+
MiniMap,
|
|
7
|
+
ReactFlowProvider,
|
|
8
|
+
useReactFlow,
|
|
9
|
+
applyNodeChanges,
|
|
10
|
+
type Edge,
|
|
11
|
+
type NodeChange,
|
|
12
|
+
type Node,
|
|
13
|
+
type Connection,
|
|
14
|
+
} from '@xyflow/react';
|
|
15
|
+
import '@xyflow/react/dist/style.css';
|
|
16
|
+
import type { GraphConfiguration, NodeState, EdgeState, Violation, GraphEvent, ExtendedCanvas, ComponentLibrary } from '@principal-ai/principal-view-core';
|
|
17
|
+
import { CanvasConverter } from '@principal-ai/principal-view-core';
|
|
18
|
+
import { CustomNode } from '../nodes/CustomNode';
|
|
19
|
+
import type { CustomNodeData } from '../nodes/CustomNode';
|
|
20
|
+
import { CustomEdge } from '../edges/CustomEdge';
|
|
21
|
+
import type { CustomEdgeData } from '../edges/CustomEdge';
|
|
22
|
+
import { convertToXYFlowNodes, convertToXYFlowEdges, autoLayoutNodes } from '../utils/graphConverter';
|
|
23
|
+
import { EdgeInfoPanel } from './EdgeInfoPanel';
|
|
24
|
+
import { NodeInfoPanel } from './NodeInfoPanel';
|
|
25
|
+
|
|
26
|
+
/** Position change event for tracking node movements */
|
|
27
|
+
export interface NodePositionChange {
|
|
28
|
+
nodeId: string;
|
|
29
|
+
position: { x: number; y: number };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** All pending changes that can be saved */
|
|
33
|
+
export interface PendingChanges {
|
|
34
|
+
/** Node position changes */
|
|
35
|
+
positionChanges: NodePositionChange[];
|
|
36
|
+
/** Node updates (type, data changes) */
|
|
37
|
+
nodeUpdates: Array<{ nodeId: string; updates: { type?: string; data?: Record<string, unknown> } }>;
|
|
38
|
+
/** Deleted node IDs */
|
|
39
|
+
deletedNodeIds: string[];
|
|
40
|
+
/** New edges created (with optional handle info for connection points) */
|
|
41
|
+
createdEdges: Array<{ from: string; to: string; type: string; sourceHandle?: string; targetHandle?: string }>;
|
|
42
|
+
/** Deleted edges (with full connection info for config removal) */
|
|
43
|
+
deletedEdges: Array<{ from: string; to: string; type: string }>;
|
|
44
|
+
/** Whether there are any changes */
|
|
45
|
+
hasChanges: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Ref handle for imperative actions */
|
|
49
|
+
export interface GraphRendererHandle {
|
|
50
|
+
/** Get all pending changes */
|
|
51
|
+
getPendingChanges: () => PendingChanges;
|
|
52
|
+
/** Reset edit state to match current props */
|
|
53
|
+
resetEditState: () => void;
|
|
54
|
+
/** Check if there are unsaved changes */
|
|
55
|
+
hasUnsavedChanges: () => boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Base props shared by all render modes */
|
|
59
|
+
interface GraphRendererBaseProps {
|
|
60
|
+
/** Optional violations to highlight */
|
|
61
|
+
violations?: Violation[];
|
|
62
|
+
|
|
63
|
+
/** Optional configuration name for identification (used with multi-config setups) */
|
|
64
|
+
configName?: string;
|
|
65
|
+
|
|
66
|
+
/** Optional class name */
|
|
67
|
+
className?: string;
|
|
68
|
+
|
|
69
|
+
/** Optional width */
|
|
70
|
+
width?: number | string;
|
|
71
|
+
|
|
72
|
+
/** Optional height */
|
|
73
|
+
height?: number | string;
|
|
74
|
+
|
|
75
|
+
/** Whether to show minimap */
|
|
76
|
+
showMinimap?: boolean;
|
|
77
|
+
|
|
78
|
+
/** Whether to show controls */
|
|
79
|
+
showControls?: boolean;
|
|
80
|
+
|
|
81
|
+
/** Whether to show background */
|
|
82
|
+
showBackground?: boolean;
|
|
83
|
+
|
|
84
|
+
/** Optional event stream for triggering animations */
|
|
85
|
+
events?: GraphEvent[];
|
|
86
|
+
|
|
87
|
+
/** Optional callback when an event is processed */
|
|
88
|
+
onEventProcessed?: (event: GraphEvent) => void;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Whether edit mode is enabled.
|
|
92
|
+
* When true, nodes can be dragged, edited, deleted, and edges can be created/deleted.
|
|
93
|
+
* All changes are tracked internally and can be retrieved via ref.getPendingChanges()
|
|
94
|
+
*/
|
|
95
|
+
editable?: boolean;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Callback when pending changes state changes.
|
|
99
|
+
* Called with true when there are unsaved changes, false when there are none.
|
|
100
|
+
* Use this to enable/disable save buttons in the parent component.
|
|
101
|
+
*/
|
|
102
|
+
onPendingChangesChange?: (hasChanges: boolean) => void;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** GraphRenderer props - canvas format only */
|
|
106
|
+
export interface GraphRendererProps extends GraphRendererBaseProps {
|
|
107
|
+
/** Extended Canvas document */
|
|
108
|
+
canvas: ExtendedCanvas;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Optional component library containing reusable node and edge type definitions.
|
|
112
|
+
* Types from the library are merged with canvas-level types, with canvas types taking precedence.
|
|
113
|
+
* This allows sharing type definitions across multiple canvas files via a library.yaml file.
|
|
114
|
+
*/
|
|
115
|
+
library?: ComponentLibrary;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Define custom node types
|
|
119
|
+
const nodeTypes = {
|
|
120
|
+
custom: CustomNode,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// Define custom edge types
|
|
124
|
+
const edgeTypes = {
|
|
125
|
+
custom: CustomEdge,
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// Animation state for nodes and edges
|
|
129
|
+
interface AnimationState {
|
|
130
|
+
nodeAnimations: Record<string, { type: 'pulse' | 'flash' | 'shake' | 'entry'; duration: number; timestamp: number }>;
|
|
131
|
+
edgeAnimations: Record<string, { type: 'flow' | 'particle' | 'pulse' | 'glow'; duration: number; direction?: 'forward' | 'backward' | 'bidirectional'; timestamp: number }>;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Internal edit state tracking
|
|
135
|
+
interface EditState {
|
|
136
|
+
positionChanges: Map<string, { x: number; y: number }>;
|
|
137
|
+
nodeUpdates: Map<string, { type?: string; data?: Record<string, unknown> }>;
|
|
138
|
+
deletedNodeIds: Set<string>;
|
|
139
|
+
createdEdges: Array<{ id: string; from: string; to: string; type: string; sourceHandle?: string; targetHandle?: string }>;
|
|
140
|
+
deletedEdges: Array<{ id: string; from: string; to: string; type: string }>;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const createEmptyEditState = (): EditState => ({
|
|
144
|
+
positionChanges: new Map(),
|
|
145
|
+
nodeUpdates: new Map(),
|
|
146
|
+
deletedNodeIds: new Set(),
|
|
147
|
+
createdEdges: [],
|
|
148
|
+
deletedEdges: [],
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
/** Inner component receives normalized legacy format */
|
|
152
|
+
interface GraphRendererInnerProps {
|
|
153
|
+
configuration: GraphConfiguration;
|
|
154
|
+
nodes: NodeState[];
|
|
155
|
+
edges: EdgeState[];
|
|
156
|
+
violations?: Violation[];
|
|
157
|
+
configName?: string;
|
|
158
|
+
showMinimap?: boolean;
|
|
159
|
+
showControls?: boolean;
|
|
160
|
+
showBackground?: boolean;
|
|
161
|
+
events?: GraphEvent[];
|
|
162
|
+
onEventProcessed?: (event: GraphEvent) => void;
|
|
163
|
+
editable?: boolean;
|
|
164
|
+
onPendingChangesChange?: (hasChanges: boolean) => void;
|
|
165
|
+
onEditStateChange?: (editState: EditState) => void;
|
|
166
|
+
editStateRef: React.MutableRefObject<EditState>;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Inner component that uses ReactFlow hooks
|
|
171
|
+
*/
|
|
172
|
+
const GraphRendererInner: React.FC<GraphRendererInnerProps> = ({
|
|
173
|
+
configuration,
|
|
174
|
+
nodes: propNodes,
|
|
175
|
+
edges: propEdges,
|
|
176
|
+
violations = [],
|
|
177
|
+
configName: _configName,
|
|
178
|
+
showMinimap = true,
|
|
179
|
+
showControls = true,
|
|
180
|
+
showBackground = true,
|
|
181
|
+
events = [],
|
|
182
|
+
onEventProcessed,
|
|
183
|
+
editable = false,
|
|
184
|
+
onPendingChangesChange,
|
|
185
|
+
onEditStateChange,
|
|
186
|
+
editStateRef,
|
|
187
|
+
}) => {
|
|
188
|
+
const { fitView } = useReactFlow();
|
|
189
|
+
|
|
190
|
+
// Track active animations
|
|
191
|
+
const [animationState, setAnimationState] = useState<AnimationState>({
|
|
192
|
+
nodeAnimations: {},
|
|
193
|
+
edgeAnimations: {},
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Track selected edge for info panel
|
|
197
|
+
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
|
198
|
+
|
|
199
|
+
// Track selected node for info panel
|
|
200
|
+
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
|
201
|
+
|
|
202
|
+
// Track pending connection for edge type picker
|
|
203
|
+
const [pendingConnection, setPendingConnection] = useState<{
|
|
204
|
+
from: string;
|
|
205
|
+
to: string;
|
|
206
|
+
sourceHandle?: string;
|
|
207
|
+
targetHandle?: string;
|
|
208
|
+
validTypes: string[];
|
|
209
|
+
} | null>(null);
|
|
210
|
+
|
|
211
|
+
// ============================================
|
|
212
|
+
// INTERNAL EDIT STATE
|
|
213
|
+
// ============================================
|
|
214
|
+
|
|
215
|
+
// Local copies of nodes and edges for editing
|
|
216
|
+
const [localNodes, setLocalNodes] = useState<NodeState[]>(propNodes);
|
|
217
|
+
const [localEdges, setLocalEdges] = useState<EdgeState[]>(propEdges);
|
|
218
|
+
|
|
219
|
+
// Track the prop values to detect external changes
|
|
220
|
+
const propNodesKeyRef = useRef(propNodes.map(n => n.id).sort().join(','));
|
|
221
|
+
const propEdgesKeyRef = useRef(propEdges.map(e => e.id).sort().join(','));
|
|
222
|
+
|
|
223
|
+
// Sync local state with props when props change (e.g., config reload)
|
|
224
|
+
// This only happens when the structure changes, not during editing
|
|
225
|
+
useEffect(() => {
|
|
226
|
+
const newNodesKey = propNodes.map(n => n.id).sort().join(',');
|
|
227
|
+
const newEdgesKey = propEdges.map(e => e.id).sort().join(',');
|
|
228
|
+
|
|
229
|
+
if (newNodesKey !== propNodesKeyRef.current || newEdgesKey !== propEdgesKeyRef.current) {
|
|
230
|
+
propNodesKeyRef.current = newNodesKey;
|
|
231
|
+
propEdgesKeyRef.current = newEdgesKey;
|
|
232
|
+
setLocalNodes(propNodes);
|
|
233
|
+
setLocalEdges(propEdges);
|
|
234
|
+
// Reset edit state when props change
|
|
235
|
+
editStateRef.current = createEmptyEditState();
|
|
236
|
+
onEditStateChange?.(editStateRef.current);
|
|
237
|
+
onPendingChangesChange?.(false);
|
|
238
|
+
}
|
|
239
|
+
}, [propNodes, propEdges, editStateRef, onEditStateChange, onPendingChangesChange]);
|
|
240
|
+
|
|
241
|
+
// Use local state when editable, props when not
|
|
242
|
+
const nodes = editable ? localNodes : propNodes;
|
|
243
|
+
const edges = editable ? localEdges : propEdges;
|
|
244
|
+
|
|
245
|
+
// Helper to check if there are pending changes
|
|
246
|
+
const checkHasChanges = useCallback((state: EditState): boolean => {
|
|
247
|
+
return state.positionChanges.size > 0 ||
|
|
248
|
+
state.nodeUpdates.size > 0 ||
|
|
249
|
+
state.deletedNodeIds.size > 0 ||
|
|
250
|
+
state.createdEdges.length > 0 ||
|
|
251
|
+
state.deletedEdges.length > 0;
|
|
252
|
+
}, []);
|
|
253
|
+
|
|
254
|
+
// Helper to update edit state and notify parent
|
|
255
|
+
const updateEditState = useCallback((updater: (prev: EditState) => EditState) => {
|
|
256
|
+
const newState = updater(editStateRef.current);
|
|
257
|
+
editStateRef.current = newState;
|
|
258
|
+
onEditStateChange?.(newState);
|
|
259
|
+
onPendingChangesChange?.(checkHasChanges(newState));
|
|
260
|
+
}, [editStateRef, onEditStateChange, onPendingChangesChange, checkHasChanges]);
|
|
261
|
+
|
|
262
|
+
// ============================================
|
|
263
|
+
// EVENT HANDLERS
|
|
264
|
+
// ============================================
|
|
265
|
+
|
|
266
|
+
// Handle edge click
|
|
267
|
+
const onEdgeClick = useCallback((_event: React.MouseEvent, edge: Edge) => {
|
|
268
|
+
setSelectedEdgeId(edge.id);
|
|
269
|
+
setSelectedNodeId(null);
|
|
270
|
+
}, []);
|
|
271
|
+
|
|
272
|
+
// Handle node click
|
|
273
|
+
const onNodeClick = useCallback((_event: React.MouseEvent, node: Node) => {
|
|
274
|
+
setSelectedNodeId(node.id);
|
|
275
|
+
setSelectedEdgeId(null);
|
|
276
|
+
}, []);
|
|
277
|
+
|
|
278
|
+
// Handle close edge info panel
|
|
279
|
+
const onCloseEdgeInfoPanel = useCallback(() => {
|
|
280
|
+
setSelectedEdgeId(null);
|
|
281
|
+
}, []);
|
|
282
|
+
|
|
283
|
+
// Handle close node info panel
|
|
284
|
+
const onCloseNodeInfoPanel = useCallback(() => {
|
|
285
|
+
setSelectedNodeId(null);
|
|
286
|
+
}, []);
|
|
287
|
+
|
|
288
|
+
// Handle node update (internal - updates local state only)
|
|
289
|
+
const handleNodeUpdate = useCallback((nodeId: string, updates: { type?: string; data?: Record<string, unknown> }) => {
|
|
290
|
+
if (!editable) return;
|
|
291
|
+
|
|
292
|
+
// Update local nodes
|
|
293
|
+
setLocalNodes(prev => prev.map(node => {
|
|
294
|
+
if (node.id === nodeId) {
|
|
295
|
+
return {
|
|
296
|
+
...node,
|
|
297
|
+
type: updates.type ?? node.type,
|
|
298
|
+
data: updates.data ? { ...node.data, ...updates.data } : node.data,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
return node;
|
|
302
|
+
}));
|
|
303
|
+
|
|
304
|
+
// Track the change
|
|
305
|
+
updateEditState(prev => {
|
|
306
|
+
const newUpdates = new Map(prev.nodeUpdates);
|
|
307
|
+
const existing = newUpdates.get(nodeId) || {};
|
|
308
|
+
newUpdates.set(nodeId, {
|
|
309
|
+
type: updates.type ?? existing.type,
|
|
310
|
+
data: updates.data ? { ...existing.data, ...updates.data } : existing.data,
|
|
311
|
+
});
|
|
312
|
+
return { ...prev, nodeUpdates: newUpdates };
|
|
313
|
+
});
|
|
314
|
+
}, [editable, updateEditState]);
|
|
315
|
+
|
|
316
|
+
// Handle node delete (internal)
|
|
317
|
+
const handleNodeDelete = useCallback((nodeId: string) => {
|
|
318
|
+
if (!editable) return;
|
|
319
|
+
|
|
320
|
+
// Remove from local state
|
|
321
|
+
setLocalNodes(prev => prev.filter(n => n.id !== nodeId));
|
|
322
|
+
setLocalEdges(prev => prev.filter(e => e.from !== nodeId && e.to !== nodeId));
|
|
323
|
+
|
|
324
|
+
// Track the change
|
|
325
|
+
updateEditState(prev => {
|
|
326
|
+
const newDeletedNodes = new Set(prev.deletedNodeIds);
|
|
327
|
+
newDeletedNodes.add(nodeId);
|
|
328
|
+
// Remove any pending updates for this node
|
|
329
|
+
const newUpdates = new Map(prev.nodeUpdates);
|
|
330
|
+
newUpdates.delete(nodeId);
|
|
331
|
+
// Remove any position changes for this node
|
|
332
|
+
const newPositions = new Map(prev.positionChanges);
|
|
333
|
+
newPositions.delete(nodeId);
|
|
334
|
+
// Remove created edges that involve this node
|
|
335
|
+
const newCreatedEdges = prev.createdEdges.filter(e => e.from !== nodeId && e.to !== nodeId);
|
|
336
|
+
return {
|
|
337
|
+
...prev,
|
|
338
|
+
deletedNodeIds: newDeletedNodes,
|
|
339
|
+
nodeUpdates: newUpdates,
|
|
340
|
+
positionChanges: newPositions,
|
|
341
|
+
createdEdges: newCreatedEdges,
|
|
342
|
+
};
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
setSelectedNodeId(null);
|
|
346
|
+
}, [editable, updateEditState]);
|
|
347
|
+
|
|
348
|
+
// Handle edge delete (internal)
|
|
349
|
+
const handleEdgeDelete = useCallback((edgeId: string) => {
|
|
350
|
+
if (!editable) return;
|
|
351
|
+
|
|
352
|
+
// Find the edge before removing it so we can track its full info
|
|
353
|
+
const edgeToDelete = localEdges.find(e => e.id === edgeId);
|
|
354
|
+
|
|
355
|
+
// Remove from local state
|
|
356
|
+
setLocalEdges(prev => prev.filter(e => e.id !== edgeId));
|
|
357
|
+
|
|
358
|
+
// Track the change
|
|
359
|
+
updateEditState(prev => {
|
|
360
|
+
// Check if this was a newly created edge
|
|
361
|
+
const createdEdgeIndex = prev.createdEdges.findIndex(e => e.id === edgeId);
|
|
362
|
+
if (createdEdgeIndex >= 0) {
|
|
363
|
+
// Just remove it from created edges
|
|
364
|
+
const newCreatedEdges = [...prev.createdEdges];
|
|
365
|
+
newCreatedEdges.splice(createdEdgeIndex, 1);
|
|
366
|
+
return { ...prev, createdEdges: newCreatedEdges };
|
|
367
|
+
}
|
|
368
|
+
// Otherwise mark as deleted with full edge info
|
|
369
|
+
if (edgeToDelete) {
|
|
370
|
+
const newDeletedEdges = [...prev.deletedEdges, {
|
|
371
|
+
id: edgeId,
|
|
372
|
+
from: edgeToDelete.from,
|
|
373
|
+
to: edgeToDelete.to,
|
|
374
|
+
type: edgeToDelete.type
|
|
375
|
+
}];
|
|
376
|
+
return { ...prev, deletedEdges: newDeletedEdges };
|
|
377
|
+
}
|
|
378
|
+
return prev;
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
setSelectedEdgeId(null);
|
|
382
|
+
}, [editable, updateEditState, localEdges]);
|
|
383
|
+
|
|
384
|
+
// Handle new connection from drag
|
|
385
|
+
const handleConnect = useCallback((connection: Connection) => {
|
|
386
|
+
if (!editable || !connection.source || !connection.target) return;
|
|
387
|
+
|
|
388
|
+
// Find source and target node types
|
|
389
|
+
const sourceNode = nodes.find(n => n.id === connection.source);
|
|
390
|
+
const targetNode = nodes.find(n => n.id === connection.target);
|
|
391
|
+
if (!sourceNode || !targetNode) return;
|
|
392
|
+
|
|
393
|
+
// Find valid edge types for this connection
|
|
394
|
+
const validTypes = configuration.allowedConnections
|
|
395
|
+
.filter(ac => ac.from === sourceNode.type && ac.to === targetNode.type)
|
|
396
|
+
.map(ac => ac.via);
|
|
397
|
+
|
|
398
|
+
const uniqueTypes = [...new Set(validTypes)];
|
|
399
|
+
|
|
400
|
+
if (uniqueTypes.length === 0) {
|
|
401
|
+
console.warn(`No valid edge types for connection from ${sourceNode.type} to ${targetNode.type}`);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (uniqueTypes.length === 1) {
|
|
406
|
+
// Create edge immediately with handle information
|
|
407
|
+
createEdge(
|
|
408
|
+
connection.source,
|
|
409
|
+
connection.target,
|
|
410
|
+
uniqueTypes[0],
|
|
411
|
+
connection.sourceHandle ?? undefined,
|
|
412
|
+
connection.targetHandle ?? undefined
|
|
413
|
+
);
|
|
414
|
+
} else {
|
|
415
|
+
// Show picker
|
|
416
|
+
setPendingConnection({
|
|
417
|
+
from: connection.source,
|
|
418
|
+
to: connection.target,
|
|
419
|
+
sourceHandle: connection.sourceHandle ?? undefined,
|
|
420
|
+
targetHandle: connection.targetHandle ?? undefined,
|
|
421
|
+
validTypes: uniqueTypes,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
}, [editable, nodes, configuration.allowedConnections]);
|
|
425
|
+
|
|
426
|
+
// Create edge helper
|
|
427
|
+
const createEdge = useCallback((from: string, to: string, type: string, sourceHandle?: string, targetHandle?: string) => {
|
|
428
|
+
const edgeId = `${from}-${to}-${type}-${Date.now()}`;
|
|
429
|
+
|
|
430
|
+
// Add to local state with handle information
|
|
431
|
+
const newEdge: EdgeState & { sourceHandle?: string; targetHandle?: string } = {
|
|
432
|
+
id: edgeId,
|
|
433
|
+
type,
|
|
434
|
+
from,
|
|
435
|
+
to,
|
|
436
|
+
data: {},
|
|
437
|
+
createdAt: Date.now(),
|
|
438
|
+
updatedAt: Date.now(),
|
|
439
|
+
sourceHandle,
|
|
440
|
+
targetHandle,
|
|
441
|
+
};
|
|
442
|
+
setLocalEdges(prev => [...prev, newEdge]);
|
|
443
|
+
|
|
444
|
+
// Track the change
|
|
445
|
+
updateEditState(prev => ({
|
|
446
|
+
...prev,
|
|
447
|
+
createdEdges: [...prev.createdEdges, { id: edgeId, from, to, type, sourceHandle, targetHandle }],
|
|
448
|
+
}));
|
|
449
|
+
}, [updateEditState]);
|
|
450
|
+
|
|
451
|
+
// Handle edge type selection from picker
|
|
452
|
+
const handleEdgeTypeSelect = useCallback((type: string) => {
|
|
453
|
+
if (!pendingConnection) return;
|
|
454
|
+
createEdge(
|
|
455
|
+
pendingConnection.from,
|
|
456
|
+
pendingConnection.to,
|
|
457
|
+
type,
|
|
458
|
+
pendingConnection.sourceHandle,
|
|
459
|
+
pendingConnection.targetHandle
|
|
460
|
+
);
|
|
461
|
+
setPendingConnection(null);
|
|
462
|
+
}, [pendingConnection, createEdge]);
|
|
463
|
+
|
|
464
|
+
// Cancel edge type picker
|
|
465
|
+
const handleCancelEdgeTypePicker = useCallback(() => {
|
|
466
|
+
setPendingConnection(null);
|
|
467
|
+
}, []);
|
|
468
|
+
|
|
469
|
+
// Track whether reconnection succeeded
|
|
470
|
+
const edgeReconnectSuccessful = useRef(true);
|
|
471
|
+
|
|
472
|
+
// Called when user starts dragging an edge endpoint
|
|
473
|
+
const handleReconnectStart = useCallback(() => {
|
|
474
|
+
edgeReconnectSuccessful.current = false;
|
|
475
|
+
}, []);
|
|
476
|
+
|
|
477
|
+
// Handle edge reconnection (dragging edge endpoint to new node)
|
|
478
|
+
const handleReconnect = useCallback((oldEdge: Edge, newConnection: Connection) => {
|
|
479
|
+
if (!editable || !newConnection.source || !newConnection.target) return;
|
|
480
|
+
|
|
481
|
+
// Find the original edge in our local state
|
|
482
|
+
const originalEdge = localEdges.find(e => e.id === oldEdge.id);
|
|
483
|
+
if (!originalEdge) return;
|
|
484
|
+
|
|
485
|
+
// Find source and target node types for validation
|
|
486
|
+
const sourceNode = nodes.find(n => n.id === newConnection.source);
|
|
487
|
+
const targetNode = nodes.find(n => n.id === newConnection.target);
|
|
488
|
+
if (!sourceNode || !targetNode) return;
|
|
489
|
+
|
|
490
|
+
// Check if the new connection is valid for this edge type
|
|
491
|
+
const isValidConnection = configuration.allowedConnections.some(
|
|
492
|
+
ac => ac.from === sourceNode.type && ac.to === targetNode.type && ac.via === originalEdge.type
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
if (!isValidConnection) {
|
|
496
|
+
console.warn(`Cannot reconnect: ${originalEdge.type} edge not allowed from ${sourceNode.type} to ${targetNode.type}`);
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Mark as successful before updating
|
|
501
|
+
edgeReconnectSuccessful.current = true;
|
|
502
|
+
|
|
503
|
+
// Update local edges - manually update the edge to preserve its type and id
|
|
504
|
+
setLocalEdges(prev => prev.map(edge => {
|
|
505
|
+
if (edge.id === oldEdge.id) {
|
|
506
|
+
return {
|
|
507
|
+
...edge,
|
|
508
|
+
from: newConnection.source!,
|
|
509
|
+
to: newConnection.target!,
|
|
510
|
+
sourceHandle: newConnection.sourceHandle ?? undefined,
|
|
511
|
+
targetHandle: newConnection.targetHandle ?? undefined,
|
|
512
|
+
updatedAt: Date.now(),
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
return edge;
|
|
516
|
+
}));
|
|
517
|
+
|
|
518
|
+
// Track the change - remove old edge and add new one
|
|
519
|
+
updateEditState(prev => {
|
|
520
|
+
// Check if this was a newly created edge
|
|
521
|
+
const createdEdgeIndex = prev.createdEdges.findIndex(e => e.id === oldEdge.id);
|
|
522
|
+
|
|
523
|
+
if (createdEdgeIndex >= 0) {
|
|
524
|
+
// Update the created edge entry
|
|
525
|
+
const newCreatedEdges = [...prev.createdEdges];
|
|
526
|
+
newCreatedEdges[createdEdgeIndex] = {
|
|
527
|
+
...newCreatedEdges[createdEdgeIndex],
|
|
528
|
+
from: newConnection.source!,
|
|
529
|
+
to: newConnection.target!,
|
|
530
|
+
sourceHandle: newConnection.sourceHandle ?? undefined,
|
|
531
|
+
targetHandle: newConnection.targetHandle ?? undefined,
|
|
532
|
+
};
|
|
533
|
+
return { ...prev, createdEdges: newCreatedEdges };
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// For existing edges, track as delete + create
|
|
537
|
+
const newDeletedEdges = [...prev.deletedEdges, {
|
|
538
|
+
id: oldEdge.id,
|
|
539
|
+
from: originalEdge.from,
|
|
540
|
+
to: originalEdge.to,
|
|
541
|
+
type: originalEdge.type,
|
|
542
|
+
}];
|
|
543
|
+
|
|
544
|
+
const newCreatedEdges = [...prev.createdEdges, {
|
|
545
|
+
id: oldEdge.id,
|
|
546
|
+
from: newConnection.source!,
|
|
547
|
+
to: newConnection.target!,
|
|
548
|
+
type: originalEdge.type,
|
|
549
|
+
sourceHandle: newConnection.sourceHandle ?? undefined,
|
|
550
|
+
targetHandle: newConnection.targetHandle ?? undefined,
|
|
551
|
+
}];
|
|
552
|
+
|
|
553
|
+
return { ...prev, deletedEdges: newDeletedEdges, createdEdges: newCreatedEdges };
|
|
554
|
+
});
|
|
555
|
+
}, [editable, localEdges, nodes, configuration.allowedConnections, updateEditState]);
|
|
556
|
+
|
|
557
|
+
// Called when reconnection ends (whether successful or not)
|
|
558
|
+
const handleReconnectEnd = useCallback(() => {
|
|
559
|
+
// If reconnection wasn't successful, the edge was dropped in empty space
|
|
560
|
+
// We need to keep the original edge (do nothing, it's still in localEdges)
|
|
561
|
+
// Edge is still in localEdges, no action needed - ReactFlow will re-render with it
|
|
562
|
+
edgeReconnectSuccessful.current = true;
|
|
563
|
+
}, []);
|
|
564
|
+
|
|
565
|
+
// ============================================
|
|
566
|
+
// SELECTED ITEMS
|
|
567
|
+
// ============================================
|
|
568
|
+
|
|
569
|
+
const selectedEdge = useMemo(() => {
|
|
570
|
+
if (!selectedEdgeId) return null;
|
|
571
|
+
return edges.find(e => e.id === selectedEdgeId);
|
|
572
|
+
}, [selectedEdgeId, edges]);
|
|
573
|
+
|
|
574
|
+
const selectedEdgeTypeDefinition = useMemo(() => {
|
|
575
|
+
if (!selectedEdge) return null;
|
|
576
|
+
return configuration.edgeTypes[selectedEdge.type];
|
|
577
|
+
}, [selectedEdge, configuration.edgeTypes]);
|
|
578
|
+
|
|
579
|
+
const selectedNode = useMemo(() => {
|
|
580
|
+
if (!selectedNodeId) return null;
|
|
581
|
+
return nodes.find(n => n.id === selectedNodeId);
|
|
582
|
+
}, [selectedNodeId, nodes]);
|
|
583
|
+
|
|
584
|
+
const selectedNodeTypeDefinition = useMemo(() => {
|
|
585
|
+
if (!selectedNode) return null;
|
|
586
|
+
return configuration.nodeTypes[selectedNode.type];
|
|
587
|
+
}, [selectedNode, configuration.nodeTypes]);
|
|
588
|
+
|
|
589
|
+
// ============================================
|
|
590
|
+
// ANIMATIONS
|
|
591
|
+
// ============================================
|
|
592
|
+
|
|
593
|
+
useEffect(() => {
|
|
594
|
+
if (events.length === 0) return;
|
|
595
|
+
|
|
596
|
+
const latestEvent = events[events.length - 1];
|
|
597
|
+
|
|
598
|
+
if (latestEvent.operation === 'animate' && latestEvent.category === 'edge') {
|
|
599
|
+
const edgeEvent = latestEvent.payload as any;
|
|
600
|
+
const edgeId = edgeEvent.edgeId;
|
|
601
|
+
const animation = edgeEvent.animation;
|
|
602
|
+
|
|
603
|
+
if (animation && edgeId) {
|
|
604
|
+
setAnimationState(prev => ({
|
|
605
|
+
...prev,
|
|
606
|
+
edgeAnimations: {
|
|
607
|
+
...prev.edgeAnimations,
|
|
608
|
+
[edgeId]: {
|
|
609
|
+
type: 'flow',
|
|
610
|
+
duration: animation.duration || 1000,
|
|
611
|
+
direction: animation.direction || 'forward',
|
|
612
|
+
timestamp: Date.now(),
|
|
613
|
+
},
|
|
614
|
+
},
|
|
615
|
+
}));
|
|
616
|
+
|
|
617
|
+
const duration = animation.duration || 1000;
|
|
618
|
+
setTimeout(() => {
|
|
619
|
+
setAnimationState(prev => {
|
|
620
|
+
const newEdgeAnimations = { ...prev.edgeAnimations };
|
|
621
|
+
delete newEdgeAnimations[edgeId];
|
|
622
|
+
return { ...prev, edgeAnimations: newEdgeAnimations };
|
|
623
|
+
});
|
|
624
|
+
}, duration);
|
|
625
|
+
|
|
626
|
+
onEventProcessed?.(latestEvent);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
if (latestEvent.category === 'state') {
|
|
631
|
+
const stateEvent = latestEvent.payload as any;
|
|
632
|
+
const nodeId = stateEvent.nodeId;
|
|
633
|
+
const newState = stateEvent.newState;
|
|
634
|
+
|
|
635
|
+
if (nodeId && newState) {
|
|
636
|
+
const stateToAnimation: Record<string, 'pulse' | 'flash' | 'shake'> = {
|
|
637
|
+
processing: 'pulse',
|
|
638
|
+
completed: 'flash',
|
|
639
|
+
error: 'shake',
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
const animationType = stateToAnimation[newState];
|
|
643
|
+
if (animationType) {
|
|
644
|
+
const duration = animationType === 'pulse' ? 1500 : animationType === 'flash' ? 1000 : 500;
|
|
645
|
+
|
|
646
|
+
setAnimationState(prev => ({
|
|
647
|
+
...prev,
|
|
648
|
+
nodeAnimations: {
|
|
649
|
+
...prev.nodeAnimations,
|
|
650
|
+
[nodeId]: { type: animationType, duration, timestamp: Date.now() },
|
|
651
|
+
},
|
|
652
|
+
}));
|
|
653
|
+
|
|
654
|
+
if (animationType !== 'pulse') {
|
|
655
|
+
setTimeout(() => {
|
|
656
|
+
setAnimationState(prev => {
|
|
657
|
+
const newNodeAnimations = { ...prev.nodeAnimations };
|
|
658
|
+
delete newNodeAnimations[nodeId];
|
|
659
|
+
return { ...prev, nodeAnimations: newNodeAnimations };
|
|
660
|
+
});
|
|
661
|
+
}, duration);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
onEventProcessed?.(latestEvent);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (latestEvent.category === 'node' && latestEvent.operation === 'create') {
|
|
670
|
+
const nodeEvent = latestEvent.payload as any;
|
|
671
|
+
const nodeId = nodeEvent.nodeId;
|
|
672
|
+
|
|
673
|
+
if (nodeId) {
|
|
674
|
+
setAnimationState(prev => ({
|
|
675
|
+
...prev,
|
|
676
|
+
nodeAnimations: {
|
|
677
|
+
...prev.nodeAnimations,
|
|
678
|
+
[nodeId]: { type: 'entry', duration: 600, timestamp: Date.now() },
|
|
679
|
+
},
|
|
680
|
+
}));
|
|
681
|
+
|
|
682
|
+
setTimeout(() => {
|
|
683
|
+
setAnimationState(prev => {
|
|
684
|
+
const newNodeAnimations = { ...prev.nodeAnimations };
|
|
685
|
+
delete newNodeAnimations[nodeId];
|
|
686
|
+
return { ...prev, nodeAnimations: newNodeAnimations };
|
|
687
|
+
});
|
|
688
|
+
}, 600);
|
|
689
|
+
|
|
690
|
+
onEventProcessed?.(latestEvent);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}, [events, onEventProcessed]);
|
|
694
|
+
|
|
695
|
+
// ============================================
|
|
696
|
+
// XYFLOW CONVERSION
|
|
697
|
+
// ============================================
|
|
698
|
+
|
|
699
|
+
const xyflowNodesBase = useMemo(() => {
|
|
700
|
+
const converted = convertToXYFlowNodes(nodes, configuration, violations);
|
|
701
|
+
const layoutType = configuration.display?.layout || 'hierarchical';
|
|
702
|
+
const positioned = autoLayoutNodes(converted, [], layoutType);
|
|
703
|
+
|
|
704
|
+
return positioned.map(node => {
|
|
705
|
+
const animation = animationState.nodeAnimations[node.id];
|
|
706
|
+
// Apply any pending position changes
|
|
707
|
+
const pendingPosition = editStateRef.current.positionChanges.get(node.id);
|
|
708
|
+
return {
|
|
709
|
+
...node,
|
|
710
|
+
...(pendingPosition ? { position: pendingPosition } : {}),
|
|
711
|
+
data: {
|
|
712
|
+
...node.data,
|
|
713
|
+
editable,
|
|
714
|
+
...(animation ? {
|
|
715
|
+
animationType: animation.type,
|
|
716
|
+
animationDuration: animation.duration,
|
|
717
|
+
} : {}),
|
|
718
|
+
} as CustomNodeData,
|
|
719
|
+
};
|
|
720
|
+
});
|
|
721
|
+
}, [nodes, configuration, violations, animationState.nodeAnimations, editable, editStateRef]);
|
|
722
|
+
|
|
723
|
+
const baseNodesKey = useMemo(() => {
|
|
724
|
+
return nodes.map(n => n.id).sort().join(',');
|
|
725
|
+
}, [nodes]);
|
|
726
|
+
|
|
727
|
+
// Local xyflow nodes state for dragging
|
|
728
|
+
const [xyflowLocalNodes, setXyflowLocalNodes] = useState(xyflowNodesBase);
|
|
729
|
+
|
|
730
|
+
// Sync when base changes
|
|
731
|
+
const prevBaseNodesKeyRef = useRef(baseNodesKey);
|
|
732
|
+
useEffect(() => {
|
|
733
|
+
if (prevBaseNodesKeyRef.current !== baseNodesKey) {
|
|
734
|
+
prevBaseNodesKeyRef.current = baseNodesKey;
|
|
735
|
+
setXyflowLocalNodes(xyflowNodesBase);
|
|
736
|
+
}
|
|
737
|
+
}, [baseNodesKey, xyflowNodesBase]);
|
|
738
|
+
|
|
739
|
+
// Also sync when entering edit mode or when base nodes change content
|
|
740
|
+
const prevEditableRef = useRef(editable);
|
|
741
|
+
useEffect(() => {
|
|
742
|
+
if (editable && !prevEditableRef.current) {
|
|
743
|
+
// Entering edit mode - sync positions
|
|
744
|
+
setXyflowLocalNodes(xyflowNodesBase);
|
|
745
|
+
}
|
|
746
|
+
prevEditableRef.current = editable;
|
|
747
|
+
}, [editable, xyflowNodesBase]);
|
|
748
|
+
|
|
749
|
+
const xyflowNodes = editable ? xyflowLocalNodes : xyflowNodesBase;
|
|
750
|
+
|
|
751
|
+
// Handle node changes (drag events)
|
|
752
|
+
const handleNodesChange = useCallback((changes: NodeChange[]) => {
|
|
753
|
+
if (!editable) return;
|
|
754
|
+
|
|
755
|
+
setXyflowLocalNodes(nds => applyNodeChanges(changes, nds) as Node<CustomNodeData>[]);
|
|
756
|
+
|
|
757
|
+
// Track position changes on drag end
|
|
758
|
+
const positionChanges = changes
|
|
759
|
+
.filter((change): change is NodeChange & {
|
|
760
|
+
type: 'position';
|
|
761
|
+
position: { x: number; y: number };
|
|
762
|
+
dragging: boolean
|
|
763
|
+
} =>
|
|
764
|
+
change.type === 'position' &&
|
|
765
|
+
'position' in change &&
|
|
766
|
+
change.position !== undefined &&
|
|
767
|
+
'dragging' in change &&
|
|
768
|
+
change.dragging === false
|
|
769
|
+
);
|
|
770
|
+
|
|
771
|
+
if (positionChanges.length > 0) {
|
|
772
|
+
updateEditState(prev => {
|
|
773
|
+
const newPositions = new Map(prev.positionChanges);
|
|
774
|
+
for (const change of positionChanges) {
|
|
775
|
+
newPositions.set(change.id, {
|
|
776
|
+
x: Math.round(change.position.x),
|
|
777
|
+
y: Math.round(change.position.y),
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
return { ...prev, positionChanges: newPositions };
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
}, [editable, updateEditState]);
|
|
784
|
+
|
|
785
|
+
const xyflowEdges = useMemo(() => {
|
|
786
|
+
const converted = convertToXYFlowEdges(edges, configuration, violations);
|
|
787
|
+
|
|
788
|
+
return converted.map(edge => {
|
|
789
|
+
const animation = animationState.edgeAnimations[edge.id];
|
|
790
|
+
if (animation) {
|
|
791
|
+
return {
|
|
792
|
+
...edge,
|
|
793
|
+
data: {
|
|
794
|
+
...edge.data,
|
|
795
|
+
animationType: animation.type,
|
|
796
|
+
animationDuration: animation.duration,
|
|
797
|
+
animationDirection: animation.direction,
|
|
798
|
+
} as CustomEdgeData,
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
return edge;
|
|
802
|
+
});
|
|
803
|
+
}, [edges, configuration, violations, animationState.edgeAnimations]);
|
|
804
|
+
|
|
805
|
+
// Fit view on mount and structure changes
|
|
806
|
+
useEffect(() => {
|
|
807
|
+
const timeoutId = setTimeout(() => {
|
|
808
|
+
fitView({
|
|
809
|
+
padding: 0.2,
|
|
810
|
+
includeHiddenNodes: false,
|
|
811
|
+
minZoom: 0.1,
|
|
812
|
+
maxZoom: 1.5,
|
|
813
|
+
duration: 200,
|
|
814
|
+
});
|
|
815
|
+
}, 100);
|
|
816
|
+
|
|
817
|
+
return () => clearTimeout(timeoutId);
|
|
818
|
+
}, [baseNodesKey, fitView]);
|
|
819
|
+
|
|
820
|
+
// ============================================
|
|
821
|
+
// RENDER
|
|
822
|
+
// ============================================
|
|
823
|
+
|
|
824
|
+
return (
|
|
825
|
+
<>
|
|
826
|
+
<ReactFlow
|
|
827
|
+
key={baseNodesKey}
|
|
828
|
+
nodes={xyflowNodes as any}
|
|
829
|
+
edges={xyflowEdges as any}
|
|
830
|
+
nodeTypes={nodeTypes as any}
|
|
831
|
+
edgeTypes={edgeTypes as any}
|
|
832
|
+
minZoom={0.1}
|
|
833
|
+
maxZoom={4}
|
|
834
|
+
defaultEdgeOptions={{ type: 'custom' }}
|
|
835
|
+
onEdgeClick={onEdgeClick}
|
|
836
|
+
onNodeClick={onNodeClick}
|
|
837
|
+
proOptions={{ hideAttribution: true }}
|
|
838
|
+
nodesDraggable={editable}
|
|
839
|
+
elementsSelectable={editable}
|
|
840
|
+
nodesConnectable={editable}
|
|
841
|
+
edgesReconnectable={editable}
|
|
842
|
+
onNodesChange={handleNodesChange}
|
|
843
|
+
onConnect={handleConnect}
|
|
844
|
+
onReconnectStart={handleReconnectStart}
|
|
845
|
+
onReconnect={handleReconnect}
|
|
846
|
+
onReconnectEnd={handleReconnectEnd}
|
|
847
|
+
panOnDrag
|
|
848
|
+
selectionOnDrag={false}
|
|
849
|
+
>
|
|
850
|
+
{showBackground && <Background color="#e5e5e5" gap={16} size={1} />}
|
|
851
|
+
{showControls && <Controls showZoom showFitView showInteractive />}
|
|
852
|
+
{showMinimap && (
|
|
853
|
+
<MiniMap
|
|
854
|
+
nodeColor={(node) => {
|
|
855
|
+
const nodeData = node.data as CustomNodeData;
|
|
856
|
+
return nodeData?.typeDefinition?.color || '#888';
|
|
857
|
+
}}
|
|
858
|
+
nodeBorderRadius={2}
|
|
859
|
+
pannable
|
|
860
|
+
zoomable
|
|
861
|
+
/>
|
|
862
|
+
)}
|
|
863
|
+
|
|
864
|
+
</ReactFlow>
|
|
865
|
+
|
|
866
|
+
{selectedEdge && selectedEdgeTypeDefinition && (
|
|
867
|
+
<EdgeInfoPanel
|
|
868
|
+
edge={selectedEdge}
|
|
869
|
+
typeDefinition={selectedEdgeTypeDefinition}
|
|
870
|
+
sourceNodeId={selectedEdge.from}
|
|
871
|
+
targetNodeId={selectedEdge.to}
|
|
872
|
+
onClose={onCloseEdgeInfoPanel}
|
|
873
|
+
onDelete={editable ? handleEdgeDelete : undefined}
|
|
874
|
+
/>
|
|
875
|
+
)}
|
|
876
|
+
|
|
877
|
+
{selectedNode && selectedNodeTypeDefinition && (
|
|
878
|
+
<NodeInfoPanel
|
|
879
|
+
node={selectedNode}
|
|
880
|
+
typeDefinition={selectedNodeTypeDefinition}
|
|
881
|
+
availableNodeTypes={configuration.nodeTypes}
|
|
882
|
+
onClose={onCloseNodeInfoPanel}
|
|
883
|
+
onDelete={editable ? handleNodeDelete : undefined}
|
|
884
|
+
onUpdate={editable ? handleNodeUpdate : undefined}
|
|
885
|
+
/>
|
|
886
|
+
)}
|
|
887
|
+
|
|
888
|
+
{pendingConnection && (
|
|
889
|
+
<div
|
|
890
|
+
style={{
|
|
891
|
+
position: 'absolute',
|
|
892
|
+
top: '50%',
|
|
893
|
+
left: '50%',
|
|
894
|
+
transform: 'translate(-50%, -50%)',
|
|
895
|
+
backgroundColor: 'white',
|
|
896
|
+
borderRadius: '8px',
|
|
897
|
+
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
|
898
|
+
padding: '16px',
|
|
899
|
+
minWidth: '200px',
|
|
900
|
+
zIndex: 1000,
|
|
901
|
+
}}
|
|
902
|
+
>
|
|
903
|
+
<div style={{ fontWeight: 'bold', marginBottom: '12px', fontSize: '14px' }}>
|
|
904
|
+
Select Edge Type
|
|
905
|
+
</div>
|
|
906
|
+
<div style={{ fontSize: '12px', color: '#666', marginBottom: '12px' }}>
|
|
907
|
+
{pendingConnection.from} → {pendingConnection.to}
|
|
908
|
+
</div>
|
|
909
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
910
|
+
{pendingConnection.validTypes.map(type => {
|
|
911
|
+
const typeDefinition = configuration.edgeTypes[type];
|
|
912
|
+
return (
|
|
913
|
+
<button
|
|
914
|
+
key={type}
|
|
915
|
+
onClick={() => handleEdgeTypeSelect(type)}
|
|
916
|
+
style={{
|
|
917
|
+
padding: '8px 12px',
|
|
918
|
+
backgroundColor: typeDefinition?.color || '#888',
|
|
919
|
+
color: 'white',
|
|
920
|
+
border: 'none',
|
|
921
|
+
borderRadius: '4px',
|
|
922
|
+
cursor: 'pointer',
|
|
923
|
+
fontSize: '12px',
|
|
924
|
+
fontWeight: 'bold',
|
|
925
|
+
textAlign: 'left',
|
|
926
|
+
}}
|
|
927
|
+
>
|
|
928
|
+
{type}
|
|
929
|
+
</button>
|
|
930
|
+
);
|
|
931
|
+
})}
|
|
932
|
+
</div>
|
|
933
|
+
<button
|
|
934
|
+
onClick={handleCancelEdgeTypePicker}
|
|
935
|
+
style={{
|
|
936
|
+
marginTop: '12px',
|
|
937
|
+
width: '100%',
|
|
938
|
+
padding: '8px 12px',
|
|
939
|
+
backgroundColor: '#f0f0f0',
|
|
940
|
+
color: '#666',
|
|
941
|
+
border: 'none',
|
|
942
|
+
borderRadius: '4px',
|
|
943
|
+
cursor: 'pointer',
|
|
944
|
+
fontSize: '12px',
|
|
945
|
+
}}
|
|
946
|
+
>
|
|
947
|
+
Cancel
|
|
948
|
+
</button>
|
|
949
|
+
</div>
|
|
950
|
+
)}
|
|
951
|
+
</>
|
|
952
|
+
);
|
|
953
|
+
};
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* Convert canvas to legacy configuration format for internal use
|
|
957
|
+
*/
|
|
958
|
+
function useCanvasToLegacy(canvas: ExtendedCanvas | undefined, library?: ComponentLibrary): {
|
|
959
|
+
configuration: GraphConfiguration;
|
|
960
|
+
nodes: NodeState[];
|
|
961
|
+
edges: EdgeState[];
|
|
962
|
+
} | null {
|
|
963
|
+
return useMemo(() => {
|
|
964
|
+
if (!canvas) return null;
|
|
965
|
+
|
|
966
|
+
const { nodes, edges } = CanvasConverter.canvasToGraph(canvas);
|
|
967
|
+
|
|
968
|
+
// Build GraphConfiguration from canvas
|
|
969
|
+
const nodeTypes: GraphConfiguration['nodeTypes'] = {};
|
|
970
|
+
const edgeTypes: GraphConfiguration['edgeTypes'] = {};
|
|
971
|
+
|
|
972
|
+
// First, add node types from library (lowest priority - can be overridden by canvas)
|
|
973
|
+
if (library?.nodeComponents) {
|
|
974
|
+
for (const [id, component] of Object.entries(library.nodeComponents)) {
|
|
975
|
+
nodeTypes[id] = {
|
|
976
|
+
shape: component.shape || 'rectangle',
|
|
977
|
+
icon: component.icon,
|
|
978
|
+
color: component.color,
|
|
979
|
+
size: component.size,
|
|
980
|
+
dataSchema: component.dataSchema || {},
|
|
981
|
+
states: component.states,
|
|
982
|
+
layout: component.layout,
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// Then, add edge types from library
|
|
988
|
+
if (library?.edgeComponents) {
|
|
989
|
+
for (const [id, component] of Object.entries(library.edgeComponents)) {
|
|
990
|
+
edgeTypes[id] = {
|
|
991
|
+
style: component.style || 'solid',
|
|
992
|
+
color: component.color,
|
|
993
|
+
width: component.width,
|
|
994
|
+
directed: component.directed,
|
|
995
|
+
animation: component.animation,
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// Next, add node types from canvas vv.nodeTypes (overrides library)
|
|
1001
|
+
if (canvas.pv?.nodeTypes) {
|
|
1002
|
+
for (const [id, def] of Object.entries(canvas.pv.nodeTypes)) {
|
|
1003
|
+
nodeTypes[id] = {
|
|
1004
|
+
shape: def.shape || 'rectangle',
|
|
1005
|
+
icon: def.icon,
|
|
1006
|
+
color: def.color,
|
|
1007
|
+
dataSchema: {},
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// Then extract node types from canvas nodes (for nodes that define their own types)
|
|
1013
|
+
for (const node of canvas.nodes || []) {
|
|
1014
|
+
const vv = node.pv;
|
|
1015
|
+
const nodeType = vv?.nodeType || node.type;
|
|
1016
|
+
|
|
1017
|
+
if (!nodeTypes[nodeType]) {
|
|
1018
|
+
// Color priority: vv.fill > node.color > vv.states.idle.color
|
|
1019
|
+
const fillColor = vv?.fill
|
|
1020
|
+
|| (typeof node.color === 'string' ? node.color : undefined)
|
|
1021
|
+
|| vv?.states?.idle?.color;
|
|
1022
|
+
|
|
1023
|
+
nodeTypes[nodeType] = {
|
|
1024
|
+
shape: vv?.shape || 'rectangle',
|
|
1025
|
+
icon: vv?.icon,
|
|
1026
|
+
color: fillColor,
|
|
1027
|
+
stroke: vv?.stroke,
|
|
1028
|
+
size: { width: node.width, height: node.height },
|
|
1029
|
+
dataSchema: vv?.dataSchema || {},
|
|
1030
|
+
states: vv?.states,
|
|
1031
|
+
layout: vv?.layout,
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// Extract edge types from canvas vv.edgeTypes
|
|
1037
|
+
if (canvas.pv?.edgeTypes) {
|
|
1038
|
+
for (const [id, def] of Object.entries(canvas.pv.edgeTypes)) {
|
|
1039
|
+
edgeTypes[id] = {
|
|
1040
|
+
style: def.style || 'solid',
|
|
1041
|
+
color: def.color,
|
|
1042
|
+
width: def.width,
|
|
1043
|
+
directed: def.directed,
|
|
1044
|
+
animation: def.animation,
|
|
1045
|
+
label: def.labelConfig,
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// Build allowed connections from edges
|
|
1051
|
+
const allowedConnections: GraphConfiguration['allowedConnections'] = [];
|
|
1052
|
+
for (const edge of canvas.edges || []) {
|
|
1053
|
+
const edgeType = edge.pv?.edgeType || 'default';
|
|
1054
|
+
|
|
1055
|
+
// Ensure edge type exists
|
|
1056
|
+
if (!edgeTypes[edgeType]) {
|
|
1057
|
+
edgeTypes[edgeType] = {
|
|
1058
|
+
style: edge.pv?.style || 'solid',
|
|
1059
|
+
color: typeof edge.color === 'string' ? edge.color : undefined,
|
|
1060
|
+
width: edge.pv?.width,
|
|
1061
|
+
directed: true,
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// Find node types for from/to
|
|
1066
|
+
const fromNode = canvas.nodes?.find(n => n.id === edge.fromNode);
|
|
1067
|
+
const toNode = canvas.nodes?.find(n => n.id === edge.toNode);
|
|
1068
|
+
const fromType = fromNode?.pv?.nodeType || edge.fromNode;
|
|
1069
|
+
const toType = toNode?.pv?.nodeType || edge.toNode;
|
|
1070
|
+
|
|
1071
|
+
allowedConnections.push({
|
|
1072
|
+
from: fromType,
|
|
1073
|
+
to: toType,
|
|
1074
|
+
via: edgeType,
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// Build display config with required layout field
|
|
1079
|
+
const display: GraphConfiguration['display'] = canvas.pv?.display
|
|
1080
|
+
? {
|
|
1081
|
+
layout: canvas.pv.display.layout || 'manual',
|
|
1082
|
+
theme: canvas.pv.display.theme,
|
|
1083
|
+
animations: canvas.pv.display.animations,
|
|
1084
|
+
}
|
|
1085
|
+
: { layout: 'manual' };
|
|
1086
|
+
|
|
1087
|
+
const configuration: GraphConfiguration = {
|
|
1088
|
+
metadata: {
|
|
1089
|
+
name: canvas.pv?.name || 'Untitled',
|
|
1090
|
+
version: canvas.pv?.version || '1.0.0',
|
|
1091
|
+
description: canvas.pv?.description,
|
|
1092
|
+
},
|
|
1093
|
+
nodeTypes,
|
|
1094
|
+
edgeTypes,
|
|
1095
|
+
allowedConnections,
|
|
1096
|
+
display,
|
|
1097
|
+
};
|
|
1098
|
+
|
|
1099
|
+
return { configuration, nodes, edges };
|
|
1100
|
+
}, [canvas, library]);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/**
|
|
1104
|
+
* Core graph visualization component using xyflow.
|
|
1105
|
+
*
|
|
1106
|
+
* Accepts an ExtendedCanvas document for rendering.
|
|
1107
|
+
*
|
|
1108
|
+
* When `editable` is true, the component manages its own edit state internally.
|
|
1109
|
+
* Use the ref to get pending changes when the user wants to save:
|
|
1110
|
+
*
|
|
1111
|
+
* ```tsx
|
|
1112
|
+
* <GraphRenderer canvas={myCanvas} />
|
|
1113
|
+
*
|
|
1114
|
+
* // With edit mode
|
|
1115
|
+
* const graphRef = useRef<GraphRendererHandle>(null);
|
|
1116
|
+
* <GraphRenderer
|
|
1117
|
+
* ref={graphRef}
|
|
1118
|
+
* canvas={myCanvas}
|
|
1119
|
+
* editable={isEditMode}
|
|
1120
|
+
* onPendingChangesChange={setHasUnsavedChanges}
|
|
1121
|
+
* />
|
|
1122
|
+
* ```
|
|
1123
|
+
*/
|
|
1124
|
+
export const GraphRenderer = forwardRef<GraphRendererHandle, GraphRendererProps>((props, ref) => {
|
|
1125
|
+
const { canvas, library, className, width = '100%', height = '100%' } = props;
|
|
1126
|
+
|
|
1127
|
+
// Convert canvas to internal format (merging library types if provided)
|
|
1128
|
+
const canvasData = useCanvasToLegacy(canvas, library);
|
|
1129
|
+
|
|
1130
|
+
// Validate we have required data
|
|
1131
|
+
if (!canvasData) {
|
|
1132
|
+
return (
|
|
1133
|
+
<div className={className} style={{ width, height, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
1134
|
+
<p style={{ color: '#666' }}>No canvas data provided.</p>
|
|
1135
|
+
</div>
|
|
1136
|
+
);
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
const { configuration, nodes, edges } = canvasData;
|
|
1140
|
+
|
|
1141
|
+
// Internal edit state ref
|
|
1142
|
+
const editStateRef = useRef<EditState>(createEmptyEditState());
|
|
1143
|
+
|
|
1144
|
+
// Expose imperative handle
|
|
1145
|
+
useImperativeHandle(ref, () => ({
|
|
1146
|
+
getPendingChanges: (): PendingChanges => {
|
|
1147
|
+
const state = editStateRef.current;
|
|
1148
|
+
return {
|
|
1149
|
+
positionChanges: Array.from(state.positionChanges.entries()).map(([nodeId, position]) => ({
|
|
1150
|
+
nodeId,
|
|
1151
|
+
position,
|
|
1152
|
+
})),
|
|
1153
|
+
nodeUpdates: Array.from(state.nodeUpdates.entries()).map(([nodeId, updates]) => ({
|
|
1154
|
+
nodeId,
|
|
1155
|
+
updates,
|
|
1156
|
+
})),
|
|
1157
|
+
deletedNodeIds: Array.from(state.deletedNodeIds),
|
|
1158
|
+
createdEdges: state.createdEdges.map(e => ({
|
|
1159
|
+
from: e.from,
|
|
1160
|
+
to: e.to,
|
|
1161
|
+
type: e.type,
|
|
1162
|
+
sourceHandle: e.sourceHandle,
|
|
1163
|
+
targetHandle: e.targetHandle,
|
|
1164
|
+
})),
|
|
1165
|
+
deletedEdges: state.deletedEdges.map(e => ({ from: e.from, to: e.to, type: e.type })),
|
|
1166
|
+
hasChanges: state.positionChanges.size > 0 ||
|
|
1167
|
+
state.nodeUpdates.size > 0 ||
|
|
1168
|
+
state.deletedNodeIds.size > 0 ||
|
|
1169
|
+
state.createdEdges.length > 0 ||
|
|
1170
|
+
state.deletedEdges.length > 0,
|
|
1171
|
+
};
|
|
1172
|
+
},
|
|
1173
|
+
resetEditState: () => {
|
|
1174
|
+
editStateRef.current = createEmptyEditState();
|
|
1175
|
+
},
|
|
1176
|
+
hasUnsavedChanges: (): boolean => {
|
|
1177
|
+
const state = editStateRef.current;
|
|
1178
|
+
return state.positionChanges.size > 0 ||
|
|
1179
|
+
state.nodeUpdates.size > 0 ||
|
|
1180
|
+
state.deletedNodeIds.size > 0 ||
|
|
1181
|
+
state.createdEdges.length > 0 ||
|
|
1182
|
+
state.deletedEdges.length > 0;
|
|
1183
|
+
},
|
|
1184
|
+
}), []);
|
|
1185
|
+
|
|
1186
|
+
// Extract only the props that inner component needs
|
|
1187
|
+
const {
|
|
1188
|
+
violations,
|
|
1189
|
+
configName,
|
|
1190
|
+
showMinimap,
|
|
1191
|
+
showControls,
|
|
1192
|
+
showBackground,
|
|
1193
|
+
events,
|
|
1194
|
+
onEventProcessed,
|
|
1195
|
+
editable,
|
|
1196
|
+
onPendingChangesChange,
|
|
1197
|
+
} = props;
|
|
1198
|
+
|
|
1199
|
+
return (
|
|
1200
|
+
<div className={className} style={{ width, height, position: 'relative' }}>
|
|
1201
|
+
<ReactFlowProvider>
|
|
1202
|
+
<GraphRendererInner
|
|
1203
|
+
configuration={configuration}
|
|
1204
|
+
nodes={nodes}
|
|
1205
|
+
edges={edges}
|
|
1206
|
+
violations={violations}
|
|
1207
|
+
configName={configName}
|
|
1208
|
+
showMinimap={showMinimap}
|
|
1209
|
+
showControls={showControls}
|
|
1210
|
+
showBackground={showBackground}
|
|
1211
|
+
events={events}
|
|
1212
|
+
onEventProcessed={onEventProcessed}
|
|
1213
|
+
editable={editable}
|
|
1214
|
+
onPendingChangesChange={onPendingChangesChange}
|
|
1215
|
+
editStateRef={editStateRef}
|
|
1216
|
+
/>
|
|
1217
|
+
</ReactFlowProvider>
|
|
1218
|
+
</div>
|
|
1219
|
+
);
|
|
1220
|
+
});
|
|
1221
|
+
|
|
1222
|
+
GraphRenderer.displayName = 'GraphRenderer';
|