@principal-ai/subsystems-react 0.21.0 → 0.21.1

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.
Files changed (201) hide show
  1. package/package.json +1 -1
  2. package/dist/components/ConfigurationSelector.d.ts +0 -35
  3. package/dist/components/ConfigurationSelector.d.ts.map +0 -1
  4. package/dist/components/ConfigurationSelector.js +0 -63
  5. package/dist/components/ConfigurationSelector.js.map +0 -1
  6. package/dist/components/GraphRenderer.d.ts +0 -295
  7. package/dist/components/GraphRenderer.d.ts.map +0 -1
  8. package/dist/components/GraphRenderer.js +0 -2008
  9. package/dist/components/GraphRenderer.js.map +0 -1
  10. package/dist/components/MultiCanvasRenderer.d.ts +0 -75
  11. package/dist/components/MultiCanvasRenderer.d.ts.map +0 -1
  12. package/dist/components/MultiCanvasRenderer.js +0 -203
  13. package/dist/components/MultiCanvasRenderer.js.map +0 -1
  14. package/dist/components/NodeTooltip.d.ts +0 -24
  15. package/dist/components/NodeTooltip.d.ts.map +0 -1
  16. package/dist/components/NodeTooltip.js +0 -160
  17. package/dist/components/NodeTooltip.js.map +0 -1
  18. package/dist/components/SelectionSidebar.d.ts +0 -18
  19. package/dist/components/SelectionSidebar.d.ts.map +0 -1
  20. package/dist/components/SelectionSidebar.js +0 -190
  21. package/dist/components/SelectionSidebar.js.map +0 -1
  22. package/dist/components/SequenceDiagramRenderer.d.ts +0 -77
  23. package/dist/components/SequenceDiagramRenderer.d.ts.map +0 -1
  24. package/dist/components/SequenceDiagramRenderer.js +0 -604
  25. package/dist/components/SequenceDiagramRenderer.js.map +0 -1
  26. package/dist/components/WorkflowSequenceDiagram.d.ts +0 -63
  27. package/dist/components/WorkflowSequenceDiagram.d.ts.map +0 -1
  28. package/dist/components/WorkflowSequenceDiagram.js +0 -147
  29. package/dist/components/WorkflowSequenceDiagram.js.map +0 -1
  30. package/dist/components/dashboard/DashboardRenderer.d.ts +0 -10
  31. package/dist/components/dashboard/DashboardRenderer.d.ts.map +0 -1
  32. package/dist/components/dashboard/DashboardRenderer.js +0 -179
  33. package/dist/components/dashboard/DashboardRenderer.js.map +0 -1
  34. package/dist/components/dashboard/MetricPanel.d.ts +0 -10
  35. package/dist/components/dashboard/MetricPanel.d.ts.map +0 -1
  36. package/dist/components/dashboard/MetricPanel.js +0 -103
  37. package/dist/components/dashboard/MetricPanel.js.map +0 -1
  38. package/dist/components/dashboard/MockDataProvider.d.ts +0 -30
  39. package/dist/components/dashboard/MockDataProvider.d.ts.map +0 -1
  40. package/dist/components/dashboard/MockDataProvider.js +0 -270
  41. package/dist/components/dashboard/MockDataProvider.js.map +0 -1
  42. package/dist/components/dashboard/components/BarChart.d.ts +0 -10
  43. package/dist/components/dashboard/components/BarChart.d.ts.map +0 -1
  44. package/dist/components/dashboard/components/BarChart.js +0 -167
  45. package/dist/components/dashboard/components/BarChart.js.map +0 -1
  46. package/dist/components/dashboard/components/LineChart.d.ts +0 -10
  47. package/dist/components/dashboard/components/LineChart.d.ts.map +0 -1
  48. package/dist/components/dashboard/components/LineChart.js +0 -141
  49. package/dist/components/dashboard/components/LineChart.js.map +0 -1
  50. package/dist/components/dashboard/components/MetricCard.d.ts +0 -9
  51. package/dist/components/dashboard/components/MetricCard.d.ts.map +0 -1
  52. package/dist/components/dashboard/components/MetricCard.js +0 -163
  53. package/dist/components/dashboard/components/MetricCard.js.map +0 -1
  54. package/dist/components/dashboard/components/SourceLink.d.ts +0 -9
  55. package/dist/components/dashboard/components/SourceLink.d.ts.map +0 -1
  56. package/dist/components/dashboard/components/SourceLink.js +0 -39
  57. package/dist/components/dashboard/components/SourceLink.js.map +0 -1
  58. package/dist/components/dashboard/components/TimeRangeSelector.d.ts +0 -9
  59. package/dist/components/dashboard/components/TimeRangeSelector.d.ts.map +0 -1
  60. package/dist/components/dashboard/components/TimeRangeSelector.js +0 -167
  61. package/dist/components/dashboard/components/TimeRangeSelector.js.map +0 -1
  62. package/dist/components/dashboard/components/index.d.ts +0 -6
  63. package/dist/components/dashboard/components/index.d.ts.map +0 -1
  64. package/dist/components/dashboard/components/index.js +0 -6
  65. package/dist/components/dashboard/components/index.js.map +0 -1
  66. package/dist/components/dashboard/index.d.ts +0 -6
  67. package/dist/components/dashboard/index.d.ts.map +0 -1
  68. package/dist/components/dashboard/index.js +0 -8
  69. package/dist/components/dashboard/index.js.map +0 -1
  70. package/dist/components/dashboard/types.d.ts +0 -74
  71. package/dist/components/dashboard/types.d.ts.map +0 -1
  72. package/dist/components/dashboard/types.js +0 -8
  73. package/dist/components/dashboard/types.js.map +0 -1
  74. package/dist/components/state-view/PipelineView.d.ts +0 -14
  75. package/dist/components/state-view/PipelineView.d.ts.map +0 -1
  76. package/dist/components/state-view/PipelineView.js +0 -195
  77. package/dist/components/state-view/PipelineView.js.map +0 -1
  78. package/dist/components/state-view/index.d.ts +0 -14
  79. package/dist/components/state-view/index.d.ts.map +0 -1
  80. package/dist/components/state-view/index.js +0 -12
  81. package/dist/components/state-view/index.js.map +0 -1
  82. package/dist/components/state-view/types.d.ts +0 -188
  83. package/dist/components/state-view/types.d.ts.map +0 -1
  84. package/dist/components/state-view/types.js +0 -10
  85. package/dist/components/state-view/types.js.map +0 -1
  86. package/dist/components/state-view/useStateView.d.ts +0 -32
  87. package/dist/components/state-view/useStateView.d.ts.map +0 -1
  88. package/dist/components/state-view/useStateView.js +0 -129
  89. package/dist/components/state-view/useStateView.js.map +0 -1
  90. package/dist/contexts/GraphEditContext.d.ts +0 -20
  91. package/dist/contexts/GraphEditContext.d.ts.map +0 -1
  92. package/dist/contexts/GraphEditContext.js +0 -8
  93. package/dist/contexts/GraphEditContext.js.map +0 -1
  94. package/dist/contexts/TooltipPortalContext.d.ts +0 -8
  95. package/dist/contexts/TooltipPortalContext.d.ts.map +0 -1
  96. package/dist/contexts/TooltipPortalContext.js +0 -8
  97. package/dist/contexts/TooltipPortalContext.js.map +0 -1
  98. package/dist/edges/CustomEdge.d.ts +0 -24
  99. package/dist/edges/CustomEdge.d.ts.map +0 -1
  100. package/dist/edges/CustomEdge.js +0 -222
  101. package/dist/edges/CustomEdge.js.map +0 -1
  102. package/dist/edges/GenericEdge.d.ts +0 -18
  103. package/dist/edges/GenericEdge.d.ts.map +0 -1
  104. package/dist/edges/GenericEdge.js +0 -10
  105. package/dist/edges/GenericEdge.js.map +0 -1
  106. package/dist/graphify/kind.d.ts +0 -23
  107. package/dist/graphify/kind.d.ts.map +0 -1
  108. package/dist/graphify/kind.js +0 -77
  109. package/dist/graphify/kind.js.map +0 -1
  110. package/dist/hooks/usePathBasedEvents.d.ts +0 -42
  111. package/dist/hooks/usePathBasedEvents.d.ts.map +0 -1
  112. package/dist/hooks/usePathBasedEvents.js +0 -119
  113. package/dist/hooks/usePathBasedEvents.js.map +0 -1
  114. package/dist/hooks/useSequenceLayout.d.ts +0 -186
  115. package/dist/hooks/useSequenceLayout.d.ts.map +0 -1
  116. package/dist/hooks/useSequenceLayout.js +0 -327
  117. package/dist/hooks/useSequenceLayout.js.map +0 -1
  118. package/dist/hooks/useUndoRedo.d.ts +0 -80
  119. package/dist/hooks/useUndoRedo.d.ts.map +0 -1
  120. package/dist/hooks/useUndoRedo.js +0 -70
  121. package/dist/hooks/useUndoRedo.js.map +0 -1
  122. package/dist/nodes/CustomNode.d.ts +0 -28
  123. package/dist/nodes/CustomNode.d.ts.map +0 -1
  124. package/dist/nodes/CustomNode.js +0 -820
  125. package/dist/nodes/CustomNode.js.map +0 -1
  126. package/dist/nodes/GenericNode.d.ts +0 -20
  127. package/dist/nodes/GenericNode.d.ts.map +0 -1
  128. package/dist/nodes/GenericNode.js +0 -20
  129. package/dist/nodes/GenericNode.js.map +0 -1
  130. package/dist/nodes/otel/OtelBoundaryNode.d.ts +0 -55
  131. package/dist/nodes/otel/OtelBoundaryNode.d.ts.map +0 -1
  132. package/dist/nodes/otel/OtelBoundaryNode.js +0 -81
  133. package/dist/nodes/otel/OtelBoundaryNode.js.map +0 -1
  134. package/dist/nodes/otel/OtelEventNode.d.ts +0 -59
  135. package/dist/nodes/otel/OtelEventNode.d.ts.map +0 -1
  136. package/dist/nodes/otel/OtelEventNode.js +0 -82
  137. package/dist/nodes/otel/OtelEventNode.js.map +0 -1
  138. package/dist/nodes/otel/OtelResourceNode.d.ts +0 -53
  139. package/dist/nodes/otel/OtelResourceNode.d.ts.map +0 -1
  140. package/dist/nodes/otel/OtelResourceNode.js +0 -105
  141. package/dist/nodes/otel/OtelResourceNode.js.map +0 -1
  142. package/dist/nodes/otel/OtelScopeNode.d.ts +0 -53
  143. package/dist/nodes/otel/OtelScopeNode.d.ts.map +0 -1
  144. package/dist/nodes/otel/OtelScopeNode.js +0 -81
  145. package/dist/nodes/otel/OtelScopeNode.js.map +0 -1
  146. package/dist/nodes/otel/OtelSpanConventionNode.d.ts +0 -61
  147. package/dist/nodes/otel/OtelSpanConventionNode.d.ts.map +0 -1
  148. package/dist/nodes/otel/OtelSpanConventionNode.js +0 -131
  149. package/dist/nodes/otel/OtelSpanConventionNode.js.map +0 -1
  150. package/dist/nodes/otel/index.d.ts +0 -17
  151. package/dist/nodes/otel/index.d.ts.map +0 -1
  152. package/dist/nodes/otel/index.js +0 -13
  153. package/dist/nodes/otel/index.js.map +0 -1
  154. package/dist/nodes/otel/shared/NodeBadges.d.ts +0 -10
  155. package/dist/nodes/otel/shared/NodeBadges.d.ts.map +0 -1
  156. package/dist/nodes/otel/shared/NodeBadges.js +0 -178
  157. package/dist/nodes/otel/shared/NodeBadges.js.map +0 -1
  158. package/dist/nodes/otel/shared/NodeContent.d.ts +0 -10
  159. package/dist/nodes/otel/shared/NodeContent.d.ts.map +0 -1
  160. package/dist/nodes/otel/shared/NodeContent.js +0 -96
  161. package/dist/nodes/otel/shared/NodeContent.js.map +0 -1
  162. package/dist/nodes/otel/shared/index.d.ts +0 -8
  163. package/dist/nodes/otel/shared/index.d.ts.map +0 -1
  164. package/dist/nodes/otel/shared/index.js +0 -8
  165. package/dist/nodes/otel/shared/index.js.map +0 -1
  166. package/dist/nodes/otel/shared/types.d.ts +0 -129
  167. package/dist/nodes/otel/shared/types.d.ts.map +0 -1
  168. package/dist/nodes/otel/shared/types.js +0 -5
  169. package/dist/nodes/otel/shared/types.js.map +0 -1
  170. package/dist/nodes/otel/shared/useNodeBehavior.d.ts +0 -42
  171. package/dist/nodes/otel/shared/useNodeBehavior.d.ts.map +0 -1
  172. package/dist/nodes/otel/shared/useNodeBehavior.js +0 -61
  173. package/dist/nodes/otel/shared/useNodeBehavior.js.map +0 -1
  174. package/dist/pierre/PierreThroughlineCodeView.d.ts +0 -22
  175. package/dist/pierre/PierreThroughlineCodeView.d.ts.map +0 -1
  176. package/dist/pierre/PierreThroughlineCodeView.js +0 -206
  177. package/dist/pierre/PierreThroughlineCodeView.js.map +0 -1
  178. package/dist/subsystem/SubsystemCarousel.d.ts +0 -36
  179. package/dist/subsystem/SubsystemCarousel.d.ts.map +0 -1
  180. package/dist/subsystem/SubsystemCarousel.js +0 -163
  181. package/dist/subsystem/SubsystemCarousel.js.map +0 -1
  182. package/dist/utils/animationMapping.d.ts +0 -53
  183. package/dist/utils/animationMapping.d.ts.map +0 -1
  184. package/dist/utils/animationMapping.js +0 -126
  185. package/dist/utils/animationMapping.js.map +0 -1
  186. package/dist/utils/canvasBounds.d.ts +0 -50
  187. package/dist/utils/canvasBounds.d.ts.map +0 -1
  188. package/dist/utils/canvasBounds.js +0 -88
  189. package/dist/utils/canvasBounds.js.map +0 -1
  190. package/dist/utils/graphConverter.d.ts +0 -18
  191. package/dist/utils/graphConverter.d.ts.map +0 -1
  192. package/dist/utils/graphConverter.js +0 -114
  193. package/dist/utils/graphConverter.js.map +0 -1
  194. package/dist/utils/iconResolver.d.ts +0 -29
  195. package/dist/utils/iconResolver.d.ts.map +0 -1
  196. package/dist/utils/iconResolver.js +0 -30
  197. package/dist/utils/iconResolver.js.map +0 -1
  198. package/dist/utils/orientationUtils.d.ts +0 -19
  199. package/dist/utils/orientationUtils.d.ts.map +0 -1
  200. package/dist/utils/orientationUtils.js +0 -62
  201. package/dist/utils/orientationUtils.js.map +0 -1
