fix: per-tab undo/redo history, stale closure in node ops, zoom animation
1. Per-tab history: Changed reducer from single HistoryState to Record<string, HistoryState> keyed by tab ID. Undo/redo now only affects the active tab. Creating a new tab initializes its own history; closing a tab cleans up its history. 2. Stale closure fix: onDropComponent, onConnect, deleteSelectedNodes, and duplicateSelectedNodes now use setTransactionTabs directly to read both nodes and edges from the same state snapshot, preventing inconsistent history entries. 3. Zoom animation: handleZoomIn/handleZoomOut now use .then() on the zoomIn()/zoomOut() promises to read the zoom level after animation completes, preventing stale zoom display. Co-Authored-By: Nakamoto, S <defi@defi-oracle.io>
This commit is contained in:
141
src/App.tsx
141
src/App.tsx
@@ -66,18 +66,23 @@ export default function App() {
|
||||
}));
|
||||
}, [activeTransactionId]);
|
||||
|
||||
// Undo/redo — combined into single state to prevent index/entries divergence
|
||||
// Undo/redo — per-tab history to prevent cross-tab state corruption
|
||||
type HistoryState = { entries: HistoryEntry[]; index: number };
|
||||
type PerTabHistoryState = Record<string, HistoryState>;
|
||||
type HistoryAction =
|
||||
| { type: 'push'; nodes: Node[]; edges: Edge[] }
|
||||
| { type: 'undo' }
|
||||
| { type: 'redo' }
|
||||
| { type: 'reset' };
|
||||
| { type: 'push'; tabId: string; nodes: Node[]; edges: Edge[] }
|
||||
| { type: 'undo'; tabId: string }
|
||||
| { type: 'redo'; tabId: string }
|
||||
| { type: 'reset'; tabId: string }
|
||||
| { type: 'remove'; tabId: string };
|
||||
|
||||
const historyReducer = useCallback((state: HistoryState, action: HistoryAction): HistoryState => {
|
||||
const defaultHistory: HistoryState = { entries: [{ nodes: [], edges: [] }], index: 0 };
|
||||
|
||||
const historyReducer = useCallback((state: PerTabHistoryState, action: HistoryAction): PerTabHistoryState => {
|
||||
const tabState = state[action.tabId] || defaultHistory;
|
||||
switch (action.type) {
|
||||
case 'push': {
|
||||
const trimmed = state.entries.slice(0, state.index + 1);
|
||||
const trimmed = tabState.entries.slice(0, tabState.index + 1);
|
||||
const entry = { nodes: JSON.parse(JSON.stringify(action.nodes)), edges: JSON.parse(JSON.stringify(action.edges)) };
|
||||
const next = [...trimmed, entry];
|
||||
let newIndex = trimmed.length;
|
||||
@@ -85,44 +90,51 @@ export default function App() {
|
||||
next.shift();
|
||||
newIndex = next.length - 1;
|
||||
}
|
||||
return { entries: next, index: newIndex };
|
||||
return { ...state, [action.tabId]: { entries: next, index: newIndex } };
|
||||
}
|
||||
case 'undo': {
|
||||
if (state.index <= 0) return state;
|
||||
return { ...state, index: state.index - 1 };
|
||||
if (tabState.index <= 0) return state;
|
||||
return { ...state, [action.tabId]: { ...tabState, index: tabState.index - 1 } };
|
||||
}
|
||||
case 'redo': {
|
||||
if (state.index >= state.entries.length - 1) return state;
|
||||
return { ...state, index: state.index + 1 };
|
||||
if (tabState.index >= tabState.entries.length - 1) return state;
|
||||
return { ...state, [action.tabId]: { ...tabState, index: tabState.index + 1 } };
|
||||
}
|
||||
case 'reset': {
|
||||
return { entries: [{ nodes: [], edges: [] }], index: 0 };
|
||||
return { ...state, [action.tabId]: { entries: [{ nodes: [], edges: [] }], index: 0 } };
|
||||
}
|
||||
case 'remove': {
|
||||
const { [action.tabId]: _, ...rest } = state;
|
||||
return rest;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [historyState, dispatchHistory] = useReducer(historyReducer, { entries: [{ nodes: [], edges: [] }], index: 0 });
|
||||
const history = historyState.entries;
|
||||
const historyIndex = historyState.index;
|
||||
const pushHistory = useCallback((n: Node[], e: Edge[]) => {
|
||||
dispatchHistory({ type: 'push', nodes: n, edges: e });
|
||||
const [historyState, dispatchHistory] = useReducer(historyReducer, { 'tx-1': { entries: [{ nodes: [], edges: [] }], index: 0 } });
|
||||
const activeHistory = historyState[activeTransactionId] || defaultHistory;
|
||||
const history = activeHistory.entries;
|
||||
const historyIndex = activeHistory.index;
|
||||
const pushHistory = useCallback((tabId: string, n: Node[], e: Edge[]) => {
|
||||
dispatchHistory({ type: 'push', tabId, nodes: n, edges: e });
|
||||
}, []);
|
||||
|
||||
const undo = useCallback(() => {
|
||||
const entry = historyState.entries[historyState.index - 1];
|
||||
if (!entry || historyState.index <= 0) return;
|
||||
dispatchHistory({ type: 'undo' });
|
||||
const tabHistory = historyState[activeTransactionId] || defaultHistory;
|
||||
const entry = tabHistory.entries[tabHistory.index - 1];
|
||||
if (!entry || tabHistory.index <= 0) return;
|
||||
dispatchHistory({ type: 'undo', tabId: activeTransactionId });
|
||||
setNodes(JSON.parse(JSON.stringify(entry.nodes)));
|
||||
setEdges(JSON.parse(JSON.stringify(entry.edges)));
|
||||
}, [historyState, setNodes, setEdges]);
|
||||
}, [historyState, activeTransactionId, setNodes, setEdges]);
|
||||
|
||||
const redo = useCallback(() => {
|
||||
const entry = historyState.entries[historyState.index + 1];
|
||||
if (!entry || historyState.index >= historyState.entries.length - 1) return;
|
||||
dispatchHistory({ type: 'redo' });
|
||||
const tabHistory = historyState[activeTransactionId] || defaultHistory;
|
||||
const entry = tabHistory.entries[tabHistory.index + 1];
|
||||
if (!entry || tabHistory.index >= tabHistory.entries.length - 1) return;
|
||||
dispatchHistory({ type: 'redo', tabId: activeTransactionId });
|
||||
setNodes(JSON.parse(JSON.stringify(entry.nodes)));
|
||||
setEdges(JSON.parse(JSON.stringify(entry.edges)));
|
||||
}, [historyState, setNodes, setEdges]);
|
||||
}, [historyState, activeTransactionId, setNodes, setEdges]);
|
||||
|
||||
// Selected nodes
|
||||
const [selectedNodeIds, setSelectedNodeIds] = useState<Set<string>>(new Set());
|
||||
@@ -224,25 +236,29 @@ export default function App() {
|
||||
position,
|
||||
data: { label: item.label, category: item.category, icon: item.icon, color: item.color, status: undefined },
|
||||
};
|
||||
setNodes(prev => {
|
||||
const next = [...prev, newNode];
|
||||
pushHistory(next, edges);
|
||||
return next;
|
||||
});
|
||||
const tabId = activeTransactionId;
|
||||
setTransactionTabs(prev => prev.map(t => {
|
||||
if (t.id !== tabId) return t;
|
||||
const nextNodes = [...t.nodes, newNode];
|
||||
pushHistory(tabId, nextNodes, t.edges);
|
||||
return { ...t, nodes: nextNodes };
|
||||
}));
|
||||
addRecentComponent(item.id);
|
||||
addTerminalEntry('info', 'canvas', `Node added: ${item.label}`);
|
||||
addAuditEntry('NODE_ADD', `Added ${item.label} node to canvas`);
|
||||
}, [setNodes, edges, pushHistory, addRecentComponent, addTerminalEntry, addAuditEntry]);
|
||||
}, [activeTransactionId, pushHistory, addRecentComponent, addTerminalEntry, addAuditEntry]);
|
||||
|
||||
const onConnect = useCallback((params: Connection) => {
|
||||
setEdges(prev => {
|
||||
const next = addEdge({ ...params, animated: true, style: { stroke: '#3b82f6', strokeWidth: 2 } }, prev);
|
||||
pushHistory(nodes, next);
|
||||
return next;
|
||||
});
|
||||
const tabId = activeTransactionId;
|
||||
setTransactionTabs(prev => prev.map(t => {
|
||||
if (t.id !== tabId) return t;
|
||||
const nextEdges = addEdge({ ...params, animated: true, style: { stroke: '#3b82f6', strokeWidth: 2 } }, t.edges);
|
||||
pushHistory(tabId, t.nodes, nextEdges);
|
||||
return { ...t, edges: nextEdges };
|
||||
}));
|
||||
addTerminalEntry('info', 'canvas', `Edge connected: ${params.source} → ${params.target}`);
|
||||
addAuditEntry('EDGE_CREATE', `Connection created`);
|
||||
}, [setEdges, nodes, pushHistory, addTerminalEntry, addAuditEntry]);
|
||||
}, [activeTransactionId, pushHistory, addTerminalEntry, addAuditEntry]);
|
||||
|
||||
const onNodesChange = useCallback((changes: NodeChange[]) => {
|
||||
setNodes((prev: Node[]) => applyNodeChanges(changes, prev));
|
||||
@@ -258,34 +274,38 @@ export default function App() {
|
||||
|
||||
const deleteSelectedNodes = useCallback(() => {
|
||||
if (selectedNodeIds.size === 0) return;
|
||||
setNodes(prev => {
|
||||
const next = prev.filter(n => !selectedNodeIds.has(n.id));
|
||||
pushHistory(next, edges.filter(e => !selectedNodeIds.has(e.source) && !selectedNodeIds.has(e.target)));
|
||||
return next;
|
||||
});
|
||||
setEdges(prev => prev.filter(e => !selectedNodeIds.has(e.source) && !selectedNodeIds.has(e.target)));
|
||||
const tabId = activeTransactionId;
|
||||
setTransactionTabs(prev => prev.map(t => {
|
||||
if (t.id !== tabId) return t;
|
||||
const nextNodes = t.nodes.filter(n => !selectedNodeIds.has(n.id));
|
||||
const nextEdges = t.edges.filter(e => !selectedNodeIds.has(e.source) && !selectedNodeIds.has(e.target));
|
||||
pushHistory(tabId, nextNodes, nextEdges);
|
||||
return { ...t, nodes: nextNodes, edges: nextEdges };
|
||||
}));
|
||||
addTerminalEntry('info', 'canvas', `Deleted ${selectedNodeIds.size} node(s)`);
|
||||
addAuditEntry('NODE_DELETE', `Removed ${selectedNodeIds.size} node(s)`);
|
||||
setSelectedNodeIds(new Set());
|
||||
}, [selectedNodeIds, setNodes, setEdges, edges, pushHistory, addTerminalEntry, addAuditEntry]);
|
||||
}, [selectedNodeIds, activeTransactionId, pushHistory, addTerminalEntry, addAuditEntry]);
|
||||
|
||||
const duplicateSelectedNodes = useCallback(() => {
|
||||
if (selectedNodeIds.size === 0) return;
|
||||
const selected = nodes.filter(n => selectedNodeIds.has(n.id));
|
||||
const newNodes = selected.map(n => ({
|
||||
...n,
|
||||
id: `node_${nodeIdCounter.current++}`,
|
||||
position: { x: n.position.x + 40, y: n.position.y + 40 },
|
||||
selected: false,
|
||||
const tabId = activeTransactionId;
|
||||
setTransactionTabs(prev => prev.map(t => {
|
||||
if (t.id !== tabId) return t;
|
||||
const selected = t.nodes.filter(n => selectedNodeIds.has(n.id));
|
||||
const newNodes = selected.map(n => ({
|
||||
...n,
|
||||
id: `node_${nodeIdCounter.current++}`,
|
||||
position: { x: n.position.x + 40, y: n.position.y + 40 },
|
||||
selected: false,
|
||||
}));
|
||||
const nextNodes = [...t.nodes, ...newNodes];
|
||||
pushHistory(tabId, nextNodes, t.edges);
|
||||
return { ...t, nodes: nextNodes };
|
||||
}));
|
||||
setNodes(prev => {
|
||||
const next = [...prev, ...newNodes];
|
||||
pushHistory(next, edges);
|
||||
return next;
|
||||
});
|
||||
addTerminalEntry('info', 'canvas', `Duplicated ${selected.length} node(s)`);
|
||||
addAuditEntry('NODE_DUPLICATE', `Duplicated ${selected.length} node(s)`);
|
||||
}, [selectedNodeIds, nodes, setNodes, edges, pushHistory, addTerminalEntry, addAuditEntry]);
|
||||
addTerminalEntry('info', 'canvas', `Duplicated node(s)`);
|
||||
addAuditEntry('NODE_DUPLICATE', `Duplicated node(s)`);
|
||||
}, [selectedNodeIds, activeTransactionId, pushHistory, addTerminalEntry, addAuditEntry]);
|
||||
|
||||
// Validate
|
||||
const runValidation = useCallback(() => {
|
||||
@@ -378,13 +398,14 @@ export default function App() {
|
||||
const id = `tx-${Date.now()}`;
|
||||
setTransactionTabs(prev => [...prev, { id, name: `Transaction ${prev.length + 1}`, nodes: [], edges: [] }]);
|
||||
setActiveTransactionId(id);
|
||||
dispatchHistory({ type: 'reset' });
|
||||
dispatchHistory({ type: 'reset', tabId: id });
|
||||
addTerminalEntry('info', 'system', 'New transaction tab created');
|
||||
}, [addTerminalEntry]);
|
||||
|
||||
const closeTransactionTab = useCallback((id: string) => {
|
||||
if (transactionTabs.length <= 1) return;
|
||||
setTransactionTabs(prev => prev.filter(t => t.id !== id));
|
||||
dispatchHistory({ type: 'remove', tabId: id });
|
||||
if (activeTransactionId === id) {
|
||||
const remaining = transactionTabs.filter(t => t.id !== id);
|
||||
setActiveTransactionId(remaining[0].id);
|
||||
|
||||
@@ -58,7 +58,7 @@ interface CanvasProps {
|
||||
onCloseTab: (id: string) => void;
|
||||
splitView: boolean;
|
||||
onToggleSplitView: () => void;
|
||||
pushHistory: (nodes: Node[], edges: Edge[]) => void;
|
||||
pushHistory: (tabId: string, nodes: Node[], edges: Edge[]) => void;
|
||||
}
|
||||
|
||||
function CanvasInner({
|
||||
@@ -105,8 +105,8 @@ function CanvasInner({
|
||||
setZoomLevel(Math.round(zoom * 100));
|
||||
}, [reactFlowInstance]);
|
||||
|
||||
const handleZoomIn = () => { reactFlowInstance.zoomIn(); setZoomLevel(Math.round(reactFlowInstance.getZoom() * 100)); };
|
||||
const handleZoomOut = () => { reactFlowInstance.zoomOut(); setZoomLevel(Math.round(reactFlowInstance.getZoom() * 100)); };
|
||||
const handleZoomIn = () => { reactFlowInstance.zoomIn().then(() => setZoomLevel(Math.round(reactFlowInstance.getZoom() * 100))); };
|
||||
const handleZoomOut = () => { reactFlowInstance.zoomOut().then(() => setZoomLevel(Math.round(reactFlowInstance.getZoom() * 100))); };
|
||||
const handleFitView = () => { reactFlowInstance.fitView(); setTimeout(() => setZoomLevel(Math.round(reactFlowInstance.getZoom() * 100)), 100); };
|
||||
|
||||
const errorCount = nodes.filter(n => (n.data as Record<string, unknown>).status === 'error').length;
|
||||
|
||||
Reference in New Issue
Block a user