@principal-ai/principal-view-react 0.13.21 → 0.13.23
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/dist/components/GraphRenderer.d.ts +8 -0
- package/dist/components/GraphRenderer.d.ts.map +1 -1
- package/dist/components/GraphRenderer.js +278 -30
- package/dist/components/GraphRenderer.js.map +1 -1
- package/dist/contexts/GraphEditContext.d.ts +14 -0
- package/dist/contexts/GraphEditContext.d.ts.map +1 -0
- package/dist/contexts/GraphEditContext.js +8 -0
- package/dist/contexts/GraphEditContext.js.map +1 -0
- package/dist/hooks/useUndoRedo.d.ts +75 -0
- package/dist/hooks/useUndoRedo.d.ts.map +1 -0
- package/dist/hooks/useUndoRedo.js +70 -0
- package/dist/hooks/useUndoRedo.js.map +1 -0
- package/dist/nodes/CustomNode.d.ts.map +1 -1
- package/dist/nodes/CustomNode.js +22 -5
- package/dist/nodes/CustomNode.js.map +1 -1
- package/package.json +1 -1
- package/src/components/GraphRenderer.tsx +372 -38
- package/src/contexts/GraphEditContext.tsx +21 -0
- package/src/hooks/useUndoRedo.ts +138 -0
- package/src/nodes/CustomNode.tsx +35 -3
|
@@ -52,6 +52,8 @@ import {
|
|
|
52
52
|
convertToXYFlowNodes,
|
|
53
53
|
convertToXYFlowEdges,
|
|
54
54
|
} from '../utils/graphConverter';
|
|
55
|
+
import { GraphEditProvider } from '../contexts/GraphEditContext';
|
|
56
|
+
import { useUndoRedo, type HistoryEntry } from '../hooks/useUndoRedo';
|
|
55
57
|
|
|
56
58
|
/**
|
|
57
59
|
* Context for providing a portal target for tooltips.
|
|
@@ -107,6 +109,14 @@ export interface GraphRendererHandle {
|
|
|
107
109
|
resetEditState: () => void;
|
|
108
110
|
/** Check if there are unsaved changes */
|
|
109
111
|
hasUnsavedChanges: () => boolean;
|
|
112
|
+
/** Whether there are changes that can be undone */
|
|
113
|
+
canUndo: () => boolean;
|
|
114
|
+
/** Whether there are changes that can be redone */
|
|
115
|
+
canRedo: () => boolean;
|
|
116
|
+
/** Undo the last change */
|
|
117
|
+
undo: () => void;
|
|
118
|
+
/** Redo the last undone change */
|
|
119
|
+
redo: () => void;
|
|
110
120
|
}
|
|
111
121
|
|
|
112
122
|
/** Base props shared by all render modes */
|
|
@@ -443,6 +453,12 @@ const CenterIndicator: React.FC<{ color: string }> = ({ color }) => {
|
|
|
443
453
|
};
|
|
444
454
|
|
|
445
455
|
/** Inner component receives normalized legacy format */
|
|
456
|
+
/** Ref for undo/redo functions that inner component will populate */
|
|
457
|
+
interface UndoRedoFunctionsRef {
|
|
458
|
+
applyUndo: () => void;
|
|
459
|
+
applyRedo: () => void;
|
|
460
|
+
}
|
|
461
|
+
|
|
446
462
|
interface GraphRendererInnerProps {
|
|
447
463
|
configuration: GraphConfiguration;
|
|
448
464
|
nodes: NodeState[];
|
|
@@ -465,6 +481,12 @@ interface GraphRendererInnerProps {
|
|
|
465
481
|
onPendingChangesChange?: (hasChanges: boolean) => void;
|
|
466
482
|
onEditStateChange?: (editState: EditState) => void;
|
|
467
483
|
editStateRef: React.MutableRefObject<EditState>;
|
|
484
|
+
resetVisualStateRef: React.MutableRefObject<(() => void) | null>;
|
|
485
|
+
undoRedoFunctionsRef: React.MutableRefObject<UndoRedoFunctionsRef | null>;
|
|
486
|
+
pushHistory: (entries: HistoryEntry[]) => void;
|
|
487
|
+
clearHistory: () => void;
|
|
488
|
+
undoFromStack: () => HistoryEntry[] | null;
|
|
489
|
+
redoFromStack: () => HistoryEntry[] | null;
|
|
468
490
|
onNodeClick?: (nodeId: string, event: React.MouseEvent) => void;
|
|
469
491
|
fitViewToNodeIds?: string[] | null;
|
|
470
492
|
fitViewPadding?: number;
|
|
@@ -498,6 +520,12 @@ const GraphRendererInner: React.FC<GraphRendererInnerProps> = ({
|
|
|
498
520
|
onPendingChangesChange,
|
|
499
521
|
onEditStateChange,
|
|
500
522
|
editStateRef,
|
|
523
|
+
resetVisualStateRef,
|
|
524
|
+
undoRedoFunctionsRef,
|
|
525
|
+
pushHistory,
|
|
526
|
+
clearHistory,
|
|
527
|
+
undoFromStack,
|
|
528
|
+
redoFromStack,
|
|
501
529
|
onNodeClick: onNodeClickProp,
|
|
502
530
|
fitViewToNodeIds,
|
|
503
531
|
fitViewPadding = 0.2,
|
|
@@ -650,6 +678,8 @@ const GraphRendererInner: React.FC<GraphRendererInnerProps> = ({
|
|
|
650
678
|
onPendingChangesChange?.(false);
|
|
651
679
|
// Clear animation state to prevent stale animations from affecting new edges
|
|
652
680
|
setAnimationState({ nodeAnimations: {}, edgeAnimations: {} });
|
|
681
|
+
// Clear undo/redo history when props change
|
|
682
|
+
clearHistory();
|
|
653
683
|
}
|
|
654
684
|
}, [propNodes, propEdges, editStateRef, onEditStateChange, onPendingChangesChange]);
|
|
655
685
|
|
|
@@ -669,6 +699,12 @@ const GraphRendererInner: React.FC<GraphRendererInnerProps> = ({
|
|
|
669
699
|
const nodes = localNodes;
|
|
670
700
|
const edges = editable ? localEdges : propEdges;
|
|
671
701
|
|
|
702
|
+
// Ref to track current xyflow nodes for undo/redo (set later via useEffect)
|
|
703
|
+
const xyflowNodesRef = useRef<Node<CustomNodeData>[]>([]);
|
|
704
|
+
|
|
705
|
+
// Ref to capture node positions at drag start (for accurate undo "before" values)
|
|
706
|
+
const dragStartPositionsRef = useRef<Map<string, { x: number; y: number }>>(new Map());
|
|
707
|
+
|
|
672
708
|
// Helper to check if there are pending changes
|
|
673
709
|
const checkHasChanges = useCallback((state: EditState): boolean => {
|
|
674
710
|
return (
|
|
@@ -704,6 +740,44 @@ const GraphRendererInner: React.FC<GraphRendererInnerProps> = ({
|
|
|
704
740
|
[editStateRef, onEditStateChange, onPendingChangesChange, checkHasChanges]
|
|
705
741
|
);
|
|
706
742
|
|
|
743
|
+
// Handler for node resize end - called from CustomNode via context
|
|
744
|
+
const handleNodeResizeEnd = useCallback(
|
|
745
|
+
(nodeId: string, dimensions: { width: number; height: number }) => {
|
|
746
|
+
if (!editable) return;
|
|
747
|
+
|
|
748
|
+
// Capture before dimensions for undo
|
|
749
|
+
const currentNode = xyflowNodesRef.current.find((n) => n.id === nodeId);
|
|
750
|
+
const beforeDimensions = currentNode
|
|
751
|
+
? { width: currentNode.width ?? 0, height: currentNode.height ?? 0 }
|
|
752
|
+
: { width: 0, height: 0 };
|
|
753
|
+
|
|
754
|
+
// Push to history
|
|
755
|
+
pushHistory([
|
|
756
|
+
{
|
|
757
|
+
type: 'dimension',
|
|
758
|
+
nodeId,
|
|
759
|
+
before: beforeDimensions,
|
|
760
|
+
after: dimensions,
|
|
761
|
+
},
|
|
762
|
+
]);
|
|
763
|
+
|
|
764
|
+
updateEditState((prev) => {
|
|
765
|
+
const newDimensions = new Map(prev.dimensionChanges);
|
|
766
|
+
newDimensions.set(nodeId, dimensions);
|
|
767
|
+
return { ...prev, dimensionChanges: newDimensions };
|
|
768
|
+
});
|
|
769
|
+
},
|
|
770
|
+
[editable, updateEditState, pushHistory]
|
|
771
|
+
);
|
|
772
|
+
|
|
773
|
+
// Memoize the context value to prevent unnecessary re-renders
|
|
774
|
+
const graphEditContextValue = useMemo(
|
|
775
|
+
() => ({
|
|
776
|
+
onNodeResizeEnd: handleNodeResizeEnd,
|
|
777
|
+
}),
|
|
778
|
+
[handleNodeResizeEnd]
|
|
779
|
+
);
|
|
780
|
+
|
|
707
781
|
// ============================================
|
|
708
782
|
// ALIGNMENT GUIDES
|
|
709
783
|
// ============================================
|
|
@@ -1008,6 +1082,7 @@ const GraphRendererInner: React.FC<GraphRendererInnerProps> = ({
|
|
|
1008
1082
|
const createEdge = useCallback(
|
|
1009
1083
|
(from: string, to: string, type: string, sourceHandle?: string, targetHandle?: string) => {
|
|
1010
1084
|
const edgeId = `${from}-${to}-${type}-${Date.now()}`;
|
|
1085
|
+
const now = Date.now();
|
|
1011
1086
|
|
|
1012
1087
|
// Add to local state with handle information
|
|
1013
1088
|
const newEdge: EdgeState & { sourceHandle?: string; targetHandle?: string } = {
|
|
@@ -1016,13 +1091,21 @@ const GraphRendererInner: React.FC<GraphRendererInnerProps> = ({
|
|
|
1016
1091
|
from,
|
|
1017
1092
|
to,
|
|
1018
1093
|
data: {},
|
|
1019
|
-
createdAt:
|
|
1020
|
-
updatedAt:
|
|
1094
|
+
createdAt: now,
|
|
1095
|
+
updatedAt: now,
|
|
1021
1096
|
sourceHandle,
|
|
1022
1097
|
targetHandle,
|
|
1023
1098
|
};
|
|
1024
1099
|
setLocalEdges((prev) => [...prev, newEdge]);
|
|
1025
1100
|
|
|
1101
|
+
// Push to history for undo
|
|
1102
|
+
pushHistory([
|
|
1103
|
+
{
|
|
1104
|
+
type: 'edgeCreate',
|
|
1105
|
+
edge: newEdge,
|
|
1106
|
+
},
|
|
1107
|
+
]);
|
|
1108
|
+
|
|
1026
1109
|
// Track the change
|
|
1027
1110
|
updateEditState((prev) => ({
|
|
1028
1111
|
...prev,
|
|
@@ -1032,7 +1115,7 @@ const GraphRendererInner: React.FC<GraphRendererInnerProps> = ({
|
|
|
1032
1115
|
],
|
|
1033
1116
|
}));
|
|
1034
1117
|
},
|
|
1035
|
-
[updateEditState]
|
|
1118
|
+
[updateEditState, pushHistory]
|
|
1036
1119
|
);
|
|
1037
1120
|
|
|
1038
1121
|
// Handle new connection from drag
|
|
@@ -1438,6 +1521,11 @@ const GraphRendererInner: React.FC<GraphRendererInnerProps> = ({
|
|
|
1438
1521
|
// Local xyflow nodes state for dragging
|
|
1439
1522
|
const [xyflowLocalNodes, setXyflowLocalNodes] = useState<Node<CustomNodeData>[]>(xyflowNodesBase);
|
|
1440
1523
|
|
|
1524
|
+
// Keep xyflowNodesRef in sync for undo/redo access to current node state
|
|
1525
|
+
useEffect(() => {
|
|
1526
|
+
xyflowNodesRef.current = xyflowLocalNodes;
|
|
1527
|
+
}, [xyflowLocalNodes]);
|
|
1528
|
+
|
|
1441
1529
|
// Sync when base node IDs change
|
|
1442
1530
|
const prevBaseNodesKeyRef = useRef(baseNodesKey);
|
|
1443
1531
|
useEffect(() => {
|
|
@@ -1505,6 +1593,7 @@ const GraphRendererInner: React.FC<GraphRendererInnerProps> = ({
|
|
|
1505
1593
|
}
|
|
1506
1594
|
}, [hasDraggableNodes, xyflowNodesBase, draggableNodeIds, updateNodeInternals]);
|
|
1507
1595
|
|
|
1596
|
+
|
|
1508
1597
|
// Handle node drag to show alignment guides
|
|
1509
1598
|
const handleNodeDrag = useCallback(
|
|
1510
1599
|
(_event: React.MouseEvent, node: Node) => {
|
|
@@ -1534,6 +1623,192 @@ const GraphRendererInner: React.FC<GraphRendererInnerProps> = ({
|
|
|
1534
1623
|
[onNodeDragStopProp]
|
|
1535
1624
|
);
|
|
1536
1625
|
|
|
1626
|
+
// ============================================
|
|
1627
|
+
// UNDO/REDO APPLY FUNCTIONS
|
|
1628
|
+
// ============================================
|
|
1629
|
+
|
|
1630
|
+
// Apply undo - reverse the effects of history entries
|
|
1631
|
+
const applyUndo = useCallback(() => {
|
|
1632
|
+
const batch = undoFromStack();
|
|
1633
|
+
if (!batch) return;
|
|
1634
|
+
|
|
1635
|
+
// Process entries in reverse order for undo
|
|
1636
|
+
for (const entry of batch.slice().reverse()) {
|
|
1637
|
+
switch (entry.type) {
|
|
1638
|
+
case 'position':
|
|
1639
|
+
// Restore previous position
|
|
1640
|
+
setXyflowLocalNodes((nodes) =>
|
|
1641
|
+
nodes.map((n) =>
|
|
1642
|
+
n.id === entry.nodeId
|
|
1643
|
+
? { ...n, position: entry.before }
|
|
1644
|
+
: n
|
|
1645
|
+
)
|
|
1646
|
+
);
|
|
1647
|
+
// Update edit state
|
|
1648
|
+
updateEditState((prev) => {
|
|
1649
|
+
const newPositions = new Map(prev.positionChanges);
|
|
1650
|
+
newPositions.set(entry.nodeId, entry.before);
|
|
1651
|
+
return { ...prev, positionChanges: newPositions };
|
|
1652
|
+
});
|
|
1653
|
+
break;
|
|
1654
|
+
|
|
1655
|
+
case 'dimension':
|
|
1656
|
+
// Restore previous dimensions
|
|
1657
|
+
setXyflowLocalNodes((nodes) =>
|
|
1658
|
+
nodes.map((n) =>
|
|
1659
|
+
n.id === entry.nodeId
|
|
1660
|
+
? { ...n, width: entry.before.width, height: entry.before.height }
|
|
1661
|
+
: n
|
|
1662
|
+
)
|
|
1663
|
+
);
|
|
1664
|
+
// Update edit state
|
|
1665
|
+
updateEditState((prev) => {
|
|
1666
|
+
const newDimensions = new Map(prev.dimensionChanges);
|
|
1667
|
+
newDimensions.set(entry.nodeId, entry.before);
|
|
1668
|
+
return { ...prev, dimensionChanges: newDimensions };
|
|
1669
|
+
});
|
|
1670
|
+
break;
|
|
1671
|
+
|
|
1672
|
+
case 'edgeCreate':
|
|
1673
|
+
// Remove the created edge
|
|
1674
|
+
setLocalEdges((edges) => edges.filter((e) => e.id !== entry.edge.id));
|
|
1675
|
+
// Update edit state
|
|
1676
|
+
updateEditState((prev) => ({
|
|
1677
|
+
...prev,
|
|
1678
|
+
createdEdges: prev.createdEdges.filter((e) => e.id !== entry.edge.id),
|
|
1679
|
+
}));
|
|
1680
|
+
break;
|
|
1681
|
+
|
|
1682
|
+
case 'edgeDelete':
|
|
1683
|
+
// Restore the deleted edge
|
|
1684
|
+
setLocalEdges((edges) => [...edges, entry.edge]);
|
|
1685
|
+
// Update edit state
|
|
1686
|
+
updateEditState((prev) => ({
|
|
1687
|
+
...prev,
|
|
1688
|
+
deletedEdges: prev.deletedEdges.filter((e) => e.id !== entry.edge.id),
|
|
1689
|
+
}));
|
|
1690
|
+
break;
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
}, [undoFromStack, updateEditState]);
|
|
1694
|
+
|
|
1695
|
+
// Apply redo - re-apply the effects of history entries
|
|
1696
|
+
const applyRedo = useCallback(() => {
|
|
1697
|
+
const batch = redoFromStack();
|
|
1698
|
+
if (!batch) return;
|
|
1699
|
+
|
|
1700
|
+
// Process entries in original order for redo
|
|
1701
|
+
for (const entry of batch) {
|
|
1702
|
+
switch (entry.type) {
|
|
1703
|
+
case 'position':
|
|
1704
|
+
// Apply new position
|
|
1705
|
+
setXyflowLocalNodes((nodes) =>
|
|
1706
|
+
nodes.map((n) =>
|
|
1707
|
+
n.id === entry.nodeId
|
|
1708
|
+
? { ...n, position: entry.after }
|
|
1709
|
+
: n
|
|
1710
|
+
)
|
|
1711
|
+
);
|
|
1712
|
+
// Update edit state
|
|
1713
|
+
updateEditState((prev) => {
|
|
1714
|
+
const newPositions = new Map(prev.positionChanges);
|
|
1715
|
+
newPositions.set(entry.nodeId, entry.after);
|
|
1716
|
+
return { ...prev, positionChanges: newPositions };
|
|
1717
|
+
});
|
|
1718
|
+
break;
|
|
1719
|
+
|
|
1720
|
+
case 'dimension':
|
|
1721
|
+
// Apply new dimensions
|
|
1722
|
+
setXyflowLocalNodes((nodes) =>
|
|
1723
|
+
nodes.map((n) =>
|
|
1724
|
+
n.id === entry.nodeId
|
|
1725
|
+
? { ...n, width: entry.after.width, height: entry.after.height }
|
|
1726
|
+
: n
|
|
1727
|
+
)
|
|
1728
|
+
);
|
|
1729
|
+
// Update edit state
|
|
1730
|
+
updateEditState((prev) => {
|
|
1731
|
+
const newDimensions = new Map(prev.dimensionChanges);
|
|
1732
|
+
newDimensions.set(entry.nodeId, entry.after);
|
|
1733
|
+
return { ...prev, dimensionChanges: newDimensions };
|
|
1734
|
+
});
|
|
1735
|
+
break;
|
|
1736
|
+
|
|
1737
|
+
case 'edgeCreate':
|
|
1738
|
+
// Re-create the edge
|
|
1739
|
+
setLocalEdges((edges) => [...edges, entry.edge]);
|
|
1740
|
+
// Update edit state
|
|
1741
|
+
updateEditState((prev) => ({
|
|
1742
|
+
...prev,
|
|
1743
|
+
createdEdges: [
|
|
1744
|
+
...prev.createdEdges,
|
|
1745
|
+
{
|
|
1746
|
+
id: entry.edge.id,
|
|
1747
|
+
from: entry.edge.from,
|
|
1748
|
+
to: entry.edge.to,
|
|
1749
|
+
type: entry.edge.type,
|
|
1750
|
+
sourceHandle: entry.edge.sourceHandle,
|
|
1751
|
+
targetHandle: entry.edge.targetHandle,
|
|
1752
|
+
},
|
|
1753
|
+
],
|
|
1754
|
+
}));
|
|
1755
|
+
break;
|
|
1756
|
+
|
|
1757
|
+
case 'edgeDelete':
|
|
1758
|
+
// Re-delete the edge
|
|
1759
|
+
setLocalEdges((edges) => edges.filter((e) => e.id !== entry.edge.id));
|
|
1760
|
+
// Update edit state
|
|
1761
|
+
updateEditState((prev) => ({
|
|
1762
|
+
...prev,
|
|
1763
|
+
deletedEdges: [
|
|
1764
|
+
...prev.deletedEdges,
|
|
1765
|
+
{
|
|
1766
|
+
id: entry.edge.id,
|
|
1767
|
+
from: entry.edge.from,
|
|
1768
|
+
to: entry.edge.to,
|
|
1769
|
+
type: entry.edge.type,
|
|
1770
|
+
},
|
|
1771
|
+
],
|
|
1772
|
+
}));
|
|
1773
|
+
break;
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
}, [redoFromStack, updateEditState]);
|
|
1777
|
+
|
|
1778
|
+
// Keyboard shortcuts for undo/redo
|
|
1779
|
+
useEffect(() => {
|
|
1780
|
+
if (!editable) return;
|
|
1781
|
+
|
|
1782
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
1783
|
+
// Cmd+Z (Mac) or Ctrl+Z (Windows/Linux) for undo
|
|
1784
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'z') {
|
|
1785
|
+
e.preventDefault();
|
|
1786
|
+
if (e.shiftKey) {
|
|
1787
|
+
// Cmd+Shift+Z for redo
|
|
1788
|
+
applyRedo();
|
|
1789
|
+
} else {
|
|
1790
|
+
applyUndo();
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
// Ctrl+Y for redo (Windows)
|
|
1794
|
+
if (e.ctrlKey && e.key === 'y') {
|
|
1795
|
+
e.preventDefault();
|
|
1796
|
+
applyRedo();
|
|
1797
|
+
}
|
|
1798
|
+
};
|
|
1799
|
+
|
|
1800
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
1801
|
+
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
1802
|
+
}, [editable, applyUndo, applyRedo]);
|
|
1803
|
+
|
|
1804
|
+
// Set undo/redo functions in ref for outer component access
|
|
1805
|
+
useEffect(() => {
|
|
1806
|
+
undoRedoFunctionsRef.current = {
|
|
1807
|
+
applyUndo,
|
|
1808
|
+
applyRedo,
|
|
1809
|
+
};
|
|
1810
|
+
}, [applyUndo, applyRedo, undoRedoFunctionsRef]);
|
|
1811
|
+
|
|
1537
1812
|
// Handle node changes (drag and resize events)
|
|
1538
1813
|
const handleNodesChange = useCallback(
|
|
1539
1814
|
(changes: NodeChange[]) => {
|
|
@@ -1613,54 +1888,72 @@ const GraphRendererInner: React.FC<GraphRendererInnerProps> = ({
|
|
|
1613
1888
|
});
|
|
1614
1889
|
}
|
|
1615
1890
|
|
|
1616
|
-
//
|
|
1891
|
+
// Capture positions at drag START for accurate undo "before" values
|
|
1892
|
+
const dragStartChanges = changes.filter(
|
|
1893
|
+
(change): change is NodeChange & {
|
|
1894
|
+
type: 'position';
|
|
1895
|
+
id: string;
|
|
1896
|
+
dragging: boolean;
|
|
1897
|
+
} =>
|
|
1898
|
+
change.type === 'position' &&
|
|
1899
|
+
'id' in change &&
|
|
1900
|
+
'dragging' in change &&
|
|
1901
|
+
change.dragging === true
|
|
1902
|
+
);
|
|
1903
|
+
|
|
1904
|
+
// For each node that just started dragging, capture its current position
|
|
1905
|
+
for (const change of dragStartChanges) {
|
|
1906
|
+
if (!dragStartPositionsRef.current.has(change.id)) {
|
|
1907
|
+
const currentNode = xyflowNodesRef.current.find((n) => n.id === change.id);
|
|
1908
|
+
if (currentNode) {
|
|
1909
|
+
dragStartPositionsRef.current.set(change.id, {
|
|
1910
|
+
x: currentNode.position.x,
|
|
1911
|
+
y: currentNode.position.y,
|
|
1912
|
+
});
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
// Track position changes on drag END
|
|
1617
1918
|
const positionChanges = changes.filter(
|
|
1618
1919
|
(
|
|
1619
1920
|
change
|
|
1620
1921
|
): change is NodeChange & {
|
|
1621
1922
|
type: 'position';
|
|
1923
|
+
id: string;
|
|
1622
1924
|
position: { x: number; y: number };
|
|
1623
1925
|
dragging: boolean;
|
|
1624
1926
|
} =>
|
|
1625
1927
|
change.type === 'position' &&
|
|
1928
|
+
'id' in change &&
|
|
1626
1929
|
'position' in change &&
|
|
1627
1930
|
change.position !== undefined &&
|
|
1628
1931
|
'dragging' in change &&
|
|
1629
1932
|
change.dragging === false
|
|
1630
1933
|
);
|
|
1631
1934
|
|
|
1632
|
-
//
|
|
1633
|
-
|
|
1634
|
-
(
|
|
1635
|
-
change
|
|
1636
|
-
): change is NodeChange & {
|
|
1637
|
-
type: 'dimensions';
|
|
1638
|
-
dimensions: { width: number; height: number };
|
|
1639
|
-
resizing: boolean;
|
|
1640
|
-
} =>
|
|
1641
|
-
change.type === 'dimensions' &&
|
|
1642
|
-
'dimensions' in change &&
|
|
1643
|
-
change.dimensions !== undefined &&
|
|
1644
|
-
'resizing' in change &&
|
|
1645
|
-
change.resizing === false
|
|
1646
|
-
);
|
|
1647
|
-
|
|
1648
|
-
if (dimensionChanges.length > 0) {
|
|
1649
|
-
updateEditState((prev) => {
|
|
1650
|
-
const newDimensions = new Map(prev.dimensionChanges);
|
|
1651
|
-
for (const change of dimensionChanges) {
|
|
1652
|
-
if (change.dimensions) {
|
|
1653
|
-
newDimensions.set(change.id, {
|
|
1654
|
-
width: Math.round(change.dimensions.width),
|
|
1655
|
-
height: Math.round(change.dimensions.height),
|
|
1656
|
-
});
|
|
1657
|
-
}
|
|
1658
|
-
}
|
|
1659
|
-
return { ...prev, dimensionChanges: newDimensions };
|
|
1660
|
-
});
|
|
1661
|
-
}
|
|
1935
|
+
// Note: Dimension changes are tracked via onResizeEnd callback in GraphEditContext,
|
|
1936
|
+
// not through onNodesChange (which only fires with resizing=true during drag).
|
|
1662
1937
|
|
|
1663
1938
|
if (positionChanges.length > 0) {
|
|
1939
|
+
// Use positions captured at drag start for accurate undo
|
|
1940
|
+
const historyEntries: HistoryEntry[] = [];
|
|
1941
|
+
for (const change of positionChanges) {
|
|
1942
|
+
const beforePos = dragStartPositionsRef.current.get(change.id) ?? { x: 0, y: 0 };
|
|
1943
|
+
historyEntries.push({
|
|
1944
|
+
type: 'position',
|
|
1945
|
+
nodeId: change.id,
|
|
1946
|
+
before: beforePos,
|
|
1947
|
+
after: {
|
|
1948
|
+
x: Math.round(change.position.x),
|
|
1949
|
+
y: Math.round(change.position.y),
|
|
1950
|
+
},
|
|
1951
|
+
});
|
|
1952
|
+
// Clear the drag start position for this node
|
|
1953
|
+
dragStartPositionsRef.current.delete(change.id);
|
|
1954
|
+
}
|
|
1955
|
+
pushHistory(historyEntries);
|
|
1956
|
+
|
|
1664
1957
|
updateEditState((prev) => {
|
|
1665
1958
|
const newPositions = new Map(prev.positionChanges);
|
|
1666
1959
|
for (const change of positionChanges) {
|
|
@@ -1673,7 +1966,7 @@ const GraphRendererInner: React.FC<GraphRendererInnerProps> = ({
|
|
|
1673
1966
|
});
|
|
1674
1967
|
}
|
|
1675
1968
|
},
|
|
1676
|
-
[editable, updateEditState, selectedNodeIds, draggableNodeIds]
|
|
1969
|
+
[editable, updateEditState, selectedNodeIds, draggableNodeIds, pushHistory]
|
|
1677
1970
|
);
|
|
1678
1971
|
|
|
1679
1972
|
const xyflowEdgesBase = useMemo(() => {
|
|
@@ -1742,6 +2035,17 @@ const GraphRendererInner: React.FC<GraphRendererInnerProps> = ({
|
|
|
1742
2035
|
}
|
|
1743
2036
|
}, [baseEdgesKey, xyflowEdgesBase]);
|
|
1744
2037
|
|
|
2038
|
+
// Set the reset visual state function for use by resetEditState
|
|
2039
|
+
// This resets both nodes and edges to their original state
|
|
2040
|
+
useEffect(() => {
|
|
2041
|
+
resetVisualStateRef.current = () => {
|
|
2042
|
+
setXyflowLocalNodes(xyflowNodesBase);
|
|
2043
|
+
setXyflowLocalEdges(xyflowEdgesBase);
|
|
2044
|
+
// Notify parent that changes have been cleared
|
|
2045
|
+
onPendingChangesChange?.(false);
|
|
2046
|
+
};
|
|
2047
|
+
}, [xyflowNodesBase, xyflowEdgesBase, onPendingChangesChange]);
|
|
2048
|
+
|
|
1745
2049
|
// Use local edges in edit mode, base edges otherwise
|
|
1746
2050
|
const xyflowEdges = editable ? xyflowLocalEdges : xyflowEdgesBase;
|
|
1747
2051
|
|
|
@@ -1830,7 +2134,7 @@ const GraphRendererInner: React.FC<GraphRendererInnerProps> = ({
|
|
|
1830
2134
|
// ============================================
|
|
1831
2135
|
|
|
1832
2136
|
return (
|
|
1833
|
-
|
|
2137
|
+
<GraphEditProvider value={graphEditContextValue}>
|
|
1834
2138
|
<ReactFlow
|
|
1835
2139
|
key={`${baseNodesKey}-${baseEdgesKey}`}
|
|
1836
2140
|
nodes={xyflowNodes}
|
|
@@ -1971,7 +2275,7 @@ const GraphRendererInner: React.FC<GraphRendererInnerProps> = ({
|
|
|
1971
2275
|
</button>
|
|
1972
2276
|
</div>
|
|
1973
2277
|
)}
|
|
1974
|
-
|
|
2278
|
+
</GraphEditProvider>
|
|
1975
2279
|
);
|
|
1976
2280
|
};
|
|
1977
2281
|
|
|
@@ -2184,6 +2488,22 @@ export const GraphRenderer = forwardRef<GraphRendererHandle, GraphRendererProps>
|
|
|
2184
2488
|
// Internal edit state ref - must be before any conditional returns
|
|
2185
2489
|
const editStateRef = useRef<EditState>(createEmptyEditState());
|
|
2186
2490
|
|
|
2491
|
+
// Ref to hold the reset visual state function - will be set after xyflowLocalNodes is defined
|
|
2492
|
+
const resetVisualStateRef = useRef<(() => void) | null>(null);
|
|
2493
|
+
|
|
2494
|
+
// Undo/redo management
|
|
2495
|
+
const {
|
|
2496
|
+
canUndo: canUndoState,
|
|
2497
|
+
canRedo: canRedoState,
|
|
2498
|
+
undo: undoFromStack,
|
|
2499
|
+
redo: redoFromStack,
|
|
2500
|
+
pushHistory,
|
|
2501
|
+
clearHistory,
|
|
2502
|
+
} = useUndoRedo();
|
|
2503
|
+
|
|
2504
|
+
// Ref to hold undo/redo apply functions - will be set by inner component
|
|
2505
|
+
const undoRedoFunctionsRef = useRef<UndoRedoFunctionsRef | null>(null);
|
|
2506
|
+
|
|
2187
2507
|
// Expose imperative handle - must be before any conditional returns
|
|
2188
2508
|
useImperativeHandle(
|
|
2189
2509
|
ref,
|
|
@@ -2227,6 +2547,10 @@ export const GraphRenderer = forwardRef<GraphRendererHandle, GraphRendererProps>
|
|
|
2227
2547
|
},
|
|
2228
2548
|
resetEditState: () => {
|
|
2229
2549
|
editStateRef.current = createEmptyEditState();
|
|
2550
|
+
// Also reset visual state (node positions/dimensions) if available
|
|
2551
|
+
resetVisualStateRef.current?.();
|
|
2552
|
+
// Clear undo/redo history
|
|
2553
|
+
clearHistory();
|
|
2230
2554
|
},
|
|
2231
2555
|
hasUnsavedChanges: (): boolean => {
|
|
2232
2556
|
const state = editStateRef.current;
|
|
@@ -2239,8 +2563,12 @@ export const GraphRenderer = forwardRef<GraphRendererHandle, GraphRendererProps>
|
|
|
2239
2563
|
state.deletedEdges.length > 0
|
|
2240
2564
|
);
|
|
2241
2565
|
},
|
|
2566
|
+
canUndo: () => canUndoState,
|
|
2567
|
+
canRedo: () => canRedoState,
|
|
2568
|
+
undo: () => undoRedoFunctionsRef.current?.applyUndo(),
|
|
2569
|
+
redo: () => undoRedoFunctionsRef.current?.applyRedo(),
|
|
2242
2570
|
}),
|
|
2243
|
-
[]
|
|
2571
|
+
[canUndoState, canRedoState, clearHistory]
|
|
2244
2572
|
);
|
|
2245
2573
|
|
|
2246
2574
|
// Validate we have required data
|
|
@@ -2317,6 +2645,12 @@ export const GraphRenderer = forwardRef<GraphRendererHandle, GraphRendererProps>
|
|
|
2317
2645
|
editable={editable}
|
|
2318
2646
|
onPendingChangesChange={onPendingChangesChange}
|
|
2319
2647
|
editStateRef={editStateRef}
|
|
2648
|
+
resetVisualStateRef={resetVisualStateRef}
|
|
2649
|
+
undoRedoFunctionsRef={undoRedoFunctionsRef}
|
|
2650
|
+
pushHistory={pushHistory}
|
|
2651
|
+
clearHistory={clearHistory}
|
|
2652
|
+
undoFromStack={undoFromStack}
|
|
2653
|
+
redoFromStack={redoFromStack}
|
|
2320
2654
|
onNodeClick={onNodeClick}
|
|
2321
2655
|
fitViewToNodeIds={fitViewToNodeIds}
|
|
2322
2656
|
fitViewPadding={fitViewPadding}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import React, { createContext, useContext } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface GraphEditContextValue {
|
|
4
|
+
/** Called when a node resize operation completes */
|
|
5
|
+
onNodeResizeEnd?: (nodeId: string, dimensions: { width: number; height: number }) => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const GraphEditContext = createContext<GraphEditContextValue>({});
|
|
9
|
+
|
|
10
|
+
export const GraphEditProvider: React.FC<{
|
|
11
|
+
children: React.ReactNode;
|
|
12
|
+
value: GraphEditContextValue;
|
|
13
|
+
}> = ({ children, value }) => {
|
|
14
|
+
return (
|
|
15
|
+
<GraphEditContext.Provider value={value}>
|
|
16
|
+
{children}
|
|
17
|
+
</GraphEditContext.Provider>
|
|
18
|
+
);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const useGraphEdit = () => useContext(GraphEditContext);
|