@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.
- package/package.json +1 -1
- package/dist/components/ConfigurationSelector.d.ts +0 -35
- package/dist/components/ConfigurationSelector.d.ts.map +0 -1
- package/dist/components/ConfigurationSelector.js +0 -63
- package/dist/components/ConfigurationSelector.js.map +0 -1
- package/dist/components/GraphRenderer.d.ts +0 -295
- package/dist/components/GraphRenderer.d.ts.map +0 -1
- package/dist/components/GraphRenderer.js +0 -2008
- package/dist/components/GraphRenderer.js.map +0 -1
- package/dist/components/MultiCanvasRenderer.d.ts +0 -75
- package/dist/components/MultiCanvasRenderer.d.ts.map +0 -1
- package/dist/components/MultiCanvasRenderer.js +0 -203
- package/dist/components/MultiCanvasRenderer.js.map +0 -1
- package/dist/components/NodeTooltip.d.ts +0 -24
- package/dist/components/NodeTooltip.d.ts.map +0 -1
- package/dist/components/NodeTooltip.js +0 -160
- package/dist/components/NodeTooltip.js.map +0 -1
- package/dist/components/SelectionSidebar.d.ts +0 -18
- package/dist/components/SelectionSidebar.d.ts.map +0 -1
- package/dist/components/SelectionSidebar.js +0 -190
- package/dist/components/SelectionSidebar.js.map +0 -1
- package/dist/components/SequenceDiagramRenderer.d.ts +0 -77
- package/dist/components/SequenceDiagramRenderer.d.ts.map +0 -1
- package/dist/components/SequenceDiagramRenderer.js +0 -604
- package/dist/components/SequenceDiagramRenderer.js.map +0 -1
- package/dist/components/WorkflowSequenceDiagram.d.ts +0 -63
- package/dist/components/WorkflowSequenceDiagram.d.ts.map +0 -1
- package/dist/components/WorkflowSequenceDiagram.js +0 -147
- package/dist/components/WorkflowSequenceDiagram.js.map +0 -1
- package/dist/components/dashboard/DashboardRenderer.d.ts +0 -10
- package/dist/components/dashboard/DashboardRenderer.d.ts.map +0 -1
- package/dist/components/dashboard/DashboardRenderer.js +0 -179
- package/dist/components/dashboard/DashboardRenderer.js.map +0 -1
- package/dist/components/dashboard/MetricPanel.d.ts +0 -10
- package/dist/components/dashboard/MetricPanel.d.ts.map +0 -1
- package/dist/components/dashboard/MetricPanel.js +0 -103
- package/dist/components/dashboard/MetricPanel.js.map +0 -1
- package/dist/components/dashboard/MockDataProvider.d.ts +0 -30
- package/dist/components/dashboard/MockDataProvider.d.ts.map +0 -1
- package/dist/components/dashboard/MockDataProvider.js +0 -270
- package/dist/components/dashboard/MockDataProvider.js.map +0 -1
- package/dist/components/dashboard/components/BarChart.d.ts +0 -10
- package/dist/components/dashboard/components/BarChart.d.ts.map +0 -1
- package/dist/components/dashboard/components/BarChart.js +0 -167
- package/dist/components/dashboard/components/BarChart.js.map +0 -1
- package/dist/components/dashboard/components/LineChart.d.ts +0 -10
- package/dist/components/dashboard/components/LineChart.d.ts.map +0 -1
- package/dist/components/dashboard/components/LineChart.js +0 -141
- package/dist/components/dashboard/components/LineChart.js.map +0 -1
- package/dist/components/dashboard/components/MetricCard.d.ts +0 -9
- package/dist/components/dashboard/components/MetricCard.d.ts.map +0 -1
- package/dist/components/dashboard/components/MetricCard.js +0 -163
- package/dist/components/dashboard/components/MetricCard.js.map +0 -1
- package/dist/components/dashboard/components/SourceLink.d.ts +0 -9
- package/dist/components/dashboard/components/SourceLink.d.ts.map +0 -1
- package/dist/components/dashboard/components/SourceLink.js +0 -39
- package/dist/components/dashboard/components/SourceLink.js.map +0 -1
- package/dist/components/dashboard/components/TimeRangeSelector.d.ts +0 -9
- package/dist/components/dashboard/components/TimeRangeSelector.d.ts.map +0 -1
- package/dist/components/dashboard/components/TimeRangeSelector.js +0 -167
- package/dist/components/dashboard/components/TimeRangeSelector.js.map +0 -1
- package/dist/components/dashboard/components/index.d.ts +0 -6
- package/dist/components/dashboard/components/index.d.ts.map +0 -1
- package/dist/components/dashboard/components/index.js +0 -6
- package/dist/components/dashboard/components/index.js.map +0 -1
- package/dist/components/dashboard/index.d.ts +0 -6
- package/dist/components/dashboard/index.d.ts.map +0 -1
- package/dist/components/dashboard/index.js +0 -8
- package/dist/components/dashboard/index.js.map +0 -1
- package/dist/components/dashboard/types.d.ts +0 -74
- package/dist/components/dashboard/types.d.ts.map +0 -1
- package/dist/components/dashboard/types.js +0 -8
- package/dist/components/dashboard/types.js.map +0 -1
- package/dist/components/state-view/PipelineView.d.ts +0 -14
- package/dist/components/state-view/PipelineView.d.ts.map +0 -1
- package/dist/components/state-view/PipelineView.js +0 -195
- package/dist/components/state-view/PipelineView.js.map +0 -1
- package/dist/components/state-view/index.d.ts +0 -14
- package/dist/components/state-view/index.d.ts.map +0 -1
- package/dist/components/state-view/index.js +0 -12
- package/dist/components/state-view/index.js.map +0 -1
- package/dist/components/state-view/types.d.ts +0 -188
- package/dist/components/state-view/types.d.ts.map +0 -1
- package/dist/components/state-view/types.js +0 -10
- package/dist/components/state-view/types.js.map +0 -1
- package/dist/components/state-view/useStateView.d.ts +0 -32
- package/dist/components/state-view/useStateView.d.ts.map +0 -1
- package/dist/components/state-view/useStateView.js +0 -129
- package/dist/components/state-view/useStateView.js.map +0 -1
- package/dist/contexts/GraphEditContext.d.ts +0 -20
- package/dist/contexts/GraphEditContext.d.ts.map +0 -1
- package/dist/contexts/GraphEditContext.js +0 -8
- package/dist/contexts/GraphEditContext.js.map +0 -1
- package/dist/contexts/TooltipPortalContext.d.ts +0 -8
- package/dist/contexts/TooltipPortalContext.d.ts.map +0 -1
- package/dist/contexts/TooltipPortalContext.js +0 -8
- package/dist/contexts/TooltipPortalContext.js.map +0 -1
- package/dist/edges/CustomEdge.d.ts +0 -24
- package/dist/edges/CustomEdge.d.ts.map +0 -1
- package/dist/edges/CustomEdge.js +0 -222
- package/dist/edges/CustomEdge.js.map +0 -1
- package/dist/edges/GenericEdge.d.ts +0 -18
- package/dist/edges/GenericEdge.d.ts.map +0 -1
- package/dist/edges/GenericEdge.js +0 -10
- package/dist/edges/GenericEdge.js.map +0 -1
- package/dist/graphify/kind.d.ts +0 -23
- package/dist/graphify/kind.d.ts.map +0 -1
- package/dist/graphify/kind.js +0 -77
- package/dist/graphify/kind.js.map +0 -1
- package/dist/hooks/usePathBasedEvents.d.ts +0 -42
- package/dist/hooks/usePathBasedEvents.d.ts.map +0 -1
- package/dist/hooks/usePathBasedEvents.js +0 -119
- package/dist/hooks/usePathBasedEvents.js.map +0 -1
- package/dist/hooks/useSequenceLayout.d.ts +0 -186
- package/dist/hooks/useSequenceLayout.d.ts.map +0 -1
- package/dist/hooks/useSequenceLayout.js +0 -327
- package/dist/hooks/useSequenceLayout.js.map +0 -1
- package/dist/hooks/useUndoRedo.d.ts +0 -80
- package/dist/hooks/useUndoRedo.d.ts.map +0 -1
- package/dist/hooks/useUndoRedo.js +0 -70
- package/dist/hooks/useUndoRedo.js.map +0 -1
- package/dist/nodes/CustomNode.d.ts +0 -28
- package/dist/nodes/CustomNode.d.ts.map +0 -1
- package/dist/nodes/CustomNode.js +0 -820
- package/dist/nodes/CustomNode.js.map +0 -1
- package/dist/nodes/GenericNode.d.ts +0 -20
- package/dist/nodes/GenericNode.d.ts.map +0 -1
- package/dist/nodes/GenericNode.js +0 -20
- package/dist/nodes/GenericNode.js.map +0 -1
- package/dist/nodes/otel/OtelBoundaryNode.d.ts +0 -55
- package/dist/nodes/otel/OtelBoundaryNode.d.ts.map +0 -1
- package/dist/nodes/otel/OtelBoundaryNode.js +0 -81
- package/dist/nodes/otel/OtelBoundaryNode.js.map +0 -1
- package/dist/nodes/otel/OtelEventNode.d.ts +0 -59
- package/dist/nodes/otel/OtelEventNode.d.ts.map +0 -1
- package/dist/nodes/otel/OtelEventNode.js +0 -82
- package/dist/nodes/otel/OtelEventNode.js.map +0 -1
- package/dist/nodes/otel/OtelResourceNode.d.ts +0 -53
- package/dist/nodes/otel/OtelResourceNode.d.ts.map +0 -1
- package/dist/nodes/otel/OtelResourceNode.js +0 -105
- package/dist/nodes/otel/OtelResourceNode.js.map +0 -1
- package/dist/nodes/otel/OtelScopeNode.d.ts +0 -53
- package/dist/nodes/otel/OtelScopeNode.d.ts.map +0 -1
- package/dist/nodes/otel/OtelScopeNode.js +0 -81
- package/dist/nodes/otel/OtelScopeNode.js.map +0 -1
- package/dist/nodes/otel/OtelSpanConventionNode.d.ts +0 -61
- package/dist/nodes/otel/OtelSpanConventionNode.d.ts.map +0 -1
- package/dist/nodes/otel/OtelSpanConventionNode.js +0 -131
- package/dist/nodes/otel/OtelSpanConventionNode.js.map +0 -1
- package/dist/nodes/otel/index.d.ts +0 -17
- package/dist/nodes/otel/index.d.ts.map +0 -1
- package/dist/nodes/otel/index.js +0 -13
- package/dist/nodes/otel/index.js.map +0 -1
- package/dist/nodes/otel/shared/NodeBadges.d.ts +0 -10
- package/dist/nodes/otel/shared/NodeBadges.d.ts.map +0 -1
- package/dist/nodes/otel/shared/NodeBadges.js +0 -178
- package/dist/nodes/otel/shared/NodeBadges.js.map +0 -1
- package/dist/nodes/otel/shared/NodeContent.d.ts +0 -10
- package/dist/nodes/otel/shared/NodeContent.d.ts.map +0 -1
- package/dist/nodes/otel/shared/NodeContent.js +0 -96
- package/dist/nodes/otel/shared/NodeContent.js.map +0 -1
- package/dist/nodes/otel/shared/index.d.ts +0 -8
- package/dist/nodes/otel/shared/index.d.ts.map +0 -1
- package/dist/nodes/otel/shared/index.js +0 -8
- package/dist/nodes/otel/shared/index.js.map +0 -1
- package/dist/nodes/otel/shared/types.d.ts +0 -129
- package/dist/nodes/otel/shared/types.d.ts.map +0 -1
- package/dist/nodes/otel/shared/types.js +0 -5
- package/dist/nodes/otel/shared/types.js.map +0 -1
- package/dist/nodes/otel/shared/useNodeBehavior.d.ts +0 -42
- package/dist/nodes/otel/shared/useNodeBehavior.d.ts.map +0 -1
- package/dist/nodes/otel/shared/useNodeBehavior.js +0 -61
- package/dist/nodes/otel/shared/useNodeBehavior.js.map +0 -1
- package/dist/pierre/PierreThroughlineCodeView.d.ts +0 -22
- package/dist/pierre/PierreThroughlineCodeView.d.ts.map +0 -1
- package/dist/pierre/PierreThroughlineCodeView.js +0 -206
- package/dist/pierre/PierreThroughlineCodeView.js.map +0 -1
- package/dist/subsystem/SubsystemCarousel.d.ts +0 -36
- package/dist/subsystem/SubsystemCarousel.d.ts.map +0 -1
- package/dist/subsystem/SubsystemCarousel.js +0 -163
- package/dist/subsystem/SubsystemCarousel.js.map +0 -1
- package/dist/utils/animationMapping.d.ts +0 -53
- package/dist/utils/animationMapping.d.ts.map +0 -1
- package/dist/utils/animationMapping.js +0 -126
- package/dist/utils/animationMapping.js.map +0 -1
- package/dist/utils/canvasBounds.d.ts +0 -50
- package/dist/utils/canvasBounds.d.ts.map +0 -1
- package/dist/utils/canvasBounds.js +0 -88
- package/dist/utils/canvasBounds.js.map +0 -1
- package/dist/utils/graphConverter.d.ts +0 -18
- package/dist/utils/graphConverter.d.ts.map +0 -1
- package/dist/utils/graphConverter.js +0 -114
- package/dist/utils/graphConverter.js.map +0 -1
- package/dist/utils/iconResolver.d.ts +0 -29
- package/dist/utils/iconResolver.d.ts.map +0 -1
- package/dist/utils/iconResolver.js +0 -30
- package/dist/utils/iconResolver.js.map +0 -1
- package/dist/utils/orientationUtils.d.ts +0 -19
- package/dist/utils/orientationUtils.d.ts.map +0 -1
- package/dist/utils/orientationUtils.js +0 -62
- 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
|