@principal-ai/principal-view-react 0.7.20 → 0.7.22

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.
@@ -15,6 +15,7 @@ import {
15
15
  MiniMap,
16
16
  ReactFlowProvider,
17
17
  useReactFlow,
18
+ useUpdateNodeInternals,
18
19
  useViewport,
19
20
  applyNodeChanges,
20
21
  applyEdgeChanges,
@@ -368,8 +369,34 @@ const GraphRendererInner: React.FC<GraphRendererInnerProps> = ({
368
369
  onSourceClick,
369
370
  }) => {
370
371
  const { fitView } = useReactFlow();
372
+ const updateNodeInternals = useUpdateNodeInternals();
371
373
  const { theme } = useTheme();
372
374
 
375
+ // Track shift key state for tooltip control
376
+ const [shiftKeyPressed, setShiftKeyPressed] = useState(false);
377
+
378
+ // Setup keyboard event listeners for shift key
379
+ useEffect(() => {
380
+ const handleKeyDown = (e: KeyboardEvent) => {
381
+ if (e.key === 'Shift') {
382
+ setShiftKeyPressed(true);
383
+ }
384
+ };
385
+
386
+ const handleKeyUp = (e: KeyboardEvent) => {
387
+ if (e.key === 'Shift') {
388
+ setShiftKeyPressed(false);
389
+ }
390
+ };
391
+
392
+ window.addEventListener('keydown', handleKeyDown);
393
+ window.addEventListener('keyup', handleKeyUp);
394
+
395
+ return () => {
396
+ window.removeEventListener('keydown', handleKeyDown);
397
+ window.removeEventListener('keyup', handleKeyUp);
398
+ };
399
+ }, []);
373
400
 
374
401
  // Track active animations
375
402
  const [animationState, setAnimationState] = useState<AnimationState>({
@@ -848,20 +875,21 @@ const GraphRendererInner: React.FC<GraphRendererInnerProps> = ({
848
875
  const originalEdge = localEdges.find((e) => e.id === oldEdge.id);
849
876
  if (!originalEdge) return;
850
877
 
851
- // Find source and target node types for validation
878
+ // Find source and target nodes for validation
852
879
  const sourceNode = nodes.find((n) => n.id === newConnection.source);
853
880
  const targetNode = nodes.find((n) => n.id === newConnection.target);
854
881
  if (!sourceNode || !targetNode) return;
855
882
 
856
883
  // Check if the new connection is valid for this edge type
884
+ // Note: allowedConnections uses node IDs as the from/to values
857
885
  const isValidConnection = configuration.allowedConnections.some(
858
886
  (ac) =>
859
- ac.from === sourceNode.type && ac.to === targetNode.type && ac.via === originalEdge.type
887
+ ac.from === sourceNode.id && ac.to === targetNode.id && ac.via === originalEdge.type
860
888
  );
861
889
 
862
890
  if (!isValidConnection) {
863
891
  console.warn(
864
- `Cannot reconnect: ${originalEdge.type} edge not allowed from ${sourceNode.type} to ${targetNode.type}`
892
+ `Cannot reconnect: ${originalEdge.type} edge not allowed from ${sourceNode.id} to ${targetNode.id}`
865
893
  );
866
894
  return;
867
895
  }
@@ -1137,6 +1165,7 @@ const GraphRendererInner: React.FC<GraphRendererInnerProps> = ({
1137
1165
  ...node.data,
1138
1166
  editable,
1139
1167
  tooltipsEnabled: showTooltips,
1168
+ shiftKeyPressed,
1140
1169
  isHighlighted: highlightedNodeId === node.id,
1141
1170
  ...(animation
1142
1171
  ? {
@@ -1147,7 +1176,7 @@ const GraphRendererInner: React.FC<GraphRendererInnerProps> = ({
1147
1176
  } as CustomNodeData,
1148
1177
  };
1149
1178
  });
1150
- }, [localNodes, configuration, violations, animationState.nodeAnimations, editable, showTooltips, highlightedNodeId, editStateRef]);
1179
+ }, [localNodes, configuration, violations, animationState.nodeAnimations, editable, showTooltips, highlightedNodeId, editStateRef, shiftKeyPressed]);
1151
1180
 
1152
1181
  const baseNodesKey = useMemo(() => {
1153
1182
  return nodes
@@ -1181,9 +1210,17 @@ const GraphRendererInner: React.FC<GraphRendererInnerProps> = ({
1181
1210
  if (editable && !prevEditableRef.current) {
1182
1211
  // Entering edit mode - sync positions
1183
1212
  setXyflowLocalNodes(xyflowNodesBase);
1213
+
1214
+ // Reset ReactFlow's internal state for all nodes to prevent NaN errors
1215
+ // This ensures ReactFlow remeasures nodes and updates drag tracking
1216
+ setTimeout(() => {
1217
+ xyflowNodesBase.forEach((node) => {
1218
+ updateNodeInternals(node.id);
1219
+ });
1220
+ }, 0);
1184
1221
  }
1185
1222
  prevEditableRef.current = editable;
1186
- }, [editable, xyflowNodesBase]);
1223
+ }, [editable, xyflowNodesBase, updateNodeInternals]);
1187
1224
 
1188
1225
  const xyflowNodes = editable ? xyflowLocalNodes : xyflowNodesBase;
1189
1226
 
@@ -1292,6 +1329,7 @@ const GraphRendererInner: React.FC<GraphRendererInnerProps> = ({
1292
1329
  data: {
1293
1330
  ...edge.data,
1294
1331
  tooltipsEnabled: showTooltips,
1332
+ shiftKeyPressed,
1295
1333
  ...(animation
1296
1334
  ? {
1297
1335
  animationType: animation.type,
@@ -1313,7 +1351,7 @@ const GraphRendererInner: React.FC<GraphRendererInnerProps> = ({
1313
1351
  if (!aSelected && bSelected) return -1; // b comes after a (rendered on top)
1314
1352
  return 0; // maintain original order
1315
1353
  });
1316
- }, [edges, configuration, violations, animationState.edgeAnimations, showTooltips, selectedEdgeIds]);
1354
+ }, [edges, configuration, violations, animationState.edgeAnimations, showTooltips, selectedEdgeIds, shiftKeyPressed]);
1317
1355
 
1318
1356
  // Local xyflow edges state for reconnection
1319
1357
  const [xyflowLocalEdges, setXyflowLocalEdges] = useState<Edge<CustomEdgeData>[]>(xyflowEdgesBase);
@@ -1656,15 +1694,10 @@ function useCanvasToLegacy(
1656
1694
  };
1657
1695
  }
1658
1696
 
1659
- // Find node types for from/to
1660
- const fromNode = canvas.nodes?.find((n) => n.id === edge.fromNode);
1661
- const toNode = canvas.nodes?.find((n) => n.id === edge.toNode);
1662
- const fromType = fromNode?.pv?.nodeType || edge.fromNode;
1663
- const toType = toNode?.pv?.nodeType || edge.toNode;
1664
-
1697
+ // Store allowed connections using node IDs
1665
1698
  allowedConnections.push({
1666
- from: fromType,
1667
- to: toType,
1699
+ from: edge.fromNode,
1700
+ to: edge.toNode,
1668
1701
  via: edgeType,
1669
1702
  });
1670
1703
  }
@@ -42,9 +42,6 @@ export const NodeTooltip: React.FC<NodeTooltipProps> = ({
42
42
 
43
43
  if (!visible) return null;
44
44
 
45
- // Don't show tooltip if no useful info
46
- if (!description && !otel) return null;
47
-
48
45
  // If no nodeRef, render inline (for storybook demos)
49
46
  const usePortal = Boolean(nodeRef);
50
47
 
@@ -156,9 +153,9 @@ export const NodeTooltip: React.FC<NodeTooltipProps> = ({
156
153
  )}
157
154
 
158
155
  {/* Description */}
159
- {description && (
160
- <div style={{ lineHeight: '1.4', color: 'rgba(255,255,255,0.9)' }}>{description}</div>
161
- )}
156
+ <div style={{ lineHeight: '1.4', color: description ? 'rgba(255,255,255,0.9)' : 'rgba(255,255,255,0.5)' }}>
157
+ {description || 'No description'}
158
+ </div>
162
159
  </div>
163
160
  );
164
161
 
@@ -14,6 +14,8 @@ export interface CustomEdgeData extends Record<string, unknown> {
14
14
  animationDirection?: 'forward' | 'backward' | 'bidirectional';
15
15
  // Whether tooltips are enabled (defaults to true)
16
16
  tooltipsEnabled?: boolean;
17
+ // Whether shift key is currently pressed (for tooltip control)
18
+ shiftKeyPressed?: boolean;
17
19
  }
18
20
 
19
21
  /**
@@ -42,6 +44,7 @@ export const CustomEdge: React.FC<EdgeProps<any>> = ({
42
44
  animationDuration = 1000,
43
45
  animationDirection = 'forward',
44
46
  tooltipsEnabled = true,
47
+ shiftKeyPressed = false,
45
48
  } = edgeProps || ({} as CustomEdgeData);
46
49
 
47
50
  const [particlePosition, setParticlePosition] = useState(0);
@@ -285,8 +288,8 @@ export const CustomEdge: React.FC<EdgeProps<any>> = ({
285
288
  </EdgeLabelRenderer>
286
289
  )}
287
290
 
288
- {/* Hover tooltip showing edge type */}
289
- {tooltipsEnabled && isHovered && (
291
+ {/* Hover tooltip showing edge type - only shown when shift key is pressed */}
292
+ {tooltipsEnabled && isHovered && shiftKeyPressed && (
290
293
  <EdgeLabelRenderer>
291
294
  <div
292
295
  style={{
@@ -45,6 +45,8 @@ export interface CustomNodeData extends Record<string, unknown> {
45
45
  editable?: boolean;
46
46
  // Whether tooltips are enabled (defaults to true)
47
47
  tooltipsEnabled?: boolean;
48
+ // Whether shift key is currently pressed (for tooltip control)
49
+ shiftKeyPressed?: boolean;
48
50
  // Whether this node is highlighted (e.g., during execution playback)
49
51
  isHighlighted?: boolean;
50
52
  }
@@ -55,9 +57,6 @@ export interface CustomNodeData extends Record<string, unknown> {
55
57
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
56
58
  export const CustomNode: React.FC<NodeProps<any>> = ({ data, selected, dragging }) => {
57
59
  const [isHovered, setIsHovered] = useState(false);
58
-
59
- // Don't show tooltip while dragging
60
- const showTooltip = isHovered && !dragging;
61
60
  const nodeRef = useRef<HTMLDivElement>(null);
62
61
  const nodeProps = data as CustomNodeData;
63
62
  const {
@@ -69,9 +68,13 @@ export const CustomNode: React.FC<NodeProps<any>> = ({ data, selected, dragging
69
68
  animationDuration = 1000,
70
69
  editable = false,
71
70
  tooltipsEnabled = true,
71
+ shiftKeyPressed = false,
72
72
  isHighlighted = false,
73
73
  } = nodeProps;
74
74
 
75
+ // Only show tooltip when hovering, not dragging, and shift key is pressed
76
+ const showTooltip = isHovered && !dragging && shiftKeyPressed;
77
+
75
78
  // Extract OTEL info and description for tooltip
76
79
  const otelInfo = nodeData?.otel as OtelInfo | undefined;
77
80
  const description = nodeData?.description as string | undefined;
@@ -981,3 +981,315 @@ export const EdgeFieldsReference: Story = {
981
981
  },
982
982
  },
983
983
  };
984
+
985
+ /**
986
+ * Canvas showing edge reconnection scenario from admin-dashboard
987
+ * Tests moving an edge from left to right of the same node
988
+ */
989
+ const edgeReconnectionCanvas: ExtendedCanvas = {
990
+ nodes: [
991
+ {
992
+ id: 'client-components',
993
+ type: 'text' as const,
994
+ x: 100,
995
+ y: 200,
996
+ width: 200,
997
+ height: 100,
998
+ text: 'Client Components\n\nReact Query\nURL state (nuqs)\nReal-time updates',
999
+ color: '#06B6D4',
1000
+ },
1001
+ {
1002
+ id: 'api-routes',
1003
+ type: 'text' as const,
1004
+ x: 400,
1005
+ y: 200,
1006
+ width: 220,
1007
+ height: 180,
1008
+ text: 'API Routes Layer\n\n• /api/otel/*\n• /api/dashboards/*\n• /api/teams/*\n• /api/github/*\n• /api/sentry/*\n• /api/billing/*\n• /api/bug-fix-agent/*',
1009
+ color: '#F59E0B',
1010
+ },
1011
+ {
1012
+ id: 'pages-layer',
1013
+ type: 'text' as const,
1014
+ x: 700,
1015
+ y: 100,
1016
+ width: 220,
1017
+ height: 180,
1018
+ text: 'Pages Layer\n\n• Dashboard (/, /dashboards)\n• Sessions (/sessions)\n• Events (/events)\n• Teams (/teams)\n• Integrations (/integrations)\n• Billing (/billing)',
1019
+ color: '#10B981',
1020
+ },
1021
+ ],
1022
+ edges: [
1023
+ {
1024
+ id: 'e8',
1025
+ fromNode: 'client-components',
1026
+ toNode: 'api-routes',
1027
+ fromSide: 'right',
1028
+ toSide: 'left',
1029
+ label: 'React Query',
1030
+ pv: {
1031
+ edgeType: 'httpRest',
1032
+ },
1033
+ },
1034
+ {
1035
+ id: 'e4',
1036
+ fromNode: 'pages-layer',
1037
+ toNode: 'api-routes',
1038
+ fromSide: 'left',
1039
+ toSide: 'right',
1040
+ label: 'SSR',
1041
+ pv: {
1042
+ edgeType: 'httpRest',
1043
+ },
1044
+ },
1045
+ ],
1046
+ pv: {
1047
+ version: '1.0.0',
1048
+ name: 'Edge Reconnection Test - Admin Dashboard Scenario',
1049
+ description: 'Reproduces the httpRest edge reconnection issue when moving edge from left to right of same node',
1050
+ edgeTypes: {
1051
+ httpRest: {
1052
+ style: 'solid',
1053
+ color: '#3b82f6',
1054
+ directed: true,
1055
+ },
1056
+ },
1057
+ },
1058
+ };
1059
+
1060
+ export const EdgeReconnectionScenario: Story = {
1061
+ args: {
1062
+ canvas: edgeReconnectionCanvas,
1063
+ width: 1100,
1064
+ height: 500,
1065
+ editable: true,
1066
+ },
1067
+ parameters: {
1068
+ docs: {
1069
+ description: {
1070
+ story: `
1071
+ **Edge Reconnection Test - Admin Dashboard Scenario**
1072
+
1073
+ This story reproduces the issue from the admin-dashboard.canvas file where:
1074
+
1075
+ 1. We have text nodes connected by httpRest edges
1076
+ 2. Edge e8 connects client-components → api-routes (left → right)
1077
+ 3. Edge e4 connects pages-layer → api-routes (left → right)
1078
+
1079
+ **To Test the Bug:**
1080
+ Try to reconnect edge e8 or e4 to different sides of the api-routes node.
1081
+ You may see the error: "Cannot reconnect: httpRest edge not allowed from text to text"
1082
+
1083
+ **Root Cause:**
1084
+ The validation checks \`allowedConnections\` which is built from existing edges.
1085
+ When nodes don't have \`pv.nodeType\`, the system uses node IDs instead of node types,
1086
+ causing validation to fail when reconnecting edges between different node pairs.
1087
+
1088
+ **Expected Behavior:**
1089
+ Edges with edgeType "httpRest" should be allowed to reconnect between any text nodes,
1090
+ regardless of which specific nodes are connected.
1091
+ `,
1092
+ },
1093
+ },
1094
+ },
1095
+ };
1096
+
1097
+ /**
1098
+ * Full Admin Dashboard canvas data for comprehensive testing
1099
+ * Includes multiple httpRest edges and various node types
1100
+ */
1101
+ const adminDashboardFullCanvas: ExtendedCanvas = {
1102
+ nodes: [
1103
+ {
1104
+ id: 'user-browser',
1105
+ type: 'text' as const,
1106
+ text: 'User Browser\n\nNext.js 16 React App',
1107
+ x: 92,
1108
+ y: 374,
1109
+ width: 180,
1110
+ height: 80,
1111
+ color: '#3B82F6',
1112
+ },
1113
+ {
1114
+ id: 'pages-layer',
1115
+ type: 'text' as const,
1116
+ text: 'Pages Layer\n\n• Dashboard (/, /dashboards)\n• Sessions (/sessions)\n• Events (/events)',
1117
+ x: 400,
1118
+ y: 100,
1119
+ width: 220,
1120
+ height: 180,
1121
+ color: '#10B981',
1122
+ },
1123
+ {
1124
+ id: 'api-routes',
1125
+ type: 'text' as const,
1126
+ text: 'API Routes Layer\n\n• /api/otel/*\n• /api/dashboards/*\n• /api/teams/*\n• /api/github/*',
1127
+ x: 400,
1128
+ y: 320,
1129
+ width: 220,
1130
+ height: 180,
1131
+ color: '#F59E0B',
1132
+ },
1133
+ {
1134
+ id: 'server-components',
1135
+ type: 'text' as const,
1136
+ text: 'Server Components\n\nSSR data fetching\nDirect DB queries',
1137
+ x: 700,
1138
+ y: 63,
1139
+ width: 200,
1140
+ height: 100,
1141
+ color: '#06B6D4',
1142
+ },
1143
+ {
1144
+ id: 'client-components',
1145
+ type: 'text' as const,
1146
+ text: 'Client Components\n\nReact Query\nURL state (nuqs)',
1147
+ x: 700,
1148
+ y: 230,
1149
+ width: 200,
1150
+ height: 100,
1151
+ color: '#06B6D4',
1152
+ },
1153
+ {
1154
+ id: 'postgresql',
1155
+ type: 'text' as const,
1156
+ text: 'PostgreSQL\n\nDrizzle ORM\nUsers, orgs, teams',
1157
+ x: 1000,
1158
+ y: 124,
1159
+ width: 200,
1160
+ height: 120,
1161
+ color: '#336791',
1162
+ },
1163
+ ],
1164
+ edges: [
1165
+ {
1166
+ id: 'e2',
1167
+ fromNode: 'user-browser',
1168
+ toNode: 'pages-layer',
1169
+ fromSide: 'right',
1170
+ toSide: 'left',
1171
+ label: 'Navigate',
1172
+ pv: { edgeType: 'httpRest' },
1173
+ },
1174
+ {
1175
+ id: 'e3',
1176
+ fromNode: 'user-browser',
1177
+ toNode: 'api-routes',
1178
+ fromSide: 'right',
1179
+ toSide: 'left',
1180
+ label: 'API calls',
1181
+ pv: { edgeType: 'httpRest' },
1182
+ },
1183
+ {
1184
+ id: 'e4',
1185
+ fromNode: 'pages-layer',
1186
+ toNode: 'server-components',
1187
+ fromSide: 'right',
1188
+ toSide: 'left',
1189
+ label: 'SSR',
1190
+ pv: { edgeType: 'httpRest' },
1191
+ },
1192
+ {
1193
+ id: 'e5',
1194
+ fromNode: 'pages-layer',
1195
+ toNode: 'client-components',
1196
+ fromSide: 'right',
1197
+ toSide: 'left',
1198
+ label: 'Hydrate',
1199
+ pv: { edgeType: 'httpRest' },
1200
+ },
1201
+ {
1202
+ id: 'e6',
1203
+ fromNode: 'server-components',
1204
+ toNode: 'postgresql',
1205
+ fromSide: 'right',
1206
+ toSide: 'left',
1207
+ label: 'Drizzle ORM',
1208
+ pv: { edgeType: 'postgresQuery' },
1209
+ },
1210
+ {
1211
+ id: 'e8',
1212
+ fromNode: 'client-components',
1213
+ toNode: 'api-routes',
1214
+ fromSide: 'left',
1215
+ toSide: 'right',
1216
+ label: 'React Query',
1217
+ pv: { edgeType: 'httpRest' },
1218
+ },
1219
+ {
1220
+ id: 'e9',
1221
+ fromNode: 'api-routes',
1222
+ toNode: 'postgresql',
1223
+ fromSide: 'right',
1224
+ toSide: 'left',
1225
+ label: 'Drizzle ORM',
1226
+ pv: { edgeType: 'postgresQuery' },
1227
+ },
1228
+ ],
1229
+ pv: {
1230
+ version: '1.0.0',
1231
+ name: 'Admin Dashboard Architecture (Simplified)',
1232
+ description: 'Simplified version of admin-dashboard.canvas to test edge reconnection issues',
1233
+ edgeTypes: {
1234
+ httpRest: {
1235
+ style: 'solid',
1236
+ color: '#3b82f6',
1237
+ directed: true,
1238
+ },
1239
+ postgresQuery: {
1240
+ style: 'solid',
1241
+ color: '#336791',
1242
+ directed: true,
1243
+ },
1244
+ },
1245
+ },
1246
+ };
1247
+
1248
+ export const AdminDashboardFull: Story = {
1249
+ args: {
1250
+ canvas: adminDashboardFullCanvas,
1251
+ width: 1400,
1252
+ height: 650,
1253
+ editable: true,
1254
+ },
1255
+ parameters: {
1256
+ docs: {
1257
+ description: {
1258
+ story: `
1259
+ **Full Admin Dashboard Architecture Test**
1260
+
1261
+ This is a simplified version of the actual admin-dashboard.canvas file from /Users/griever/Developer/observability.
1262
+
1263
+ **Key Test Cases:**
1264
+
1265
+ 1. **Edge e8 (client-components → api-routes)**:
1266
+ - Currently connects from LEFT side of client-components to RIGHT side of api-routes
1267
+ - Try dragging the edge handle to connect from RIGHT side instead
1268
+ - Expected: Should work seamlessly
1269
+ - Actual: May fail with "Cannot reconnect: httpRest edge not allowed from text to text"
1270
+
1271
+ 2. **Multiple httpRest edges**:
1272
+ - Notice that e2, e3, e4, e5, and e8 all use edgeType "httpRest"
1273
+ - They connect various text nodes together
1274
+ - Try reconnecting any of these edges to different nodes or different sides
1275
+
1276
+ 3. **Mixed edge types**:
1277
+ - Some edges use "httpRest", others use "postgresQuery"
1278
+ - This tests that the validation correctly distinguishes between different edge types
1279
+
1280
+ **The Issue:**
1281
+ The \`allowedConnections\` array is built by mapping each edge to its specific source and target node IDs.
1282
+ This means edge e8's allowed connection is registered as:
1283
+ \`\`\`
1284
+ { from: "client-components", to: "api-routes", via: "httpRest" }
1285
+ \`\`\`
1286
+
1287
+ When you try to reconnect this edge to a different node pair (even with the same node types),
1288
+ the validation fails because it's checking node IDs, not node types.
1289
+
1290
+ **Enable editing mode** to test the reconnection behavior.
1291
+ `,
1292
+ },
1293
+ },
1294
+ },
1295
+ };
@@ -1075,3 +1075,109 @@ Color coding:
1075
1075
  },
1076
1076
  },
1077
1077
  };
1078
+
1079
+ const EditModeToggleTemplate = () => {
1080
+ const [isEditMode, setIsEditMode] = React.useState(false);
1081
+ const graphRef = React.useRef<GraphRendererHandle>(null);
1082
+ const [hasChanges, setHasChanges] = React.useState(false);
1083
+
1084
+ const handleSave = () => {
1085
+ if (graphRef.current) {
1086
+ const changes = graphRef.current.getPendingChanges();
1087
+ console.log('Saving changes:', changes);
1088
+ alert('Changes saved! Check console for details.');
1089
+ graphRef.current.resetEditState();
1090
+ }
1091
+ };
1092
+
1093
+ return (
1094
+ <div>
1095
+ <div
1096
+ style={{
1097
+ marginBottom: 16,
1098
+ padding: 12,
1099
+ backgroundColor: '#f0f9ff',
1100
+ borderRadius: 4,
1101
+ border: '1px solid #3b82f6',
1102
+ }}
1103
+ >
1104
+ <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
1105
+ <button
1106
+ onClick={() => setIsEditMode(!isEditMode)}
1107
+ style={{
1108
+ padding: '8px 16px',
1109
+ backgroundColor: isEditMode ? '#ef4444' : '#3b82f6',
1110
+ color: 'white',
1111
+ border: 'none',
1112
+ borderRadius: 4,
1113
+ cursor: 'pointer',
1114
+ fontWeight: 'bold',
1115
+ fontSize: 14,
1116
+ }}
1117
+ >
1118
+ {isEditMode ? '🔓 Exit Edit Mode' : '🔒 Enter Edit Mode'}
1119
+ </button>
1120
+ {isEditMode && hasChanges && (
1121
+ <button
1122
+ onClick={handleSave}
1123
+ style={{
1124
+ padding: '8px 16px',
1125
+ backgroundColor: '#10b981',
1126
+ color: 'white',
1127
+ border: 'none',
1128
+ borderRadius: 4,
1129
+ cursor: 'pointer',
1130
+ fontWeight: 'bold',
1131
+ fontSize: 14,
1132
+ }}
1133
+ >
1134
+ 💾 Save Changes
1135
+ </button>
1136
+ )}
1137
+ <div style={{ fontSize: 13, color: '#1e40af' }}>
1138
+ Mode: <strong>{isEditMode ? 'Editable' : 'Read-only'}</strong>
1139
+ {isEditMode && hasChanges && (
1140
+ <span style={{ marginLeft: 8, color: '#f97316', fontWeight: 'bold' }}>
1141
+ (unsaved changes)
1142
+ </span>
1143
+ )}
1144
+ </div>
1145
+ </div>
1146
+ <div style={{ marginTop: 8, fontSize: 12, color: '#475569' }}>
1147
+ Toggle between read-only and edit mode. Try dragging nodes after switching modes to verify
1148
+ the NaN error fix.
1149
+ </div>
1150
+ </div>
1151
+ <GraphRenderer
1152
+ ref={graphRef}
1153
+ canvas={sampleCanvas}
1154
+ width={800}
1155
+ height={500}
1156
+ editable={isEditMode}
1157
+ onPendingChangesChange={setHasChanges}
1158
+ />
1159
+ </div>
1160
+ );
1161
+ };
1162
+
1163
+ export const EditModeToggle: Story = {
1164
+ render: () => <EditModeToggleTemplate />,
1165
+ parameters: {
1166
+ docs: {
1167
+ description: {
1168
+ story: `
1169
+ **Edit Mode Toggle** - Demonstrates toggling between read-only and editable modes without remounting.
1170
+
1171
+ This story tests the fix for the NaN coordinate error that occurred when transitioning between modes:
1172
+ - Click "Enter Edit Mode" to enable editing
1173
+ - Try dragging nodes (should work without NaN errors)
1174
+ - Click "Exit Edit Mode" to return to read-only
1175
+ - Toggle back to edit mode and drag again (should still work)
1176
+
1177
+ Previously, this would cause \`NaN\` errors in edge coordinates due to ReactFlow's internal state corruption.
1178
+ The fix uses \`updateNodeInternals()\` to reset ReactFlow's measurement tracking when entering edit mode.
1179
+ `,
1180
+ },
1181
+ },
1182
+ },
1183
+ };