@@ -1,2008 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useMemo, useState, useEffect, useLayoutEffect, useCallback, useRef, useImperativeHandle, forwardRef, } from 'react';
3
- import { ReactFlow, Background, BackgroundVariant, Controls, MiniMap, ReactFlowProvider, useReactFlow, useUpdateNodeInternals, useViewport, applyNodeChanges, applyEdgeChanges, } from '@xyflow/react';
4
- import { CanvasConverter, buildScopeColorMap, buildSpanColorMap, resolveEventSpanColor, DRAFT_NODE_COLOR, DEFAULT_SPAN_COLOR, isStandardCanvasNode, } from '@principal-ai/subsystems-core';
5
- import { useTheme } from '@principal-ade/industry-theme';
6
- import { CustomNode } from '../nodes/CustomNode';
7
- import { CustomEdge } from '../edges/CustomEdge';
8
- import { convertToXYFlowNodes, convertToXYFlowEdges, } from '../utils/graphConverter';
9
- import { useElkLayout, applyElkPathsToEdges } from '../hooks/useElkLayout';
10
- import { getCanvasBounds, calculateInitialViewport, } from '../utils/canvasBounds';
11
- import { GraphEditProvider } from '../contexts/GraphEditContext';
12
- import { useUndoRedo } from '../hooks/useUndoRedo';
13
- import { TooltipPortalContext } from '../contexts/TooltipPortalContext';
14
- // Re-export for backwards compatibility
15
- export { TooltipPortalContext };
16
- // Define custom node types
17
- // Type assertion needed because ReactFlow's NodeTypes expects generic components
18
- // but CustomNode is properly typed with CustomNodeData for type safety internally
19
- const nodeTypes = { custom: CustomNode };
20
- // Define custom edge types
21
- // Type assertion needed because ReactFlow's EdgeTypes expects generic components
22
- // but CustomEdge is properly typed with CustomEdgeData for type safety internally
23
- const edgeTypes = { custom: CustomEdge };
24
- const createEmptyEditState = () => ({
25
- positionChanges: new Map(),
26
- dimensionChanges: new Map(),
27
- textChanges: new Map(),
28
- nodeUpdates: new Map(),
29
- deletedNodeIds: new Set(),
30
- createdEdges: [],
31
- deletedEdges: [],
32
- });
33
- /**
34
- * Alignment guides component that renders visual lines when nodes align during drag.
35
- * Shows vertical lines for horizontal alignment (left/center/right) and
36
- * horizontal lines for vertical alignment (top/middle/bottom).
37
- */
38
- const AlignmentGuidesOverlay = ({ guides, color }) => {
39
- const { x: viewportX, y: viewportY, zoom } = useViewport();
40
- if (guides.length === 0)
41
- return null;
42
- return (_jsx("svg", { style: {
43
- position: 'absolute',
44
- top: 0,
45
- left: 0,
46
- width: '100%',
47
- height: '100%',
48
- pointerEvents: 'none',
49
- zIndex: 10,
50
- }, children: guides.map((guide, index) => {
51
- if (guide.type === 'vertical') {
52
- // Vertical line (for horizontal alignment)
53
- const screenX = guide.position * zoom + viewportX;
54
- return (_jsx("line", { x1: screenX, y1: 0, x2: screenX, y2: "100%", stroke: color, strokeWidth: 1.5, strokeDasharray: "4,4", opacity: 0.8 }, `${guide.type}-${guide.position}-${index}`));
55
- }
56
- else {
57
- // Horizontal line (for vertical alignment)
58
- const screenY = guide.position * zoom + viewportY;
59
- return (_jsx("line", { x1: 0, y1: screenY, x2: "100%", y2: screenY, stroke: color, strokeWidth: 1.5, strokeDasharray: "4,4", opacity: 0.8 }, `${guide.type}-${guide.position}-${index}`));
60
- }
61
- }) }));
62
- };
63
- /**
64
- * Center indicator component that shows a crosshair at the canvas origin (0,0).
65
- * Uses viewport transform to position correctly regardless of pan/zoom.
66
- */
67
- const CenterIndicator = ({ color }) => {
68
- const { x, y } = useViewport();
69
- // Size of the crosshair in screen pixels (stays constant regardless of zoom)
70
- const size = 20;
71
- const strokeWidth = 1.5;
72
- // The viewport transform places origin (0,0) at screen position (x, y)
73
- const screenX = x;
74
- const screenY = y;
75
- return (_jsxs("svg", { style: {
76
- position: 'absolute',
77
- top: 0,
78
- left: 0,
79
- width: '100%',
80
- height: '100%',
81
- pointerEvents: 'none',
82
- zIndex: 5,
83
- }, children: [_jsx("line", { x1: screenX, y1: screenY - size, x2: screenX, y2: screenY + size, stroke: color, strokeWidth: strokeWidth, opacity: 0.7 }), _jsx("line", { x1: screenX - size, y1: screenY, x2: screenX + size, y2: screenY, stroke: color, strokeWidth: strokeWidth, opacity: 0.7 }), _jsx("circle", { cx: screenX, cy: screenY, r: 3, fill: color, opacity: 0.7 })] }));
84
- };
85
- /**
86
- * Inner component that uses ReactFlow hooks
87
- */
88
- const GraphRendererInner = ({ configuration, nodes: propNodes, edges: propEdges, violations = [], configName: _configName, showMinimap = false, showControls = true, showBackground = true, backgroundVariant = 'lines', backgroundGap, showCenterIndicator = false, showTooltips = true, fitViewDuration = 200, highlightedNodeId, activeNodeIds, events = [], onEventProcessed, editable = false, onPendingChangesChange, onEditStateChange, editStateRef, resetVisualStateRef, resetTextChangesVersionRef, undoRedoFunctionsRef, pushHistory, clearHistory, undoFromStack, redoFromStack, onNodeClick: onNodeClickProp, fitViewToNodeIds, fitViewPadding = 0.2, draggableNodeIds, onNodeDragStop: onNodeDragStopProp, onCopy, initialViewport, elkLayout, scenarioEdges, showSequenceLabels = true, }) => {
89
- const { fitView, fitBounds, getNodes } = useReactFlow();
90
- const updateNodeInternals = useUpdateNodeInternals();
91
- const { theme } = useTheme();
92
- // Track shift key state for tooltip control
93
- const [shiftKeyPressed, setShiftKeyPressed] = useState(false);
94
- // Track text changes version to force re-renders when text is edited
95
- const [textChangesVersion, setTextChangesVersion] = useState(0);
96
- // Track if we're currently processing a node hide operation
97
- const hidingNodeRef = useRef(false);
98
- // Setup keyboard event listeners for shift key
99
- useEffect(() => {
100
- const handleKeyDown = (e) => {
101
- if (e.key === 'Shift') {
102
- setShiftKeyPressed(true);
103
- }
104
- };
105
- const handleKeyUp = (e) => {
106
- if (e.key === 'Shift') {
107
- setShiftKeyPressed(false);
108
- }
109
- };
110
- window.addEventListener('keydown', handleKeyDown);
111
- window.addEventListener('keyup', handleKeyUp);
112
- return () => {
113
- window.removeEventListener('keydown', handleKeyDown);
114
- window.removeEventListener('keyup', handleKeyUp);
115
- };
116
- }, []);
117
- // Track active animations
118
- const [animationState, setAnimationState] = useState({
119
- nodeAnimations: {},
120
- edgeAnimations: {},
121
- });
122
- // Track alignment guides during drag operations
123
- const [alignmentGuides, setAlignmentGuides] = useState([]);
124
- // Track selected edges for info panel (supports multi-select)
125
- const [selectedEdgeIds, setSelectedEdgeIds] = useState(new Set());
126
- // Track selected nodes for info panel (supports multi-select)
127
- const [selectedNodeIds, setSelectedNodeIds] = useState(new Set());
128
- // Setup keyboard event listener for copy (Cmd+C / Ctrl+C)
129
- useEffect(() => {
130
- if (!onCopy)
131
- return;
132
- const handleCopy = (e) => {
133
- // Check for Cmd+C (Mac) or Ctrl+C (Windows/Linux)
134
- if ((e.metaKey || e.ctrlKey) && e.key === 'c') {
135
- if (selectedNodeIds.size > 0) {
136
- e.preventDefault();
137
- onCopy(Array.from(selectedNodeIds));
138
- }
139
- }
140
- };
141
- window.addEventListener('keydown', handleCopy);
142
- return () => {
143
- window.removeEventListener('keydown', handleCopy);
144
- };
145
- }, [onCopy, selectedNodeIds]);
146
- // Track hidden nodes (shift-click to toggle)
147
- const [hiddenNodeIds, setHiddenNodeIds] = useState(new Set());
148
- // Track pending connection for edge type picker
149
- const [pendingConnection, setPendingConnection] = useState(null);
150
- // Sync highlightedNodeId to selection state
151
- useEffect(() => {
152
- if (highlightedNodeId) {
153
- setSelectedNodeIds(new Set([highlightedNodeId]));
154
- // Clear edge selection when highlighting a node
155
- setSelectedEdgeIds(new Set());
156
- }
157
- }, [highlightedNodeId]);
158
- // ============================================
159
- // INTERNAL EDIT STATE
160
- // ============================================
161
- // Local copies of nodes and edges for editing
162
- const [localNodes, setLocalNodes] = useState(propNodes);
163
- const [localEdges, setLocalEdges] = useState(propEdges);
164
- // Track the prop values to detect external changes
165
- const propNodesKeyRef = useRef(propNodes
166
- .map((n) => n.id)
167
- .sort()
168
- .join(','));
169
- const propEdgesKeyRef = useRef(propEdges
170
- .map((e) => e.id)
171
- .sort()
172
- .join(','));
173
- // Sync local state with props when props change (e.g., config reload)
174
- // This only happens when the structure changes, not during editing
175
- useEffect(() => {
176
- const newNodesKey = propNodes
177
- .map((n) => n.id)
178
- .sort()
179
- .join(',');
180
- const newEdgesKey = propEdges
181
- .map((e) => e.id)
182
- .sort()
183
- .join(',');
184
- if (newNodesKey !== propNodesKeyRef.current || newEdgesKey !== propEdgesKeyRef.current) {
185
- // Debug: Log sync events
186
- if (process.env.NODE_ENV === 'development') {
187
- console.log('[GraphRenderer] Syncing local state with props:', {
188
- prevNodesKey: propNodesKeyRef.current,
189
- newNodesKey,
190
- prevEdgesKey: propEdgesKeyRef.current,
191
- newEdgesKey,
192
- propNodesCount: propNodes.length,
193
- propEdgesCount: propEdges.length,
194
- });
195
- }
196
- propNodesKeyRef.current = newNodesKey;
197
- propEdgesKeyRef.current = newEdgesKey;
198
- setLocalNodes(propNodes);
199
- setLocalEdges(propEdges);
200
- // Reset edit state when props change
201
- editStateRef.current = createEmptyEditState();
202
- onEditStateChange?.(editStateRef.current);
203
- onPendingChangesChange?.(false);
204
- // Clear animation state to prevent stale animations from affecting new edges
205
- setAnimationState({ nodeAnimations: {}, edgeAnimations: {} });
206
- // Clear undo/redo history when props change
207
- clearHistory();
208
- }
209
- }, [propNodes, propEdges, editStateRef, onEditStateChange, onPendingChangesChange]);
210
- // When draggableNodeIds is provided, positions are managed externally
211
- // Sync positions from props whenever they change (only when props actually change from parent)
212
- const propNodesRef = useRef(propNodes);
213
- useEffect(() => {
214
- if (!draggableNodeIds || draggableNodeIds.size === 0)
215
- return;
216
- // Only sync if propNodes reference actually changed (from parent update)
217
- if (propNodesRef.current === propNodes)
218
- return;
219
- propNodesRef.current = propNodes;
220
- setLocalNodes(propNodes);
221
- }, [propNodes, draggableNodeIds]);
222
- // Always use localNodes for rendering - it syncs with props when structure changes
223
- // and receives state_changed event updates. localEdges only used in edit mode.
224
- // Filter out deleted nodes in edit mode
225
- const nodes = editable
226
- ? localNodes.filter((node) => !editStateRef.current.deletedNodeIds.has(node.id))
227
- : localNodes;
228
- const edges = editable ? localEdges : propEdges;
229
- // Ref to track current xyflow nodes for undo/redo (set later via useEffect)
230
- const xyflowNodesRef = useRef([]);
231
- // Ref to capture node positions at drag start (for accurate undo "before" values)
232
- const dragStartPositionsRef = useRef(new Map());
233
- // Helper to check if there are pending changes
234
- const checkHasChanges = useCallback((state) => {
235
- return (state.positionChanges.size > 0 ||
236
- state.dimensionChanges.size > 0 ||
237
- state.textChanges.size > 0 ||
238
- state.nodeUpdates.size > 0 ||
239
- state.deletedNodeIds.size > 0 ||
240
- state.createdEdges.length > 0 ||
241
- state.deletedEdges.length > 0);
242
- }, []);
243
- // Helper to update edit state and notify parent
244
- const updateEditState = useCallback((updater) => {
245
- const newState = updater(editStateRef.current);
246
- editStateRef.current = newState;
247
- const hasChanges = checkHasChanges(newState);
248
- // Debug logging
249
- if (process.env.NODE_ENV === 'development') {
250
- console.log('[GraphRenderer] Edit state updated:', {
251
- positionChanges: newState.positionChanges.size,
252
- dimensionChanges: newState.dimensionChanges.size,
253
- textChanges: newState.textChanges.size,
254
- nodeUpdates: newState.nodeUpdates.size,
255
- hasChanges,
256
- });
257
- }
258
- onEditStateChange?.(newState);
259
- onPendingChangesChange?.(hasChanges);
260
- }, [editStateRef, onEditStateChange, onPendingChangesChange, checkHasChanges]);
261
- // Handler for node resize end - called from CustomNode via context
262
- const handleNodeResizeEnd = useCallback((nodeId, dimensions) => {
263
- if (!editable)
264
- return;
265
- // Capture before dimensions for undo
266
- const currentNode = xyflowNodesRef.current.find((n) => n.id === nodeId);
267
- const beforeDimensions = currentNode
268
- ? { width: currentNode.width ?? 0, height: currentNode.height ?? 0 }
269
- : { width: 0, height: 0 };
270
- // Push to history
271
- pushHistory([
272
- {
273
- type: 'dimension',
274
- nodeId,
275
- before: beforeDimensions,
276
- after: dimensions,
277
- },
278
- ]);
279
- updateEditState((prev) => {
280
- const newDimensions = new Map(prev.dimensionChanges);
281
- newDimensions.set(nodeId, dimensions);
282
- return { ...prev, dimensionChanges: newDimensions };
283
- });
284
- }, [editable, updateEditState, pushHistory]);
285
- // Handler for node text change - called from CustomNode via context
286
- const handleNodeTextChange = useCallback((nodeId, text) => {
287
- if (!editable)
288
- return;
289
- // Capture before text for undo
290
- // For text nodes, the text is in the canvas node's 'text' field
291
- // For group nodes, it's in the 'label' field
292
- // We need to look at the original canvas to get the before value
293
- const beforeText = editStateRef.current.textChanges.get(nodeId) ?? '';
294
- // Push to history
295
- pushHistory([
296
- {
297
- type: 'text',
298
- nodeId,
299
- before: beforeText,
300
- after: text,
301
- },
302
- ]);
303
- updateEditState((prev) => {
304
- const newTextChanges = new Map(prev.textChanges);
305
- newTextChanges.set(nodeId, text);
306
- return { ...prev, textChanges: newTextChanges };
307
- });
308
- // Force re-render by incrementing version
309
- setTextChangesVersion((v) => v + 1);
310
- }, [editable, updateEditState, pushHistory]);
311
- // Handler for deleting selected nodes (Delete/Backspace key)
312
- const handleDeleteSelectedNodes = useCallback(() => {
313
- if (!editable || selectedNodeIds.size === 0)
314
- return;
315
- const nodesToDelete = Array.from(selectedNodeIds);
316
- const historyEntries = [];
317
- // Build history entries for each deleted node
318
- for (const nodeId of nodesToDelete) {
319
- const node = localNodes.find((n) => n.id === nodeId);
320
- if (!node)
321
- continue;
322
- historyEntries.push({
323
- type: 'nodeDelete',
324
- nodeId,
325
- nodeData: node,
326
- });
327
- }
328
- // Push all deletions as a single batch
329
- if (historyEntries.length > 0) {
330
- pushHistory(historyEntries);
331
- updateEditState((prev) => {
332
- const newDeletedNodeIds = new Set(prev.deletedNodeIds);
333
- nodesToDelete.forEach((id) => newDeletedNodeIds.add(id));
334
- return { ...prev, deletedNodeIds: newDeletedNodeIds };
335
- });
336
- // Clear selection after deletion
337
- setSelectedNodeIds(new Set());
338
- }
339
- }, [editable, selectedNodeIds, localNodes, pushHistory, updateEditState]);
340
- // Handle toggling node hidden state (Cmd/Ctrl+click)
341
- // This is exposed via context so CustomNode can call it on mousedown
342
- // (mousedown works in edit mode where click is intercepted by drag handling)
343
- const handleToggleNodeHidden = useCallback((nodeId) => {
344
- hidingNodeRef.current = true;
345
- setHiddenNodeIds((prev) => {
346
- const next = new Set(prev);
347
- if (next.has(nodeId)) {
348
- next.delete(nodeId);
349
- }
350
- else {
351
- next.add(nodeId);
352
- }
353
- return next;
354
- });
355
- // Reset the flag after state update completes
356
- setTimeout(() => {
357
- hidingNodeRef.current = false;
358
- }, 0);
359
- }, []);
360
- // Handle hiding all nodes not directly connected to the given node (Cmd/Ctrl+Shift+click)
361
- // If all unconnected nodes are already hidden, this will unhide them (toggle behavior)
362
- const handleHideUnconnectedNodes = useCallback((nodeId) => {
363
- hidingNodeRef.current = true;
364
- // Find all nodes directly connected to this node
365
- const connectedNodeIds = new Set([nodeId]);
366
- for (const edge of localEdges) {
367
- if (edge.from === nodeId) {
368
- connectedNodeIds.add(edge.to);
369
- }
370
- else if (edge.to === nodeId) {
371
- connectedNodeIds.add(edge.from);
372
- }
373
- }
374
- // Get all unconnected node IDs
375
- const unconnectedNodeIds = localNodes
376
- .map((n) => n.id)
377
- .filter((id) => !connectedNodeIds.has(id));
378
- setHiddenNodeIds((prev) => {
379
- // Check if all unconnected nodes are already hidden
380
- const allUnconnectedHidden = unconnectedNodeIds.every((id) => prev.has(id));
381
- if (allUnconnectedHidden) {
382
- // Toggle off - unhide all unconnected nodes
383
- const next = new Set(prev);
384
- for (const id of unconnectedNodeIds) {
385
- next.delete(id);
386
- }
387
- return next;
388
- }
389
- else {
390
- // Hide all unconnected nodes
391
- const next = new Set(prev);
392
- for (const id of unconnectedNodeIds) {
393
- next.add(id);
394
- }
395
- return next;
396
- }
397
- });
398
- // Reset the flag after state update completes
399
- setTimeout(() => {
400
- hidingNodeRef.current = false;
401
- }, 0);
402
- }, [localEdges, localNodes]);
403
- // Memoize the context value to prevent unnecessary re-renders
404
- const graphEditContextValue = useMemo(() => ({
405
- onNodeResizeEnd: handleNodeResizeEnd,
406
- onNodeTextChange: handleNodeTextChange,
407
- onToggleNodeHidden: handleToggleNodeHidden,
408
- onHideUnconnectedNodes: handleHideUnconnectedNodes,
409
- }), [handleNodeResizeEnd, handleNodeTextChange, handleToggleNodeHidden, handleHideUnconnectedNodes]);
410
- // ============================================
411
- // ALIGNMENT GUIDES
412
- // ============================================
413
- /**
414
- * Detect alignment guides between a dragging node and other nodes
415
- * @param draggingNodeId - ID of the node being dragged
416
- * @param nodes - All nodes in the graph
417
- * @returns Array of alignment guides to display
418
- */
419
- const detectAlignmentGuides = useCallback((draggingNodeId, nodes) => {
420
- const threshold = 5; // pixels - how close to snap/show guide
421
- const guides = [];
422
- const draggingNode = nodes.find((n) => n.id === draggingNodeId);
423
- if (!draggingNode)
424
- return guides;
425
- const dragLeft = draggingNode.position.x;
426
- const dragRight = draggingNode.position.x + (draggingNode.width ?? 0);
427
- const dragCenterX = draggingNode.position.x + (draggingNode.width ?? 0) / 2;
428
- const dragTop = draggingNode.position.y;
429
- const dragBottom = draggingNode.position.y + (draggingNode.height ?? 0);
430
- const dragCenterY = draggingNode.position.y + (draggingNode.height ?? 0) / 2;
431
- // Check alignment with other nodes
432
- for (const node of nodes) {
433
- if (node.id === draggingNodeId)
434
- continue;
435
- const nodeLeft = node.position.x;
436
- const nodeRight = node.position.x + (node.width ?? 0);
437
- const nodeCenterX = node.position.x + (node.width ?? 0) / 2;
438
- const nodeTop = node.position.y;
439
- const nodeBottom = node.position.y + (node.height ?? 0);
440
- const nodeCenterY = node.position.y + (node.height ?? 0) / 2;
441
- // Vertical guides (align horizontally - left/center/right)
442
- if (Math.abs(dragLeft - nodeLeft) < threshold) {
443
- guides.push({ type: 'vertical', position: nodeLeft, label: 'Left' });
444
- }
445
- else if (Math.abs(dragCenterX - nodeCenterX) < threshold) {
446
- guides.push({ type: 'vertical', position: nodeCenterX, label: 'Center' });
447
- }
448
- else if (Math.abs(dragRight - nodeRight) < threshold) {
449
- guides.push({ type: 'vertical', position: nodeRight, label: 'Right' });
450
- }
451
- // Horizontal guides (align vertically - top/middle/bottom)
452
- if (Math.abs(dragTop - nodeTop) < threshold) {
453
- guides.push({ type: 'horizontal', position: nodeTop, label: 'Top' });
454
- }
455
- else if (Math.abs(dragCenterY - nodeCenterY) < threshold) {
456
- guides.push({ type: 'horizontal', position: nodeCenterY, label: 'Middle' });
457
- }
458
- else if (Math.abs(dragBottom - nodeBottom) < threshold) {
459
- guides.push({ type: 'horizontal', position: nodeBottom, label: 'Bottom' });
460
- }
461
- }
462
- // Remove duplicates (same position guides)
463
- const uniqueGuides = guides.filter((guide, index, self) => index ===
464
- self.findIndex((g) => g.type === guide.type && g.position === guide.position));
465
- return uniqueGuides;
466
- }, []);
467
- // ============================================
468
- // EVENT HANDLERS
469
- // ============================================
470
- // Handle edge click (toggle selection, supports Shift for multi-select)
471
- const onEdgeClick = useCallback((event, edge) => {
472
- if (event.shiftKey && editable) {
473
- // Shift+click: toggle edge in selection
474
- setSelectedEdgeIds((prev) => {
475
- const next = new Set(prev);
476
- if (next.has(edge.id)) {
477
- next.delete(edge.id);
478
- }
479
- else {
480
- next.add(edge.id);
481
- }
482
- return next;
483
- });
484
- }
485
- else {
486
- // Regular click: single select (replace selection)
487
- const shouldDeselect = selectedEdgeIds.size === 1 && selectedEdgeIds.has(edge.id);
488
- if (shouldDeselect) {
489
- setSelectedEdgeIds(new Set());
490
- }
491
- else {
492
- setSelectedEdgeIds(new Set([edge.id]));
493
- }
494
- setSelectedNodeIds(new Set());
495
- }
496
- }, [editable, selectedEdgeIds]);
497
- // Handle node click (toggle selection, supports Cmd/Ctrl for hide/dim)
498
- const onNodeClick = useCallback((event, node) => {
499
- // Cmd+click (Mac) or Ctrl+click (Windows/Linux): toggle node visibility (hide edges and dim node)
500
- // Cmd+Shift+click: hide all nodes not directly connected to this node
501
- if (event.metaKey || event.ctrlKey) {
502
- event.preventDefault();
503
- event.stopPropagation();
504
- if (event.shiftKey) {
505
- // Cmd/Ctrl+Shift+click: hide unconnected nodes
506
- handleHideUnconnectedNodes(node.id);
507
- }
508
- else {
509
- // Cmd/Ctrl+click: toggle single node
510
- handleToggleNodeHidden(node.id);
511
- }
512
- return;
513
- }
514
- // Shift+click: toggle node in selection (add/remove from multi-select)
515
- if (event.shiftKey) {
516
- event.preventDefault();
517
- event.stopPropagation();
518
- setSelectedNodeIds((prev) => {
519
- const next = new Set(prev);
520
- if (next.has(node.id)) {
521
- next.delete(node.id);
522
- }
523
- else {
524
- next.add(node.id);
525
- }
526
- // Update local nodes selection state immediately for edit mode
527
- if (editable) {
528
- setXyflowLocalNodes((nodes) => nodes.map((n) => ({
529
- ...n,
530
- selected: next.has(n.id),
531
- })));
532
- }
533
- return next;
534
- });
535
- return;
536
- }
537
- // Regular click: single select (replace selection)
538
- event.preventDefault();
539
- event.stopPropagation();
540
- const shouldDeselect = selectedNodeIds.size === 1 && selectedNodeIds.has(node.id);
541
- if (shouldDeselect) {
542
- setSelectedNodeIds(new Set());
543
- // Update local nodes selection state immediately for edit mode
544
- if (editable) {
545
- setXyflowLocalNodes((nodes) => nodes.map((n) => ({
546
- ...n,
547
- selected: false,
548
- })));
549
- }
550
- }
551
- else {
552
- setSelectedNodeIds(new Set([node.id]));
553
- // Update local nodes selection state immediately for edit mode
554
- if (editable) {
555
- setXyflowLocalNodes((nodes) => nodes.map((n) => ({
556
- ...n,
557
- selected: n.id === node.id,
558
- })));
559
- }
560
- }
561
- setSelectedEdgeIds(new Set());
562
- // If custom node click handler is provided, call it after selection is updated
563
- if (onNodeClickProp) {
564
- onNodeClickProp(node.id, event);
565
- }
566
- }, [selectedNodeIds, onNodeClickProp, editable, handleToggleNodeHidden, handleHideUnconnectedNodes]);
567
- // Track the last node we fitted to (for toggle behavior)
568
- const lastFittedNodeRef = useRef(null);
569
- // Handle double-click on node in non-edit mode to fit/focus around it
570
- const onNodeDoubleClick = useCallback((event, node) => {
571
- // Prevent default zoom behavior
572
- event.preventDefault();
573
- event.stopPropagation();
574
- // Only fit to node in non-edit mode
575
- if (editable)
576
- return;
577
- // Toggle behavior: if we already fitted to this node, zoom out to full view
578
- if (lastFittedNodeRef.current === node.id) {
579
- lastFittedNodeRef.current = null;
580
- // Calculate bounds for ALL nodes
581
- const allNodes = getNodes();
582
- if (allNodes.length > 0) {
583
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
584
- for (const n of allNodes) {
585
- const w = n.measured?.width ?? n.width ?? 200;
586
- const h = n.measured?.height ?? n.height ?? 100;
587
- minX = Math.min(minX, n.position.x);
588
- minY = Math.min(minY, n.position.y);
589
- maxX = Math.max(maxX, n.position.x + w);
590
- maxY = Math.max(maxY, n.position.y + h);
591
- }
592
- fitBounds({
593
- x: minX,
594
- y: minY,
595
- width: maxX - minX,
596
- height: maxY - minY,
597
- }, {
598
- padding: 0.2,
599
- duration: fitViewDuration,
600
- });
601
- }
602
- return;
603
- }
604
- // Get the node's dimensions (use measured dimensions if available)
605
- const width = node.measured?.width ?? node.width ?? 200;
606
- const height = node.measured?.height ?? node.height ?? 100;
607
- // Fit the view to the node's bounds
608
- lastFittedNodeRef.current = node.id;
609
- fitBounds({
610
- x: node.position.x,
611
- y: node.position.y,
612
- width,
613
- height,
614
- }, {
615
- padding: 0.5,
616
- duration: fitViewDuration,
617
- });
618
- }, [editable, fitBounds, getNodes, fitViewDuration]);
619
- // Handle pane click (clear selection when clicking empty space)
620
- const onPaneClick = useCallback(() => {
621
- setSelectedNodeIds(new Set());
622
- setSelectedEdgeIds(new Set());
623
- // In edit mode, also update local nodes selection state
624
- if (editable) {
625
- setXyflowLocalNodes((nodes) => nodes.map((n) => ({
626
- ...n,
627
- selected: false,
628
- })));
629
- }
630
- }, [editable]);
631
- // Handle selection change from ReactFlow (box selection and clicks)
632
- const handleSelectionChange = useCallback(({ nodes: selectedNodes, edges: selectedEdges }) => {
633
- // Ignore selection changes when we're hiding a node
634
- if (hidingNodeRef.current) {
635
- return;
636
- }
637
- // In edit mode, we manage selection ourselves via onNodeClick
638
- // Skip handleSelectionChange to avoid ReactFlow overwriting our selection state
639
- if (editable) {
640
- return;
641
- }
642
- const newSelectedNodeIds = new Set(selectedNodes.map((n) => n.id));
643
- // Update selection state for read-only mode (for visual feedback)
644
- setSelectedNodeIds(newSelectedNodeIds);
645
- setSelectedEdgeIds(new Set(selectedEdges.map((e) => e.id)));
646
- }, [editable]);
647
- // Create edge helper
648
- const createEdge = useCallback((from, to, type, sourceHandle, targetHandle) => {
649
- const edgeId = `${from}-${to}-${type}-${Date.now()}`;
650
- const now = Date.now();
651
- // Add to local state with handle information
652
- const newEdge = {
653
- id: edgeId,
654
- type,
655
- from,
656
- to,
657
- data: {},
658
- createdAt: now,
659
- updatedAt: now,
660
- sourceHandle,
661
- targetHandle,
662
- };
663
- setLocalEdges((prev) => [...prev, newEdge]);
664
- // Push to history for undo
665
- pushHistory([
666
- {
667
- type: 'edgeCreate',
668
- edge: newEdge,
669
- },
670
- ]);
671
- // Track the change
672
- updateEditState((prev) => ({
673
- ...prev,
674
- createdEdges: [
675
- ...prev.createdEdges,
676
- { id: edgeId, from, to, type, sourceHandle, targetHandle },
677
- ],
678
- }));
679
- }, [updateEditState, pushHistory]);
680
- // Handle new connection from drag
681
- const handleConnect = useCallback((connection) => {
682
- if (!editable || !connection.source || !connection.target)
683
- return;
684
- // Find source and target node types
685
- const sourceNode = nodes.find((n) => n.id === connection.source);
686
- const targetNode = nodes.find((n) => n.id === connection.target);
687
- if (!sourceNode || !targetNode)
688
- return;
689
- // Find valid edge types for this connection
690
- const validTypes = configuration.allowedConnections
691
- .filter((ac) => ac.from === sourceNode.type && ac.to === targetNode.type)
692
- .map((ac) => ac.via);
693
- const uniqueTypes = [...new Set(validTypes)];
694
- if (uniqueTypes.length === 0) {
695
- console.warn(`No valid edge types for connection from ${sourceNode.type} to ${targetNode.type}`);
696
- return;
697
- }
698
- if (uniqueTypes.length === 1) {
699
- // Create edge immediately with handle information
700
- createEdge(connection.source, connection.target, uniqueTypes[0], connection.sourceHandle ?? undefined, connection.targetHandle ?? undefined);
701
- }
702
- else {
703
- // Show picker
704
- setPendingConnection({
705
- from: connection.source,
706
- to: connection.target,
707
- sourceHandle: connection.sourceHandle ?? undefined,
708
- targetHandle: connection.targetHandle ?? undefined,
709
- validTypes: uniqueTypes,
710
- });
711
- }
712
- }, [editable, nodes, configuration.allowedConnections, createEdge]);
713
- // Handle edge type selection from picker
714
- const handleEdgeTypeSelect = useCallback((type) => {
715
- if (!pendingConnection)
716
- return;
717
- createEdge(pendingConnection.from, pendingConnection.to, type, pendingConnection.sourceHandle, pendingConnection.targetHandle);
718
- setPendingConnection(null);
719
- }, [pendingConnection, createEdge]);
720
- // Cancel edge type picker
721
- const handleCancelEdgeTypePicker = useCallback(() => {
722
- setPendingConnection(null);
723
- }, []);
724
- // Helper to convert handle ID back to side name
725
- const handleToSide = useCallback((handle, isSource) => {
726
- if (!handle)
727
- return undefined;
728
- // Source handles are like "right-out", target handles are like "left"
729
- if (isSource) {
730
- return handle.replace('-out', '');
731
- }
732
- return handle;
733
- }, []);
734
- // Track whether reconnection succeeded
735
- const edgeReconnectSuccessful = useRef(true);
736
- // Called when user starts dragging an edge endpoint
737
- const handleReconnectStart = useCallback(() => {
738
- edgeReconnectSuccessful.current = false;
739
- }, []);
740
- // Handle edge reconnection (dragging edge endpoint to new node)
741
- const handleReconnect = useCallback((oldEdge, newConnection) => {
742
- if (!editable || !newConnection.source || !newConnection.target)
743
- return;
744
- // Find the original edge in our local state
745
- const originalEdge = localEdges.find((e) => e.id === oldEdge.id);
746
- if (!originalEdge)
747
- return;
748
- // Find source and target nodes for validation
749
- const sourceNode = nodes.find((n) => n.id === newConnection.source);
750
- const targetNode = nodes.find((n) => n.id === newConnection.target);
751
- if (!sourceNode || !targetNode)
752
- return;
753
- // Check if the new connection is valid for this edge type
754
- // Note: allowedConnections uses node IDs as the from/to values
755
- const isValidConnection = configuration.allowedConnections.some((ac) => ac.from === sourceNode.id && ac.to === targetNode.id && ac.via === originalEdge.type);
756
- if (!isValidConnection) {
757
- console.warn(`Cannot reconnect: ${originalEdge.type} edge not allowed from ${sourceNode.id} to ${targetNode.id}`);
758
- return;
759
- }
760
- // Mark as successful before updating
761
- edgeReconnectSuccessful.current = true;
762
- // Convert handles back to sides for edge data
763
- const newFromSide = handleToSide(newConnection.sourceHandle, true);
764
- const newToSide = handleToSide(newConnection.targetHandle, false);
765
- // Update local edges - manually update the edge to preserve its type and id
766
- setLocalEdges((prev) => prev.map((edge) => {
767
- if (edge.id === oldEdge.id) {
768
- // Build data object, filtering out undefined values
769
- const updatedData = { ...edge.data };
770
- if (newFromSide !== undefined)
771
- updatedData.fromSide = newFromSide;
772
- if (newToSide !== undefined)
773
- updatedData.toSide = newToSide;
774
- return {
775
- ...edge,
776
- from: newConnection.source,
777
- to: newConnection.target,
778
- sourceHandle: newConnection.sourceHandle ?? undefined,
779
- targetHandle: newConnection.targetHandle ?? undefined,
780
- data: updatedData,
781
- updatedAt: Date.now(),
782
- };
783
- }
784
- return edge;
785
- }));
786
- // Also update the visual edge state directly to reflect the reconnection immediately
787
- setXyflowLocalEdges((prev) => prev.map((edge) => {
788
- if (edge.id === oldEdge.id) {
789
- return {
790
- ...edge,
791
- source: newConnection.source,
792
- target: newConnection.target,
793
- sourceHandle: newConnection.sourceHandle ?? undefined,
794
- targetHandle: newConnection.targetHandle ?? undefined,
795
- };
796
- }
797
- return edge;
798
- }));
799
- // Track the change - remove old edge and add new one
800
- updateEditState((prev) => {
801
- // Check if this was a newly created edge
802
- const createdEdgeIndex = prev.createdEdges.findIndex((e) => e.id === oldEdge.id);
803
- if (createdEdgeIndex >= 0) {
804
- // Update the created edge entry
805
- const newCreatedEdges = [...prev.createdEdges];
806
- newCreatedEdges[createdEdgeIndex] = {
807
- ...newCreatedEdges[createdEdgeIndex],
808
- from: newConnection.source,
809
- to: newConnection.target,
810
- sourceHandle: newConnection.sourceHandle ?? undefined,
811
- targetHandle: newConnection.targetHandle ?? undefined,
812
- };
813
- return { ...prev, createdEdges: newCreatedEdges };
814
- }
815
- // For existing edges, track as delete + create
816
- const newDeletedEdges = [
817
- ...prev.deletedEdges,
818
- {
819
- id: oldEdge.id,
820
- from: originalEdge.from,
821
- to: originalEdge.to,
822
- type: originalEdge.type,
823
- },
824
- ];
825
- const newCreatedEdges = [
826
- ...prev.createdEdges,
827
- {
828
- id: oldEdge.id,
829
- from: newConnection.source,
830
- to: newConnection.target,
831
- type: originalEdge.type,
832
- sourceHandle: newConnection.sourceHandle ?? undefined,
833
- targetHandle: newConnection.targetHandle ?? undefined,
834
- },
835
- ];
836
- return { ...prev, deletedEdges: newDeletedEdges, createdEdges: newCreatedEdges };
837
- });
838
- }, [editable, localEdges, nodes, configuration.allowedConnections, updateEditState, handleToSide]);
839
- // Called when reconnection ends (whether successful or not)
840
- const handleReconnectEnd = useCallback(() => {
841
- // If reconnection wasn't successful, the edge was dropped in empty space
842
- // We need to keep the original edge (do nothing, it's still in localEdges)
843
- // Edge is still in localEdges, no action needed - ReactFlow will re-render with it
844
- edgeReconnectSuccessful.current = true;
845
- }, []);
846
- // ============================================
847
- // SELECTED ITEMS
848
- // ============================================
849
- // Get first selected edge (for single-selection info panel)
850
- // ============================================
851
- // ANIMATIONS
852
- // ============================================
853
- useEffect(() => {
854
- if (events.length === 0)
855
- return;
856
- const latestEvent = events[events.length - 1];
857
- if (latestEvent.operation === 'animate' && latestEvent.category === 'edge') {
858
- const edgeEvent = latestEvent.payload;
859
- const edgeId = edgeEvent.edgeId;
860
- const animation = edgeEvent.animation;
861
- if (animation && edgeId) {
862
- setAnimationState((prev) => ({
863
- ...prev,
864
- edgeAnimations: {
865
- ...prev.edgeAnimations,
866
- [edgeId]: {
867
- type: 'flow',
868
- duration: animation.duration || 1000,
869
- direction: animation.direction || 'forward',
870
- timestamp: Date.now(),
871
- },
872
- },
873
- }));
874
- const duration = animation.duration || 1000;
875
- setTimeout(() => {
876
- setAnimationState((prev) => {
877
- const newEdgeAnimations = { ...prev.edgeAnimations };
878
- delete newEdgeAnimations[edgeId];
879
- return { ...prev, edgeAnimations: newEdgeAnimations };
880
- });
881
- }, duration);
882
- onEventProcessed?.(latestEvent);
883
- }
884
- }
885
- if (latestEvent.category === 'state') {
886
- const stateEvent = latestEvent.payload;
887
- const nodeId = stateEvent.nodeId;
888
- const newState = stateEvent.newState;
889
- if (nodeId && newState) {
890
- // Update the node's state
891
- setLocalNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, state: newState } : node)));
892
- // Trigger animation based on state
893
- const stateToAnimation = {
894
- processing: 'pulse',
895
- completed: 'flash',
896
- error: 'shake',
897
- };
898
- const animationType = stateToAnimation[newState];
899
- if (animationType) {
900
- const duration = animationType === 'pulse' ? 1500 : animationType === 'flash' ? 1000 : 500;
901
- setAnimationState((prev) => ({
902
- ...prev,
903
- nodeAnimations: {
904
- ...prev.nodeAnimations,
905
- [nodeId]: { type: animationType, duration, timestamp: Date.now() },
906
- },
907
- }));
908
- if (animationType !== 'pulse') {
909
- setTimeout(() => {
910
- setAnimationState((prev) => {
911
- const newNodeAnimations = { ...prev.nodeAnimations };
912
- delete newNodeAnimations[nodeId];
913
- return { ...prev, nodeAnimations: newNodeAnimations };
914
- });
915
- }, duration);
916
- }
917
- }
918
- onEventProcessed?.(latestEvent);
919
- }
920
- }
921
- if (latestEvent.category === 'node' && latestEvent.operation === 'create') {
922
- const nodeEvent = latestEvent.payload;
923
- const nodeId = nodeEvent.nodeId;
924
- if (nodeId) {
925
- setAnimationState((prev) => ({
926
- ...prev,
927
- nodeAnimations: {
928
- ...prev.nodeAnimations,
929
- [nodeId]: { type: 'entry', duration: 600, timestamp: Date.now() },
930
- },
931
- }));
932
- setTimeout(() => {
933
- setAnimationState((prev) => {
934
- const newNodeAnimations = { ...prev.nodeAnimations };
935
- delete newNodeAnimations[nodeId];
936
- return { ...prev, nodeAnimations: newNodeAnimations };
937
- });
938
- }, 600);
939
- onEventProcessed?.(latestEvent);
940
- }
941
- }
942
- }, [events, onEventProcessed]);
943
- // ============================================
944
- // ============================================
945
- // ============================================
946
- // XYFLOW CONVERSION
947
- // ============================================
948
- const xyflowNodesBase = useMemo(() => {
949
- const converted = convertToXYFlowNodes(localNodes, configuration, violations);
950
- return converted.map((node) => {
951
- const animation = animationState.nodeAnimations[node.id];
952
- // Apply any pending position changes
953
- const pendingPosition = editStateRef.current.positionChanges.get(node.id);
954
- // Apply any pending text changes
955
- const pendingText = editStateRef.current.textChanges.get(node.id);
956
- // Allow specific nodes to be draggable even when not in edit mode
957
- const isDraggable = editable || draggableNodeIds?.has(node.id);
958
- // When draggableNodeIds is provided, we need to explicitly control each node's draggability
959
- // because nodesDraggable will be true to allow the specific nodes to drag
960
- const hasDraggableNodeIds = draggableNodeIds && draggableNodeIds.size > 0;
961
- // React Flow v12 requires measured dimensions for controlled drag to work
962
- const dataWidth = typeof node.data?.width === 'number' ? node.data.width : undefined;
963
- const dataHeight = typeof node.data?.height === 'number' ? node.data.height : undefined;
964
- const nodeWidth = node.width ?? dataWidth ?? 200;
965
- const nodeHeight = node.height ?? dataHeight ?? 100;
966
- return {
967
- ...node,
968
- ...(pendingPosition ? { position: pendingPosition } : {}),
969
- selected: selectedNodeIds.has(node.id),
970
- // Explicitly set draggable for each node when using draggableNodeIds
971
- draggable: hasDraggableNodeIds ? isDraggable : (editable || false),
972
- // Set measured dimensions for React Flow v12 controlled drag
973
- measured: { width: nodeWidth, height: nodeHeight },
974
- data: {
975
- ...node.data,
976
- editable,
977
- pendingText,
978
- tooltipsEnabled: showTooltips,
979
- shiftKeyPressed,
980
- isHighlighted: highlightedNodeId === node.id,
981
- isActive: !activeNodeIds || activeNodeIds.length === 0 || activeNodeIds.includes(node.id),
982
- isHidden: hiddenNodeIds.has(node.id),
983
- ...(animation
984
- ? {
985
- animationType: animation.type,
986
- animationDuration: animation.duration,
987
- }
988
- : {}),
989
- },
990
- };
991
- });
992
- }, [localNodes, configuration, violations, animationState.nodeAnimations, editable, showTooltips, highlightedNodeId, activeNodeIds, editStateRef, shiftKeyPressed, selectedNodeIds, hiddenNodeIds, draggableNodeIds, textChangesVersion]);
993
- const baseNodesKey = useMemo(() => {
994
- return nodes
995
- .map((n) => n.id)
996
- .sort()
997
- .join(',');
998
- }, [nodes]);
999
- const baseEdgesKey = useMemo(() => {
1000
- return edges
1001
- .map((e) => e.id)
1002
- .sort()
1003
- .join(',');
1004
- }, [edges]);
1005
- // Local xyflow nodes state for dragging
1006
- const [xyflowLocalNodes, setXyflowLocalNodes] = useState(xyflowNodesBase);
1007
- // Keep xyflowNodesRef in sync for undo/redo access to current node state
1008
- useEffect(() => {
1009
- xyflowNodesRef.current = xyflowLocalNodes;
1010
- }, [xyflowLocalNodes]);
1011
- // Sync when base node IDs change
1012
- const prevBaseNodesKeyRef = useRef(baseNodesKey);
1013
- useEffect(() => {
1014
- if (prevBaseNodesKeyRef.current !== baseNodesKey) {
1015
- prevBaseNodesKeyRef.current = baseNodesKey;
1016
- setXyflowLocalNodes(xyflowNodesBase);
1017
- }
1018
- }, [baseNodesKey, xyflowNodesBase]);
1019
- // Sync selection state to local nodes when selectedNodeIds changes (edit mode only)
1020
- // Use useLayoutEffect to ensure sync happens before paint (prevents flash)
1021
- const prevSelectedNodeIdsRef = useRef(selectedNodeIds);
1022
- useLayoutEffect(() => {
1023
- if (!editable)
1024
- return;
1025
- if (prevSelectedNodeIdsRef.current === selectedNodeIds)
1026
- return;
1027
- prevSelectedNodeIdsRef.current = selectedNodeIds;
1028
- setXyflowLocalNodes((localNodes) => localNodes.map((localNode) => ({
1029
- ...localNode,
1030
- selected: selectedNodeIds.has(localNode.id),
1031
- })));
1032
- }, [editable, selectedNodeIds]);
1033
- // Sync hidden state to local nodes when hiddenNodeIds changes (edit mode only)
1034
- // This ensures isHidden is in sync when we switch back to xyflowNodesBase on exit
1035
- const prevHiddenNodeIdsRef = useRef(hiddenNodeIds);
1036
- useEffect(() => {
1037
- if (!editable)
1038
- return;
1039
- if (prevHiddenNodeIdsRef.current === hiddenNodeIds)
1040
- return;
1041
- prevHiddenNodeIdsRef.current = hiddenNodeIds;
1042
- setXyflowLocalNodes((nodes) => nodes.map((n) => ({
1043
- ...n,
1044
- data: { ...n.data, isHidden: hiddenNodeIds.has(n.id) },
1045
- })));
1046
- }, [editable, hiddenNodeIds]);
1047
- // Sync pending text changes to local nodes when textChangesVersion changes (edit mode only)
1048
- const prevTextChangesVersionRef = useRef(textChangesVersion);
1049
- useEffect(() => {
1050
- if (!editable)
1051
- return;
1052
- if (prevTextChangesVersionRef.current === textChangesVersion)
1053
- return;
1054
- prevTextChangesVersionRef.current = textChangesVersion;
1055
- setXyflowLocalNodes((nodes) => nodes.map((n) => {
1056
- const pendingText = editStateRef.current.textChanges.get(n.id);
1057
- return {
1058
- ...n,
1059
- data: { ...n.data, pendingText },
1060
- };
1061
- }));
1062
- }, [editable, textChangesVersion]);
1063
- // Also sync when entering edit mode or when base nodes change content
1064
- const prevEditableRef = useRef(editable);
1065
- useEffect(() => {
1066
- if (editable && !prevEditableRef.current) {
1067
- // Entering edit mode - sync positions
1068
- setXyflowLocalNodes(xyflowNodesBase);
1069
- // Reset ReactFlow's internal state for all nodes to prevent NaN errors
1070
- // This ensures ReactFlow remeasures nodes and updates drag tracking
1071
- setTimeout(() => {
1072
- xyflowNodesBase.forEach((node) => {
1073
- updateNodeInternals(node.id);
1074
- });
1075
- }, 0);
1076
- }
1077
- prevEditableRef.current = editable;
1078
- }, [editable, xyflowNodesBase, updateNodeInternals]);
1079
- // Use local nodes state when editable OR when we have draggable nodes (for drag visual updates)
1080
- const hasDraggableNodes = draggableNodeIds && draggableNodeIds.size > 0;
1081
- const xyflowNodes = (editable || hasDraggableNodes) ? xyflowLocalNodes : xyflowNodesBase;
1082
- // Sync xyflowLocalNodes from xyflowNodesBase when draggable nodes are present
1083
- // This ensures React Flow has the correct node state for drag calculations
1084
- const prevHasDraggableNodesRef = useRef(false);
1085
- useEffect(() => {
1086
- const hadDraggableNodes = prevHasDraggableNodesRef.current;
1087
- prevHasDraggableNodesRef.current = hasDraggableNodes || false;
1088
- // When draggable nodes first appear, sync local nodes and update internals
1089
- if (hasDraggableNodes && !hadDraggableNodes) {
1090
- setXyflowLocalNodes(xyflowNodesBase);
1091
- // Give React Flow time to render, then update internals
1092
- setTimeout(() => {
1093
- draggableNodeIds?.forEach((nodeId) => {
1094
- updateNodeInternals(nodeId);
1095
- });
1096
- }, 50);
1097
- }
1098
- }, [hasDraggableNodes, xyflowNodesBase, draggableNodeIds, updateNodeInternals]);
1099
- // Handle node drag to show alignment guides
1100
- const handleNodeDrag = useCallback((_event, node) => {
1101
- // Allow dragging if editable OR if this node is in draggableNodeIds
1102
- const canDrag = editable || draggableNodeIds?.has(node.id);
1103
- if (!canDrag)
1104
- return;
1105
- // Only show alignment guides in full edit mode
1106
- if (editable) {
1107
- const guides = detectAlignmentGuides(node.id, xyflowNodes);
1108
- setAlignmentGuides(guides);
1109
- }
1110
- }, [editable, draggableNodeIds, xyflowNodes, detectAlignmentGuides]);
1111
- // Clear guides when drag ends and notify parent
1112
- const handleNodeDragStop = useCallback((_event, node) => {
1113
- setAlignmentGuides([]);
1114
- // Call the callback if provided (for draggable nodes outside edit mode)
1115
- if (onNodeDragStopProp && node.position) {
1116
- onNodeDragStopProp(node.id, node.position);
1117
- }
1118
- }, [onNodeDragStopProp]);
1119
- // ============================================
1120
- // UNDO/REDO APPLY FUNCTIONS
1121
- // ============================================
1122
- // Apply undo - reverse the effects of history entries
1123
- const applyUndo = useCallback(() => {
1124
- const batch = undoFromStack();
1125
- if (!batch)
1126
- return;
1127
- // Process entries in reverse order for undo
1128
- for (const entry of batch.slice().reverse()) {
1129
- switch (entry.type) {
1130
- case 'position':
1131
- // Restore previous position
1132
- setXyflowLocalNodes((nodes) => nodes.map((n) => n.id === entry.nodeId
1133
- ? { ...n, position: entry.before }
1134
- : n));
1135
- // Update edit state
1136
- updateEditState((prev) => {
1137
- const newPositions = new Map(prev.positionChanges);
1138
- newPositions.set(entry.nodeId, entry.before);
1139
- return { ...prev, positionChanges: newPositions };
1140
- });
1141
- break;
1142
- case 'dimension':
1143
- // Restore previous dimensions
1144
- setXyflowLocalNodes((nodes) => nodes.map((n) => n.id === entry.nodeId
1145
- ? { ...n, width: entry.before.width, height: entry.before.height }
1146
- : n));
1147
- // Update edit state
1148
- updateEditState((prev) => {
1149
- const newDimensions = new Map(prev.dimensionChanges);
1150
- newDimensions.set(entry.nodeId, entry.before);
1151
- return { ...prev, dimensionChanges: newDimensions };
1152
- });
1153
- break;
1154
- case 'text':
1155
- // Restore previous text
1156
- // Update edit state
1157
- updateEditState((prev) => {
1158
- const newTextChanges = new Map(prev.textChanges);
1159
- newTextChanges.set(entry.nodeId, entry.before);
1160
- return { ...prev, textChanges: newTextChanges };
1161
- });
1162
- // Force re-render
1163
- setTextChangesVersion((v) => v + 1);
1164
- break;
1165
- case 'nodeDelete':
1166
- // Restore the deleted node
1167
- updateEditState((prev) => {
1168
- const newDeletedNodeIds = new Set(prev.deletedNodeIds);
1169
- newDeletedNodeIds.delete(entry.nodeId);
1170
- return { ...prev, deletedNodeIds: newDeletedNodeIds };
1171
- });
1172
- break;
1173
- case 'edgeCreate':
1174
- // Remove the created edge
1175
- setLocalEdges((edges) => edges.filter((e) => e.id !== entry.edge.id));
1176
- // Update edit state
1177
- updateEditState((prev) => ({
1178
- ...prev,
1179
- createdEdges: prev.createdEdges.filter((e) => e.id !== entry.edge.id),
1180
- }));
1181
- break;
1182
- case 'edgeDelete':
1183
- // Restore the deleted edge
1184
- setLocalEdges((edges) => [...edges, entry.edge]);
1185
- // Update edit state
1186
- updateEditState((prev) => ({
1187
- ...prev,
1188
- deletedEdges: prev.deletedEdges.filter((e) => e.id !== entry.edge.id),
1189
- }));
1190
- break;
1191
- }
1192
- }
1193
- }, [undoFromStack, updateEditState]);
1194
- // Apply redo - re-apply the effects of history entries
1195
- const applyRedo = useCallback(() => {
1196
- const batch = redoFromStack();
1197
- if (!batch)
1198
- return;
1199
- // Process entries in original order for redo
1200
- for (const entry of batch) {
1201
- switch (entry.type) {
1202
- case 'position':
1203
- // Apply new position
1204
- setXyflowLocalNodes((nodes) => nodes.map((n) => n.id === entry.nodeId
1205
- ? { ...n, position: entry.after }
1206
- : n));
1207
- // Update edit state
1208
- updateEditState((prev) => {
1209
- const newPositions = new Map(prev.positionChanges);
1210
- newPositions.set(entry.nodeId, entry.after);
1211
- return { ...prev, positionChanges: newPositions };
1212
- });
1213
- break;
1214
- case 'dimension':
1215
- // Apply new dimensions
1216
- setXyflowLocalNodes((nodes) => nodes.map((n) => n.id === entry.nodeId
1217
- ? { ...n, width: entry.after.width, height: entry.after.height }
1218
- : n));
1219
- // Update edit state
1220
- updateEditState((prev) => {
1221
- const newDimensions = new Map(prev.dimensionChanges);
1222
- newDimensions.set(entry.nodeId, entry.after);
1223
- return { ...prev, dimensionChanges: newDimensions };
1224
- });
1225
- break;
1226
- case 'text':
1227
- // Apply new text
1228
- // Update edit state
1229
- updateEditState((prev) => {
1230
- const newTextChanges = new Map(prev.textChanges);
1231
- newTextChanges.set(entry.nodeId, entry.after);
1232
- return { ...prev, textChanges: newTextChanges };
1233
- });
1234
- // Force re-render
1235
- setTextChangesVersion((v) => v + 1);
1236
- break;
1237
- case 'nodeDelete':
1238
- // Re-delete the node
1239
- updateEditState((prev) => {
1240
- const newDeletedNodeIds = new Set(prev.deletedNodeIds);
1241
- newDeletedNodeIds.add(entry.nodeId);
1242
- return { ...prev, deletedNodeIds: newDeletedNodeIds };
1243
- });
1244
- break;
1245
- case 'edgeCreate':
1246
- // Re-create the edge
1247
- setLocalEdges((edges) => [...edges, entry.edge]);
1248
- // Update edit state
1249
- updateEditState((prev) => ({
1250
- ...prev,
1251
- createdEdges: [
1252
- ...prev.createdEdges,
1253
- {
1254
- id: entry.edge.id,
1255
- from: entry.edge.from,
1256
- to: entry.edge.to,
1257
- type: entry.edge.type,
1258
- sourceHandle: entry.edge.sourceHandle,
1259
- targetHandle: entry.edge.targetHandle,
1260
- },
1261
- ],
1262
- }));
1263
- break;
1264
- case 'edgeDelete':
1265
- // Re-delete the edge
1266
- setLocalEdges((edges) => edges.filter((e) => e.id !== entry.edge.id));
1267
- // Update edit state
1268
- updateEditState((prev) => ({
1269
- ...prev,
1270
- deletedEdges: [
1271
- ...prev.deletedEdges,
1272
- {
1273
- id: entry.edge.id,
1274
- from: entry.edge.from,
1275
- to: entry.edge.to,
1276
- type: entry.edge.type,
1277
- },
1278
- ],
1279
- }));
1280
- break;
1281
- }
1282
- }
1283
- }, [redoFromStack, updateEditState]);
1284
- // Keyboard shortcuts for undo/redo and delete
1285
- useEffect(() => {
1286
- if (!editable)
1287
- return;
1288
- const handleKeyDown = (e) => {
1289
- // Cmd+Z (Mac) or Ctrl+Z (Windows/Linux) for undo
1290
- if ((e.metaKey || e.ctrlKey) && e.key === 'z') {
1291
- e.preventDefault();
1292
- if (e.shiftKey) {
1293
- // Cmd+Shift+Z for redo
1294
- applyRedo();
1295
- }
1296
- else {
1297
- applyUndo();
1298
- }
1299
- }
1300
- // Ctrl+Y for redo (Windows)
1301
- if (e.ctrlKey && e.key === 'y') {
1302
- e.preventDefault();
1303
- applyRedo();
1304
- }
1305
- // Delete or Backspace to delete selected nodes
1306
- if (e.key === 'Delete' || e.key === 'Backspace') {
1307
- // Only delete if not editing text (check if active element is an input/textarea)
1308
- const activeElement = document.activeElement;
1309
- const isEditingText = activeElement?.tagName === 'INPUT' || activeElement?.tagName === 'TEXTAREA';
1310
- if (!isEditingText) {
1311
- e.preventDefault();
1312
- handleDeleteSelectedNodes();
1313
- }
1314
- }
1315
- };
1316
- window.addEventListener('keydown', handleKeyDown);
1317
- return () => window.removeEventListener('keydown', handleKeyDown);
1318
- }, [editable, applyUndo, applyRedo, handleDeleteSelectedNodes]);
1319
- // Set undo/redo functions in ref for outer component access
1320
- useEffect(() => {
1321
- undoRedoFunctionsRef.current = {
1322
- applyUndo,
1323
- applyRedo,
1324
- };
1325
- }, [applyUndo, applyRedo, undoRedoFunctionsRef]);
1326
- // Handle node changes (drag and resize events)
1327
- const handleNodesChange = useCallback((changes) => {
1328
- const hasDraggableNodes = draggableNodeIds && draggableNodeIds.size > 0;
1329
- // In edit mode, apply all changes
1330
- // When we have draggable nodes, apply position changes for those nodes
1331
- if (!editable && !hasDraggableNodes)
1332
- return;
1333
- // Filter out selection changes - we manage selection ourselves via selectedNodeIds
1334
- let changesToApply = changes.filter((change) => change.type !== 'select');
1335
- // When not in full edit mode but have draggable nodes,
1336
- // only apply position changes for draggable nodes
1337
- if (!editable && hasDraggableNodes) {
1338
- changesToApply = changesToApply.filter((change) => change.type === 'position' &&
1339
- 'id' in change &&
1340
- draggableNodeIds.has(change.id));
1341
- }
1342
- // Only apply changes if there are changes to apply
1343
- if (changesToApply.length > 0) {
1344
- setXyflowLocalNodes((nds) => {
1345
- // For group nodes (ending in :__group__), also move child nodes with same prefix
1346
- const groupPositionChanges = changesToApply.filter((change) => change.type === 'position' &&
1347
- 'id' in change &&
1348
- typeof change.id === 'string' &&
1349
- change.id.endsWith(':__group__') &&
1350
- 'position' in change &&
1351
- change.position !== undefined);
1352
- // Calculate deltas for each group
1353
- const groupDeltas = new Map();
1354
- for (const change of groupPositionChanges) {
1355
- const canvasPrefix = change.id.replace(':__group__', ':');
1356
- const currentNode = nds.find((n) => n.id === change.id);
1357
- if (currentNode && change.position) {
1358
- const dx = change.position.x - currentNode.position.x;
1359
- const dy = change.position.y - currentNode.position.y;
1360
- groupDeltas.set(canvasPrefix, { dx, dy });
1361
- }
1362
- }
1363
- // Apply changes but preserve our selection state
1364
- let updated = applyNodeChanges(changesToApply, nds);
1365
- // Also apply deltas to child nodes of moved groups
1366
- if (groupDeltas.size > 0) {
1367
- updated = updated.map((node) => {
1368
- // Check if this node belongs to a moved group (but isn't the group itself)
1369
- for (const [prefix, delta] of groupDeltas) {
1370
- if (node.id.startsWith(prefix) && !node.id.endsWith(':__group__')) {
1371
- return {
1372
- ...node,
1373
- position: {
1374
- x: node.position.x + delta.dx,
1375
- y: node.position.y + delta.dy,
1376
- },
1377
- };
1378
- }
1379
- }
1380
- return node;
1381
- });
1382
- }
1383
- // Restore selection state from our managed selectedNodeIds
1384
- return updated.map((node) => ({
1385
- ...node,
1386
- selected: selectedNodeIds.has(node.id),
1387
- }));
1388
- });
1389
- }
1390
- // Capture positions at drag START for accurate undo "before" values
1391
- const dragStartChanges = changes.filter((change) => change.type === 'position' &&
1392
- 'id' in change &&
1393
- 'dragging' in change &&
1394
- change.dragging === true);
1395
- // For each node that just started dragging, capture its current position
1396
- for (const change of dragStartChanges) {
1397
- if (!dragStartPositionsRef.current.has(change.id)) {
1398
- const currentNode = xyflowNodesRef.current.find((n) => n.id === change.id);
1399
- if (currentNode) {
1400
- dragStartPositionsRef.current.set(change.id, {
1401
- x: currentNode.position.x,
1402
- y: currentNode.position.y,
1403
- });
1404
- }
1405
- }
1406
- }
1407
- // Track position changes on drag END
1408
- const positionChanges = changes.filter((change) => change.type === 'position' &&
1409
- 'id' in change &&
1410
- 'position' in change &&
1411
- change.position !== undefined &&
1412
- 'dragging' in change &&
1413
- change.dragging === false);
1414
- // Note: Dimension changes are tracked via onResizeEnd callback in GraphEditContext,
1415
- // not through onNodesChange (which only fires with resizing=true during drag).
1416
- if (positionChanges.length > 0) {
1417
- // Use positions captured at drag start for accurate undo
1418
- const historyEntries = [];
1419
- for (const change of positionChanges) {
1420
- const beforePos = dragStartPositionsRef.current.get(change.id) ?? { x: 0, y: 0 };
1421
- historyEntries.push({
1422
- type: 'position',
1423
- nodeId: change.id,
1424
- before: beforePos,
1425
- after: {
1426
- x: Math.round(change.position.x),
1427
- y: Math.round(change.position.y),
1428
- },
1429
- });
1430
- // Clear the drag start position for this node
1431
- dragStartPositionsRef.current.delete(change.id);
1432
- }
1433
- pushHistory(historyEntries);
1434
- updateEditState((prev) => {
1435
- const newPositions = new Map(prev.positionChanges);
1436
- for (const change of positionChanges) {
1437
- newPositions.set(change.id, {
1438
- x: Math.round(change.position.x),
1439
- y: Math.round(change.position.y),
1440
- });
1441
- }
1442
- return { ...prev, positionChanges: newPositions };
1443
- });
1444
- }
1445
- }, [editable, updateEditState, selectedNodeIds, draggableNodeIds, pushHistory]);
1446
- // Build lookup map for scenario edge sequence numbers
1447
- const scenarioEdgeSequenceMap = useMemo(() => {
1448
- const map = new Map();
1449
- if (scenarioEdges && showSequenceLabels) {
1450
- for (const se of scenarioEdges) {
1451
- map.set(`${se.fromSpan}->${se.toSpan}`, se.sequenceNumber);
1452
- }
1453
- }
1454
- return map;
1455
- }, [scenarioEdges, showSequenceLabels]);
1456
- const xyflowEdgesBase = useMemo(() => {
1457
- const converted = convertToXYFlowEdges(edges, configuration, violations);
1458
- // Filter out edges connected to inactive or hidden nodes
1459
- const filtered = converted.filter(edge => {
1460
- // Filter by hidden nodes (shift-clicked)
1461
- const sourceHidden = hiddenNodeIds.has(edge.source);
1462
- const targetHidden = hiddenNodeIds.has(edge.target);
1463
- if (sourceHidden || targetHidden) {
1464
- return false;
1465
- }
1466
- // Filter by active nodes (scenario playback)
1467
- if (activeNodeIds && activeNodeIds.length > 0) {
1468
- const sourceActive = activeNodeIds.includes(edge.source);
1469
- const targetActive = activeNodeIds.includes(edge.target);
1470
- return sourceActive && targetActive;
1471
- }
1472
- return true;
1473
- });
1474
- const mappedEdges = filtered.map((edge) => {
1475
- const animation = animationState.edgeAnimations[edge.id];
1476
- const isSelected = selectedEdgeIds.has(edge.id);
1477
- // Look up sequence number from scenario edges
1478
- const edgeKey = `${edge.source}->${edge.target}`;
1479
- const sequenceNumber = scenarioEdgeSequenceMap.get(edgeKey);
1480
- return {
1481
- ...edge,
1482
- data: {
1483
- ...edge.data,
1484
- // Inject sequence number into edge data for label rendering
1485
- ...(sequenceNumber !== undefined
1486
- ? {
1487
- data: {
1488
- ...edge.data?.data,
1489
- sequenceNumber,
1490
- },
1491
- // Configure label to show sequence number
1492
- typeDefinition: {
1493
- ...edge.data?.typeDefinition,
1494
- label: {
1495
- field: 'sequenceNumber',
1496
- position: 'middle',
1497
- },
1498
- },
1499
- }
1500
- : {}),
1501
- tooltipsEnabled: showTooltips,
1502
- shiftKeyPressed,
1503
- ...(animation
1504
- ? {
1505
- animationType: animation.type,
1506
- animationDuration: animation.duration,
1507
- animationDirection: animation.direction,
1508
- }
1509
- : {}),
1510
- },
1511
- // Preserve edge zIndex from converter (50 = above groups, below nodes)
1512
- // Selected edges get elevated to 1000
1513
- zIndex: isSelected ? 1000 : edge.zIndex,
1514
- };
1515
- });
1516
- // Sort edges so selected ones appear last (on top in SVG)
1517
- return mappedEdges.sort((a, b) => {
1518
- const aSelected = selectedEdgeIds.has(a.id);
1519
- const bSelected = selectedEdgeIds.has(b.id);
1520
- if (aSelected && !bSelected)
1521
- return 1; // a comes after b (rendered on top)
1522
- if (!aSelected && bSelected)
1523
- return -1; // b comes after a (rendered on top)
1524
- return 0; // maintain original order
1525
- });
1526
- }, [edges, configuration, violations, animationState.edgeAnimations, showTooltips, selectedEdgeIds, shiftKeyPressed, activeNodeIds, hiddenNodeIds, scenarioEdgeSequenceMap]);
1527
- // ELK layout for circuit-board style edge routing
1528
- const { edgePaths: elkEdgePaths, edgeLabelPositions: elkLabelPositions } = useElkLayout(xyflowNodesBase, xyflowEdgesBase, {
1529
- enabled: elkLayout?.enabled ?? false,
1530
- routingStyle: elkLayout?.routingStyle ?? 'orthogonal',
1531
- edgeSpacing: elkLayout?.edgeSpacing ?? 5,
1532
- edgeNodeSpacing: elkLayout?.edgeNodeSpacing ?? 10,
1533
- preserveNodePositions: true,
1534
- });
1535
- // Apply ELK paths to edges when ELK layout is enabled
1536
- const xyflowEdgesWithElk = useMemo(() => {
1537
- if (!elkLayout?.enabled) {
1538
- return xyflowEdgesBase;
1539
- }
1540
- // Hide edges until ELK has computed paths to prevent flash of default edge routing
1541
- if (elkEdgePaths.size === 0) {
1542
- return [];
1543
- }
1544
- return applyElkPathsToEdges(xyflowEdgesBase, elkEdgePaths, elkLabelPositions);
1545
- }, [elkLayout?.enabled, xyflowEdgesBase, elkEdgePaths, elkLabelPositions]);
1546
- // Local xyflow edges state for reconnection
1547
- const [xyflowLocalEdges, setXyflowLocalEdges] = useState(xyflowEdgesWithElk);
1548
- // Create a stable key for ELK paths to detect when they actually change
1549
- const elkPathsKey = useMemo(() => {
1550
- if (!elkLayout?.enabled || elkEdgePaths.size === 0)
1551
- return '';
1552
- // Create a key from edge IDs and their full paths
1553
- return Array.from(elkEdgePaths.entries())
1554
- .map(([id, path]) => `${id}:${path}`)
1555
- .join('|');
1556
- }, [elkLayout?.enabled, elkEdgePaths]);
1557
- // Sync local edges when base edges or ELK paths change
1558
- const prevSyncKeyRef = useRef({ baseEdgesKey, elkPathsKey });
1559
- useEffect(() => {
1560
- const keyChanged = prevSyncKeyRef.current.baseEdgesKey !== baseEdgesKey ||
1561
- prevSyncKeyRef.current.elkPathsKey !== elkPathsKey;
1562
- if (keyChanged && xyflowEdgesWithElk.length > 0) {
1563
- prevSyncKeyRef.current = { baseEdgesKey, elkPathsKey };
1564
- setXyflowLocalEdges(xyflowEdgesWithElk);
1565
- }
1566
- }, [baseEdgesKey, elkPathsKey, xyflowEdgesWithElk]);
1567
- // Set the reset visual state function for use by resetEditState
1568
- // This resets both nodes and edges to their original state
1569
- useEffect(() => {
1570
- resetVisualStateRef.current = () => {
1571
- setXyflowLocalNodes(xyflowNodesBase);
1572
- setXyflowLocalEdges(xyflowEdgesWithElk);
1573
- // Notify parent that changes have been cleared
1574
- onPendingChangesChange?.(false);
1575
- };
1576
- }, [xyflowNodesBase, xyflowEdgesWithElk, onPendingChangesChange]);
1577
- // Set the reset text changes version function for use by resetEditState
1578
- useEffect(() => {
1579
- resetTextChangesVersionRef.current = () => {
1580
- setTextChangesVersion(0);
1581
- };
1582
- }, []);
1583
- // Use local edges in edit mode, base edges otherwise
1584
- const xyflowEdges = editable ? xyflowLocalEdges : xyflowEdgesWithElk;
1585
- // Handle edge changes (selection, reconnection, etc.)
1586
- const handleEdgesChange = useCallback((changes) => {
1587
- if (!editable)
1588
- return;
1589
- setXyflowLocalEdges((eds) => applyEdgeChanges(changes, eds));
1590
- }, [editable]);
1591
- // Fit view on mount and structure changes
1592
- useEffect(() => {
1593
- // Skip default fitView if we have specific nodes to fit to
1594
- if (fitViewToNodeIds && fitViewToNodeIds.length > 0) {
1595
- return;
1596
- }
1597
- // Skip delayed fitView if we have a pre-calculated initial viewport
1598
- if (initialViewport) {
1599
- return;
1600
- }
1601
- const timeoutId = setTimeout(() => {
1602
- fitView({
1603
- padding: 0.2,
1604
- includeHiddenNodes: false,
1605
- minZoom: 0.1,
1606
- maxZoom: 1.5,
1607
- duration: fitViewDuration,
1608
- });
1609
- }, 100);
1610
- return () => clearTimeout(timeoutId);
1611
- }, [baseNodesKey, baseEdgesKey, fitView, fitViewDuration, fitViewToNodeIds, initialViewport]);
1612
- // Create a stable key from fitViewToNodeIds for dependency tracking
1613
- const fitViewToNodeIdsKey = useMemo(() => (fitViewToNodeIds && fitViewToNodeIds.length > 0 ? fitViewToNodeIds.slice().sort().join(',') : ''), [fitViewToNodeIds]);
1614
- // Fit view to specific nodes when fitViewToNodeIds changes
1615
- useEffect(() => {
1616
- if (!fitViewToNodeIdsKey || !fitViewToNodeIds || fitViewToNodeIds.length === 0) {
1617
- return;
1618
- }
1619
- // Use a longer timeout to ensure nodes are rendered and measured
1620
- const timeoutId = setTimeout(() => {
1621
- // Get actual node objects from React Flow's internal state
1622
- const allNodes = getNodes();
1623
- const nodeIdsSet = new Set(fitViewToNodeIds);
1624
- const nodesToFit = allNodes.filter((node) => nodeIdsSet.has(node.id));
1625
- if (nodesToFit.length > 0) {
1626
- // Calculate bounding box from node positions
1627
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
1628
- for (const node of nodesToFit) {
1629
- const width = node.measured?.width ?? node.width ?? 200;
1630
- const height = node.measured?.height ?? node.height ?? 100;
1631
- minX = Math.min(minX, node.position.x);
1632
- minY = Math.min(minY, node.position.y);
1633
- maxX = Math.max(maxX, node.position.x + width);
1634
- maxY = Math.max(maxY, node.position.y + height);
1635
- }
1636
- const bounds = {
1637
- x: minX,
1638
- y: minY,
1639
- width: maxX - minX,
1640
- height: maxY - minY,
1641
- };
1642
- fitBounds(bounds, {
1643
- padding: fitViewPadding,
1644
- duration: fitViewDuration,
1645
- });
1646
- }
1647
- }, 150);
1648
- return () => clearTimeout(timeoutId);
1649
- }, [fitViewToNodeIdsKey, fitViewToNodeIds, fitViewPadding, fitView, fitViewDuration, getNodes, fitBounds]);
1650
- // ============================================
1651
- // RENDER
1652
- // ============================================
1653
- return (_jsxs(GraphEditProvider, { value: graphEditContextValue, children: [_jsxs(ReactFlow, { nodes: xyflowNodes, edges: xyflowEdges, nodeTypes: nodeTypes, edgeTypes: edgeTypes, minZoom: 0.1, maxZoom: 4, defaultViewport: initialViewport, defaultEdgeOptions: { type: 'custom' }, onEdgeClick: onEdgeClick, onNodeClick: onNodeClick, onNodeDoubleClick: onNodeDoubleClick, onNodeDrag: handleNodeDrag, onNodeDragStop: handleNodeDragStop, proOptions: { hideAttribution: true }, nodesDraggable: editable || (draggableNodeIds && draggableNodeIds.size > 0), elementsSelectable: editable, selectNodesOnDrag: false, nodesConnectable: editable, edgesReconnectable: editable, reconnectRadius: 25, elevateEdgesOnSelect: true, onNodesChange: handleNodesChange, onEdgesChange: handleEdgesChange, onConnect: handleConnect, onReconnectStart: handleReconnectStart, onReconnect: handleReconnect, onReconnectEnd: handleReconnectEnd, onPaneClick: onPaneClick, onSelectionChange: handleSelectionChange, panOnDrag: !editable, panOnScroll: true, zoomOnScroll: false, zoomOnPinch: true, zoomOnDoubleClick: false, selectionOnDrag: false, selectionKeyCode: null, multiSelectionKeyCode: "Shift", children: [showBackground && (_jsx(Background, { color: backgroundVariant === 'dots' ? theme.colors.border : theme.colors.textMuted, gap: backgroundGap ?? (backgroundVariant === 'dots' ? 16 : 50), size: backgroundVariant === 'dots' ? 1 : 0.5, variant: backgroundVariant === 'dots'
1654
- ? BackgroundVariant.Dots
1655
- : backgroundVariant === 'lines'
1656
- ? BackgroundVariant.Lines
1657
- : BackgroundVariant.Cross })), showControls && _jsx(Controls, { showZoom: true, showFitView: true, showInteractive: true }), showMinimap && (_jsx(MiniMap, { nodeColor: (node) => {
1658
- const nodeData = node.data;
1659
- return nodeData?.typeDefinition?.color || theme.colors.secondary;
1660
- }, nodeBorderRadius: 2, pannable: true, zoomable: true })), showCenterIndicator && _jsx(CenterIndicator, { color: theme.colors.textMuted }), editable && alignmentGuides.length > 0 && (_jsx(AlignmentGuidesOverlay, { guides: alignmentGuides, color: theme.colors.primary }))] }, `${baseNodesKey}-${baseEdgesKey}`), pendingConnection && (_jsxs("div", { style: {
1661
- position: 'absolute',
1662
- top: '50%',
1663
- left: '50%',
1664
- transform: 'translate(-50%, -50%)',
1665
- backgroundColor: theme.colors.background,
1666
- color: theme.colors.text,
1667
- borderRadius: '8px',
1668
- boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
1669
- padding: '16px',
1670
- minWidth: '200px',
1671
- zIndex: 1000,
1672
- border: `1px solid ${theme.colors.border}`,
1673
- }, children: [_jsx("div", { style: { fontWeight: 'bold', marginBottom: '12px', fontSize: '14px' }, children: "Select Edge Type" }), _jsxs("div", { style: { fontSize: '12px', color: theme.colors.textSecondary, marginBottom: '12px' }, children: [pendingConnection.from, " \u2192 ", pendingConnection.to] }), _jsx("div", { style: { display: 'flex', flexDirection: 'column', gap: '8px' }, children: pendingConnection.validTypes.map((type) => {
1674
- const typeDefinition = configuration.edgeTypes[type];
1675
- return (_jsx("button", { onClick: () => handleEdgeTypeSelect(type), style: {
1676
- padding: '8px 12px',
1677
- backgroundColor: typeDefinition?.color || theme.colors.secondary,
1678
- color: theme.colors.background,
1679
- border: 'none',
1680
- borderRadius: '4px',
1681
- cursor: 'pointer',
1682
- fontSize: '12px',
1683
- fontWeight: 'bold',
1684
- textAlign: 'left',
1685
- }, children: type }, type));
1686
- }) }), _jsx("button", { onClick: handleCancelEdgeTypePicker, style: {
1687
- marginTop: '12px',
1688
- width: '100%',
1689
- padding: '8px 12px',
1690
- backgroundColor: theme.colors.surface,
1691
- color: theme.colors.textSecondary,
1692
- border: `1px solid ${theme.colors.border}`,
1693
- borderRadius: '4px',
1694
- cursor: 'pointer',
1695
- fontSize: '12px',
1696
- }, children: "Cancel" })] }))] }));
1697
- };
1698
- /**
1699
- * Convert canvas to legacy configuration format for internal use
1700
- *
1701
- * Color Contract:
1702
- * - scopeColor: Used as border color (from .scopes.canvas)
1703
- * - spanColor: Used as fill color (from .spans.canvas based on workflow context)
1704
- */
1705
- function useCanvasToLegacy(canvas, library, spansCanvas, scopesCanvas, workflowSpanPattern) {
1706
- return useMemo(() => {
1707
- if (!canvas)
1708
- return null;
1709
- const { nodes, edges } = CanvasConverter.canvasToGraph(canvas);
1710
- // Fix edge types: published core doesn't support top-level edgeType yet
1711
- // Re-map edge types from canvas to support both old (pv.edgeType) and new (edgeType) formats
1712
- const canvasEdgeMap = new Map(canvas.edges?.map(e => [e.id, e]) || []);
1713
- for (const edge of edges) {
1714
- const canvasEdge = canvasEdgeMap.get(edge.id);
1715
- if (canvasEdge) {
1716
- // Support both top-level edgeType (new) and pv.edgeType (deprecated)
1717
- const edgeType = canvasEdge.edgeType || canvasEdge.pv?.edgeType || 'default';
1718
- edge.type = edgeType;
1719
- }
1720
- }
1721
- // Build scope color map from scopes canvas (for fill colors)
1722
- const scopeColorMap = buildScopeColorMap(scopesCanvas);
1723
- // Build span color map from spans canvas (for border colors in workflow context)
1724
- const spanColorMap = buildSpanColorMap(spansCanvas);
1725
- // Resolve span color for current workflow context
1726
- const spanColor = resolveEventSpanColor(workflowSpanPattern, spanColorMap);
1727
- // Inject scope and span colors into nodes
1728
- // - scopeColor: fill/background color (from otel.scope → scopes.canvas)
1729
- // - spanColor: border color in workflow context (from workflow spanPattern → spans.canvas)
1730
- for (const node of nodes) {
1731
- const otel = node.data?.otel;
1732
- const scope = otel?.scope;
1733
- // Determine scope color (for fill/background)
1734
- let nodeScopeColor;
1735
- if (scope && scopeColorMap[scope]) {
1736
- // Node has a scope with a defined color - use it as fill color
1737
- nodeScopeColor = scopeColorMap[scope];
1738
- }
1739
- else {
1740
- // Draft nodes or nodes without scope get the draft color
1741
- nodeScopeColor = DRAFT_NODE_COLOR;
1742
- }
1743
- // Inject both scope and span colors
1744
- node.data = {
1745
- ...node.data,
1746
- scopeColor: nodeScopeColor,
1747
- spanColor: workflowSpanPattern ? spanColor : DEFAULT_SPAN_COLOR,
1748
- };
1749
- }
1750
- // Build GraphConfiguration from canvas
1751
- const nodeTypes = {};
1752
- const edgeTypes = {};
1753
- // Add node types from canvas nodeTypes
1754
- if (canvas.nodeTypes) {
1755
- for (const [id, def] of Object.entries(canvas.nodeTypes)) {
1756
- nodeTypes[id] = {
1757
- description: def.description,
1758
- shape: def.shape || 'rectangle',
1759
- icon: def.icon,
1760
- color: def.color,
1761
- dataSchema: {},
1762
- };
1763
- }
1764
- }
1765
- // Then extract node types from canvas nodes (for nodes that define their own types)
1766
- // Only process standard canvas nodes (OTEL nodes have their types defined separately)
1767
- for (const node of canvas.nodes || []) {
1768
- if (!isStandardCanvasNode(node)) {
1769
- continue;
1770
- }
1771
- const vv = node.pv;
1772
- const nodeType = vv?.nodeType || node.type;
1773
- if (!nodeTypes[nodeType]) {
1774
- // Color priority: vv.fill > node.color > vv.states.idle.color
1775
- const fillColor = vv?.fill ||
1776
- (typeof node.color === 'string' ? node.color : undefined) ||
1777
- vv?.states?.idle?.color;
1778
- // Derive description from text content (everything after line 1) for text nodes
1779
- let nodeDescription = `${nodeType} node`;
1780
- if (node.type === 'text' && 'text' in node) {
1781
- const lines = node.text.split('\n');
1782
- const descFromText = lines.slice(1).join('\n').trim();
1783
- if (descFromText) {
1784
- nodeDescription = descFromText;
1785
- }
1786
- }
1787
- nodeTypes[nodeType] = {
1788
- description: nodeDescription,
1789
- shape: vv?.shape || 'rectangle',
1790
- icon: vv?.icon,
1791
- color: fillColor,
1792
- stroke: vv?.stroke,
1793
- size: { width: node.width, height: node.height },
1794
- dataSchema: vv?.dataSchema || {},
1795
- states: vv?.states,
1796
- layout: vv?.layout,
1797
- };
1798
- }
1799
- }
1800
- // Extract edge types from canvas edgeTypes (new) or pv.edgeTypes (deprecated)
1801
- const canvasEdgeTypes = canvas.edgeTypes || canvas.pv?.edgeTypes;
1802
- if (canvasEdgeTypes) {
1803
- for (const [id, def] of Object.entries(canvasEdgeTypes)) {
1804
- edgeTypes[id] = {
1805
- style: def.style || 'solid',
1806
- color: def.color,
1807
- width: def.width,
1808
- directed: def.directed,
1809
- animation: def.animation,
1810
- label: def.labelConfig,
1811
- };
1812
- }
1813
- }
1814
- // Build allowed connections from edges
1815
- const allowedConnections = [];
1816
- for (const edge of canvas.edges || []) {
1817
- // Support both top-level edgeType (new) and pv.edgeType (deprecated)
1818
- const edgeType = edge.edgeType || edge.pv?.edgeType || 'default';
1819
- // Ensure edge type exists
1820
- if (!edgeTypes[edgeType]) {
1821
- edgeTypes[edgeType] = {
1822
- style: 'solid',
1823
- color: typeof edge.color === 'string' ? edge.color : undefined,
1824
- directed: true,
1825
- };
1826
- }
1827
- // Store allowed connections using node IDs
1828
- allowedConnections.push({
1829
- from: edge.fromNode,
1830
- to: edge.toNode,
1831
- via: edgeType,
1832
- });
1833
- }
1834
- // Build display config with required layout field
1835
- const display = canvas.display
1836
- ? {
1837
- layout: canvas.display.layout || 'manual',
1838
- theme: canvas.display.theme,
1839
- animations: canvas.display.animations,
1840
- }
1841
- : { layout: 'manual' };
1842
- const configuration = {
1843
- metadata: {
1844
- name: canvas.name || 'Untitled',
1845
- description: canvas.description,
1846
- },
1847
- nodeTypes,
1848
- edgeTypes,
1849
- allowedConnections,
1850
- display,
1851
- };
1852
- return { configuration, nodes, edges };
1853
- }, [canvas, library, spansCanvas, scopesCanvas, workflowSpanPattern]);
1854
- }
1855
- /**
1856
- * Core graph visualization component using xyflow.
1857
- *
1858
- * Accepts an ExtendedCanvas document for rendering.
1859
- *
1860
- * When `editable` is true, the component manages its own edit state internally.
1861
- * Use the ref to get pending changes when the user wants to save:
1862
- *
1863
- * ```tsx
1864
- * <GraphRenderer canvas={myCanvas} />
1865
- *
1866
- * // With edit mode
1867
- * const graphRef = useRef<GraphRendererHandle>(null);
1868
- * <GraphRenderer
1869
- * ref={graphRef}
1870
- * canvas={myCanvas}
1871
- * editable={isEditMode}
1872
- * onPendingChangesChange={setHasUnsavedChanges}
1873
- * />
1874
- * ```
1875
- */
1876
- export const GraphRenderer = forwardRef((props, ref) => {
1877
- const { canvas, library, spansCanvas, scopesCanvas, workflowSpanPattern, className, width = '100%', height = '100%', containerWidth, containerHeight, elkLayout, scenarioEdges, showSequenceLabels, } = props;
1878
- const { theme } = useTheme();
1879
- const containerRef = useRef(null);
1880
- const [portalTarget, setPortalTarget] = useState(null);
1881
- // Set portal target after mount (ref is null during first render)
1882
- useEffect(() => {
1883
- setPortalTarget(containerRef.current);
1884
- }, []);
1885
- // Convert canvas to internal format (merging library types if provided)
1886
- // Also inject scope colors (for border) and span colors (for fill)
1887
- const canvasData = useCanvasToLegacy(canvas, library, spansCanvas, scopesCanvas, workflowSpanPattern);
1888
- // Calculate initial viewport if container dimensions are provided
1889
- // This avoids the "zoom in then animate out" effect on initial render
1890
- const initialViewport = useMemo(() => {
1891
- if (!containerWidth || !containerHeight || !canvas) {
1892
- return undefined;
1893
- }
1894
- const bounds = getCanvasBounds(canvas);
1895
- return calculateInitialViewport(bounds, containerWidth, containerHeight, {
1896
- padding: 0.2,
1897
- minZoom: 0.1,
1898
- maxZoom: 1.5,
1899
- });
1900
- }, [canvas, containerWidth, containerHeight]);
1901
- // Debug: Log canvas data to help diagnose disappearing edges
1902
- if (process.env.NODE_ENV === 'development') {
1903
- console.log('[GraphRenderer] Canvas data:', {
1904
- hasCanvas: !!canvas,
1905
- canvasEdgesCount: canvas?.edges?.length ?? 0,
1906
- hasCanvasData: !!canvasData,
1907
- convertedNodesCount: canvasData?.nodes.length ?? 0,
1908
- convertedEdgesCount: canvasData?.edges.length ?? 0,
1909
- });
1910
- }
1911
- // Internal edit state ref - must be before any conditional returns
1912
- const editStateRef = useRef(createEmptyEditState());
1913
- // Ref to hold the reset visual state function - will be set after xyflowLocalNodes is defined
1914
- const resetVisualStateRef = useRef(null);
1915
- // Ref to hold the reset text changes version function - will be set by inner component
1916
- const resetTextChangesVersionRef = useRef(null);
1917
- // Undo/redo management
1918
- const { canUndo: canUndoState, canRedo: canRedoState, undo: undoFromStack, redo: redoFromStack, pushHistory, clearHistory, } = useUndoRedo();
1919
- // Ref to hold undo/redo apply functions - will be set by inner component
1920
- const undoRedoFunctionsRef = useRef(null);
1921
- // Expose imperative handle - must be before any conditional returns
1922
- useImperativeHandle(ref, () => ({
1923
- getPendingChanges: () => {
1924
- const state = editStateRef.current;
1925
- return {
1926
- positionChanges: Array.from(state.positionChanges.entries()).map(([nodeId, position]) => ({
1927
- nodeId,
1928
- position,
1929
- })),
1930
- dimensionChanges: Array.from(state.dimensionChanges.entries()).map(([nodeId, dimensions]) => ({
1931
- nodeId,
1932
- dimensions,
1933
- })),
1934
- textChanges: Array.from(state.textChanges.entries()).map(([nodeId, text]) => ({
1935
- nodeId,
1936
- text,
1937
- })),
1938
- nodeUpdates: Array.from(state.nodeUpdates.entries()).map(([nodeId, updates]) => ({
1939
- nodeId,
1940
- updates,
1941
- })),
1942
- deletedNodeIds: Array.from(state.deletedNodeIds),
1943
- createdEdges: state.createdEdges.map((e) => ({
1944
- from: e.from,
1945
- to: e.to,
1946
- type: e.type,
1947
- sourceHandle: e.sourceHandle,
1948
- targetHandle: e.targetHandle,
1949
- })),
1950
- deletedEdges: state.deletedEdges.map((e) => ({ from: e.from, to: e.to, type: e.type })),
1951
- hasChanges: state.positionChanges.size > 0 ||
1952
- state.dimensionChanges.size > 0 ||
1953
- state.textChanges.size > 0 ||
1954
- state.nodeUpdates.size > 0 ||
1955
- state.deletedNodeIds.size > 0 ||
1956
- state.createdEdges.length > 0 ||
1957
- state.deletedEdges.length > 0,
1958
- };
1959
- },
1960
- resetEditState: () => {
1961
- editStateRef.current = createEmptyEditState();
1962
- // Reset text changes version to trigger re-render
1963
- resetTextChangesVersionRef.current?.();
1964
- // Also reset visual state (node positions/dimensions) if available
1965
- resetVisualStateRef.current?.();
1966
- // Clear undo/redo history
1967
- clearHistory();
1968
- },
1969
- hasUnsavedChanges: () => {
1970
- const state = editStateRef.current;
1971
- return (state.positionChanges.size > 0 ||
1972
- state.dimensionChanges.size > 0 ||
1973
- state.textChanges.size > 0 ||
1974
- state.nodeUpdates.size > 0 ||
1975
- state.deletedNodeIds.size > 0 ||
1976
- state.createdEdges.length > 0 ||
1977
- state.deletedEdges.length > 0);
1978
- },
1979
- canUndo: () => canUndoState,
1980
- canRedo: () => canRedoState,
1981
- undo: () => undoRedoFunctionsRef.current?.applyUndo(),
1982
- redo: () => undoRedoFunctionsRef.current?.applyRedo(),
1983
- }), [canUndoState, canRedoState, clearHistory]);
1984
- // Validate we have required data
1985
- if (!canvasData) {
1986
- return (_jsx("div", { className: className, style: {
1987
- width,
1988
- height,
1989
- display: 'flex',
1990
- alignItems: 'center',
1991
- justifyContent: 'center',
1992
- backgroundColor: theme.colors.background,
1993
- color: theme.colors.textSecondary,
1994
- }, children: _jsx("p", { children: "No canvas data provided." }) }));
1995
- }
1996
- const { configuration, nodes, edges } = canvasData;
1997
- // Extract only the props that inner component needs
1998
- const { violations, configName, showMinimap, showControls, showBackground, backgroundVariant, backgroundGap, showCenterIndicator, showTooltips, fitViewDuration, highlightedNodeId, activeNodeIds, events, onEventProcessed, editable, onPendingChangesChange, onNodeClick, fitViewToNodeIds, fitViewPadding, draggableNodeIds, onNodeDragStop, onCopy, } = props;
1999
- // If container dimensions are provided but viewport isn't calculated yet, wait
2000
- // This prevents the flash from rendering with default viewport then re-rendering
2001
- const isWaitingForViewport = (containerWidth !== undefined || containerHeight !== undefined) && !initialViewport;
2002
- if (isWaitingForViewport) {
2003
- return (_jsx("div", { ref: containerRef, className: className, style: { width, height, position: 'relative' } }));
2004
- }
2005
- return (_jsx("div", { ref: containerRef, className: className, style: { width, height, position: 'relative' }, children: _jsx(TooltipPortalContext.Provider, { value: portalTarget, children: _jsx(ReactFlowProvider, { children: _jsx(GraphRendererInner, { configuration: configuration, nodes: nodes, edges: edges, violations: violations, configName: configName, showMinimap: showMinimap, showControls: showControls, showBackground: showBackground, backgroundVariant: backgroundVariant, backgroundGap: backgroundGap, showCenterIndicator: showCenterIndicator, showTooltips: showTooltips, fitViewDuration: fitViewDuration, highlightedNodeId: highlightedNodeId, activeNodeIds: activeNodeIds, events: events, onEventProcessed: onEventProcessed, editable: editable, onPendingChangesChange: onPendingChangesChange, editStateRef: editStateRef, resetVisualStateRef: resetVisualStateRef, resetTextChangesVersionRef: resetTextChangesVersionRef, undoRedoFunctionsRef: undoRedoFunctionsRef, pushHistory: pushHistory, clearHistory: clearHistory, undoFromStack: undoFromStack, redoFromStack: redoFromStack, onNodeClick: onNodeClick, fitViewToNodeIds: fitViewToNodeIds, fitViewPadding: fitViewPadding, draggableNodeIds: draggableNodeIds, onNodeDragStop: onNodeDragStop, onCopy: onCopy, initialViewport: initialViewport, elkLayout: elkLayout, scenarioEdges: scenarioEdges, showSequenceLabels: showSequenceLabels }) }) }) }));
2006
- });
2007
- GraphRenderer.displayName = 'GraphRenderer';
2008
- //# sourceMappingURL=GraphRenderer.js.map