Add undo/redo support for node graph editor

This commit is contained in:
Skyler Lehmkuhl 2025-10-28 09:53:57 -04:00
parent 9d6eaa5bba
commit a379266f99
2 changed files with 526 additions and 17 deletions

View File

@ -1957,4 +1957,354 @@ export const actions = {
updateMenu();
},
},
// Node graph actions
graphAddNode: {
create: (trackId, nodeType, position, nodeId, backendId) => {
redoStack.length = 0;
let action = {
trackId: trackId,
nodeType: nodeType,
position: position,
nodeId: nodeId, // Frontend node ID from Drawflow
backendId: backendId
};
undoStack.push({ name: "graphAddNode", action: action });
actions.graphAddNode.execute(action);
updateMenu();
},
execute: async (action) => {
// Re-add node via Tauri and reload frontend
const result = await invoke('graph_add_node', {
trackId: action.trackId,
nodeType: action.nodeType,
posX: action.position.x,
posY: action.position.y
});
// Reload the entire graph to show the restored node
if (context.reloadNodeEditor) {
await context.reloadNodeEditor();
}
},
rollback: async (action) => {
// Remove node from backend
await invoke('graph_remove_node', {
trackId: action.trackId,
nodeId: action.backendId
});
// Remove from frontend
if (context.nodeEditor) {
context.nodeEditor.removeNodeId(`node-${action.nodeId}`);
}
},
},
graphRemoveNode: {
create: (trackId, nodeId, backendId, nodeData) => {
redoStack.length = 0;
let action = {
trackId: trackId,
nodeId: nodeId,
backendId: backendId,
nodeData: nodeData, // Store full node data for restoration
};
undoStack.push({ name: "graphRemoveNode", action: action });
actions.graphRemoveNode.execute(action);
updateMenu();
},
execute: async (action) => {
await invoke('graph_remove_node', {
trackId: action.trackId,
nodeId: action.backendId
});
if (context.nodeEditor) {
context.nodeEditor.removeNodeId(`node-${action.nodeId}`);
}
},
rollback: async (action) => {
// Re-add node to backend
const result = await invoke('graph_add_node', {
trackId: action.trackId,
nodeType: action.nodeData.nodeType,
posX: action.nodeData.position.x,
posY: action.nodeData.position.y
});
// Store new backend ID
const newBackendId = result.node_id || result;
// Re-add to frontend via reloadGraph
if (context.reloadNodeEditor) {
await context.reloadNodeEditor();
}
},
},
graphAddConnection: {
create: (trackId, fromNode, fromPort, toNode, toPort, frontendFromId, frontendToId, fromPortClass, toPortClass) => {
redoStack.length = 0;
let action = {
trackId: trackId,
fromNode: fromNode,
fromPort: fromPort,
toNode: toNode,
toPort: toPort,
frontendFromId: frontendFromId,
frontendToId: frontendToId,
fromPortClass: fromPortClass,
toPortClass: toPortClass
};
undoStack.push({ name: "graphAddConnection", action: action });
actions.graphAddConnection.execute(action);
updateMenu();
},
execute: async (action) => {
// Suppress action recording during undo/redo
if (context.nodeEditorState) {
context.nodeEditorState.suppressActionRecording = true;
}
try {
await invoke('graph_connect', {
trackId: action.trackId,
fromNode: action.fromNode,
fromPort: action.fromPort,
toNode: action.toNode,
toPort: action.toPort
});
// Add connection in frontend only if it doesn't exist
if (context.nodeEditor) {
const inputNode = context.nodeEditor.getNodeFromId(action.frontendToId);
const inputConnections = inputNode?.inputs[action.toPortClass]?.connections;
const alreadyConnected = inputConnections?.some(conn =>
conn.node === action.frontendFromId && conn.input === action.fromPortClass
);
if (!alreadyConnected) {
context.nodeEditor.addConnection(
action.frontendFromId,
action.frontendToId,
action.fromPortClass,
action.toPortClass
);
}
}
} finally {
if (context.nodeEditorState) {
context.nodeEditorState.suppressActionRecording = false;
}
}
},
rollback: async (action) => {
// Suppress action recording during undo/redo
if (context.nodeEditorState) {
context.nodeEditorState.suppressActionRecording = true;
}
try {
await invoke('graph_disconnect', {
trackId: action.trackId,
fromNode: action.fromNode,
fromPort: action.fromPort,
toNode: action.toNode,
toPort: action.toPort
});
// Remove from frontend
if (context.nodeEditor) {
context.nodeEditor.removeSingleConnection(
action.frontendFromId,
action.frontendToId,
action.fromPortClass,
action.toPortClass
);
}
} finally {
if (context.nodeEditorState) {
context.nodeEditorState.suppressActionRecording = false;
}
}
},
},
graphRemoveConnection: {
create: (trackId, fromNode, fromPort, toNode, toPort, frontendFromId, frontendToId, fromPortClass, toPortClass) => {
redoStack.length = 0;
let action = {
trackId: trackId,
fromNode: fromNode,
fromPort: fromPort,
toNode: toNode,
toPort: toPort,
frontendFromId: frontendFromId,
frontendToId: frontendToId,
fromPortClass: fromPortClass,
toPortClass: toPortClass
};
undoStack.push({ name: "graphRemoveConnection", action: action });
actions.graphRemoveConnection.execute(action);
updateMenu();
},
execute: async (action) => {
// Suppress action recording during undo/redo
if (context.nodeEditorState) {
context.nodeEditorState.suppressActionRecording = true;
}
try {
await invoke('graph_disconnect', {
trackId: action.trackId,
fromNode: action.fromNode,
fromPort: action.fromPort,
toNode: action.toNode,
toPort: action.toPort
});
if (context.nodeEditor) {
context.nodeEditor.removeSingleConnection(
action.frontendFromId,
action.frontendToId,
action.fromPortClass,
action.toPortClass
);
}
} finally {
if (context.nodeEditorState) {
context.nodeEditorState.suppressActionRecording = false;
}
}
},
rollback: async (action) => {
// Suppress action recording during undo/redo
if (context.nodeEditorState) {
context.nodeEditorState.suppressActionRecording = true;
}
try {
await invoke('graph_connect', {
trackId: action.trackId,
fromNode: action.fromNode,
fromPort: action.fromPort,
toNode: action.toNode,
toPort: action.toPort
});
// Re-add connection in frontend
if (context.nodeEditor) {
context.nodeEditor.addConnection(
action.frontendFromId,
action.frontendToId,
action.fromPortClass,
action.toPortClass
);
}
} finally {
if (context.nodeEditorState) {
context.nodeEditorState.suppressActionRecording = false;
}
}
},
},
graphSetParameter: {
initialize: (trackId, nodeId, paramId, frontendNodeId, currentValue) => {
return {
trackId: trackId,
nodeId: nodeId,
paramId: paramId,
frontendNodeId: frontendNodeId,
oldValue: currentValue,
};
},
finalize: (action, newValue) => {
action.newValue = newValue;
// Only record if value actually changed
if (action.oldValue !== action.newValue) {
undoStack.push({ name: "graphSetParameter", action: action });
updateMenu();
}
},
create: (trackId, nodeId, paramId, frontendNodeId, newValue, oldValue) => {
redoStack.length = 0;
let action = {
trackId: trackId,
nodeId: nodeId,
paramId: paramId,
frontendNodeId: frontendNodeId,
newValue: newValue,
oldValue: oldValue,
};
undoStack.push({ name: "graphSetParameter", action: action });
actions.graphSetParameter.execute(action);
updateMenu();
},
execute: async (action) => {
await invoke('graph_set_parameter', {
trackId: action.trackId,
nodeId: action.nodeId,
paramId: action.paramId,
value: action.newValue
});
// Update frontend slider if it exists
const slider = document.querySelector(`#node-${action.frontendNodeId} input[data-param="${action.paramId}"]`);
if (slider) {
slider.value = action.newValue;
// Trigger display update
slider.dispatchEvent(new Event('input'));
}
},
rollback: async (action) => {
await invoke('graph_set_parameter', {
trackId: action.trackId,
nodeId: action.nodeId,
paramId: action.paramId,
value: action.oldValue
});
// Update frontend slider
const slider = document.querySelector(`#node-${action.frontendNodeId} input[data-param="${action.paramId}"]`);
if (slider) {
slider.value = action.oldValue;
slider.dispatchEvent(new Event('input'));
}
},
},
graphMoveNode: {
create: (trackId, nodeId, oldPosition, newPosition) => {
redoStack.length = 0;
let action = {
trackId: trackId,
nodeId: nodeId,
oldPosition: oldPosition,
newPosition: newPosition,
};
undoStack.push({ name: "graphMoveNode", action: action });
// Don't call execute - movement already happened in UI
updateMenu();
},
execute: (action) => {
// Move node in frontend
if (context.nodeEditor) {
const node = context.nodeEditor.getNodeFromId(action.nodeId);
if (node) {
context.nodeEditor.drawflow.drawflow[context.nodeEditor.module].data[action.nodeId].pos_x = action.newPosition.x;
context.nodeEditor.drawflow.drawflow[context.nodeEditor.module].data[action.nodeId].pos_y = action.newPosition.y;
// Update visual position
const nodeElement = document.getElementById(`node-${action.nodeId}`);
if (nodeElement) {
nodeElement.style.left = action.newPosition.x + 'px';
nodeElement.style.top = action.newPosition.y + 'px';
}
context.nodeEditor.updateConnectionNodes(`node-${action.nodeId}`);
}
}
},
rollback: (action) => {
// Move node back to old position
if (context.nodeEditor) {
const node = context.nodeEditor.getNodeFromId(action.nodeId);
if (node) {
context.nodeEditor.drawflow.drawflow[context.nodeEditor.module].data[action.nodeId].pos_x = action.oldPosition.x;
context.nodeEditor.drawflow.drawflow[context.nodeEditor.module].data[action.nodeId].pos_y = action.oldPosition.y;
const nodeElement = document.getElementById(`node-${action.nodeId}`);
if (nodeElement) {
nodeElement.style.left = action.oldPosition.x + 'px';
nodeElement.style.top = action.oldPosition.y + 'px';
}
context.nodeEditor.updateConnectionNodes(`node-${action.nodeId}`);
}
}
},
},
};

View File

@ -6213,6 +6213,15 @@ function nodeEditor() {
const expandedNodes = new Set(); // Set of node IDs that are expanded
const nodeParents = new Map(); // Map of child node ID -> parent VoiceAllocator ID
// Cache node data for undo/redo (nodeId -> {nodeType, backendId, position, parameters})
const nodeDataCache = new Map();
// Track node movement for undo/redo (nodeId -> {oldX, oldY})
const nodeMoveTracker = new Map();
// Flag to prevent recording actions during undo/redo operations
let suppressActionRecording = false;
// Wait for DOM insertion
setTimeout(() => {
const drawflowDiv = container.querySelector("#drawflow");
@ -6226,43 +6235,39 @@ function nodeEditor() {
// Store editor reference in context
context.nodeEditor = editor;
context.reloadNodeEditor = reloadGraph;
context.nodeEditorState = {
get suppressActionRecording() { return suppressActionRecording; },
set suppressActionRecording(value) { suppressActionRecording = value; }
};
// Add reconnection support: dragging from a connected input disconnects and starts new connection
drawflowDiv.addEventListener('mousedown', (e) => {
console.log('Mousedown on drawflow, target:', e.target);
// Check if clicking on an input port
const inputPort = e.target.closest('.input');
console.log('Found input port:', inputPort);
if (inputPort) {
// Get the node and port information - the drawflow-node div has the id
const drawflowNode = inputPort.closest('.drawflow-node');
console.log('Found drawflow node:', drawflowNode, 'ID:', drawflowNode?.id);
if (!drawflowNode) return;
const nodeId = parseInt(drawflowNode.id.replace('node-', ''));
console.log('Node ID:', nodeId);
// Access the node data directly from the current module
const moduleName = editor.module;
const node = editor.drawflow.drawflow[moduleName]?.data[nodeId];
console.log('Node data:', node);
if (!node) return;
// Get the port class (input_1, input_2, etc.)
const portClasses = Array.from(inputPort.classList);
const portClass = portClasses.find(c => c.startsWith('input_'));
console.log('Port class:', portClass, 'All classes:', portClasses);
if (!portClass) return;
// Check if this input has any connections
const inputConnections = node.inputs[portClass];
console.log('Input connections:', inputConnections);
if (inputConnections && inputConnections.connections && inputConnections.connections.length > 0) {
// Get the first connection (inputs should only have one connection)
const connection = inputConnections.connections[0];
console.log('Found connection to disconnect:', connection);
// Prevent default to avoid interfering with the drag
e.stopPropagation();
@ -6279,10 +6284,8 @@ function nodeEditor() {
// Now trigger Drawflow's connection drag from the output that was connected
// We need to simulate starting a drag from the output port
const outputNodeElement = document.getElementById(`node-${connection.node}`);
console.log('Output node element:', outputNodeElement);
if (outputNodeElement) {
const outputPort = outputNodeElement.querySelector(`.${connection.input}`);
console.log('Output port:', outputPort, 'class:', connection.input);
if (outputPort) {
// Dispatch a synthetic mousedown event on the output port
// This will trigger Drawflow's normal connection start logic
@ -6512,6 +6515,18 @@ function nodeEditor() {
}, 50);
});
// Track node drag start for undo/redo
drawflowDiv.addEventListener('mousedown', (e) => {
const nodeElement = e.target.closest('.drawflow-node');
if (nodeElement && !e.target.closest('.input') && !e.target.closest('.output')) {
const nodeId = parseInt(nodeElement.id.replace('node-', ''));
const node = editor.getNodeFromId(nodeId);
if (node) {
nodeMoveTracker.set(nodeId, { x: node.pos_x, y: node.pos_y });
}
}
});
// Node moved - resize parent VoiceAllocator
editor.on("nodeMoved", (nodeId) => {
const node = editor.getNodeFromId(nodeId);
@ -6520,6 +6535,29 @@ function nodeEditor() {
}
});
// Track node drag end for undo/redo
drawflowDiv.addEventListener('mouseup', (e) => {
// Check all tracked nodes for position changes
for (const [nodeId, oldPos] of nodeMoveTracker.entries()) {
const node = editor.getNodeFromId(nodeId);
if (node && (node.pos_x !== oldPos.x || node.pos_y !== oldPos.y)) {
// Position changed - record action
redoStack.length = 0;
undoStack.push({
name: "graphMoveNode",
action: {
nodeId: nodeId,
oldPosition: oldPos,
newPosition: { x: node.pos_x, y: node.pos_y }
}
});
updateMenu();
}
}
// Clear tracker
nodeMoveTracker.clear();
});
// Node removed - prevent deletion of template nodes
editor.on("nodeRemoved", (nodeId) => {
const nodeElement = document.getElementById(`node-${nodeId}`);
@ -6529,6 +6567,32 @@ function nodeEditor() {
return;
}
// Get cached node data before removal
const cachedData = nodeDataCache.get(nodeId);
if (cachedData && cachedData.backendId) {
// Call backend to remove the node
invoke('graph_remove_node', {
trackId: cachedData.trackId,
nodeId: cachedData.backendId
}).catch(err => {
console.error("Failed to remove node from backend:", err);
});
// Record action for undo (don't call execute since node is already removed from frontend)
redoStack.length = 0;
undoStack.push({
name: "graphRemoveNode",
action: {
trackId: cachedData.trackId,
nodeId: nodeId,
backendId: cachedData.backendId,
nodeData: cachedData
}
});
updateMenu();
}
// Stop oscilloscope visualization if this was an Oscilloscope node
stopOscilloscopeVisualization(nodeId);
@ -6536,6 +6600,9 @@ function nodeEditor() {
const parentId = nodeParents.get(nodeId);
nodeParents.delete(nodeId);
// Clean up node data cache
nodeDataCache.delete(nodeId);
// Resize parent if needed
if (parentId) {
resizeVoiceAllocatorToFit(parentId);
@ -6675,6 +6742,29 @@ function nodeEditor() {
console.log("Verifying stored backend ID:", editor.getNodeFromId(drawflowNodeId).data.backendId);
// Cache node data for undo/redo
nodeDataCache.set(drawflowNodeId, {
nodeType: nodeType,
backendId: backendNodeId,
position: { x, y },
parentNodeId: parentNodeId,
trackId: getCurrentMidiTrack()
});
// Record action for undo (node is already added to frontend and backend)
redoStack.length = 0;
undoStack.push({
name: "graphAddNode",
action: {
trackId: getCurrentMidiTrack(),
nodeType: nodeType,
position: { x, y },
nodeId: drawflowNodeId,
backendId: backendNodeId
}
});
updateMenu();
// If this is an AudioOutput node, automatically set it as the graph output
if (nodeType === "AudioOutput") {
console.log(`Setting node ${backendNodeId} as graph output`);
@ -6822,9 +6912,30 @@ function nodeEditor() {
const sliders = nodeElement.querySelectorAll('input[type="range"]');
sliders.forEach(slider => {
// Track parameter change action for undo/redo
let paramAction = null;
// Prevent node dragging when interacting with slider
slider.addEventListener("mousedown", (e) => {
e.stopPropagation();
// Initialize undo action
const paramId = parseInt(e.target.getAttribute("data-param"));
const currentValue = parseFloat(e.target.value);
const nodeData = editor.getNodeFromId(nodeId);
if (nodeData && nodeData.data.backendId !== null) {
const currentTrackId = getCurrentMidiTrack();
if (currentTrackId !== null) {
paramAction = actions.graphSetParameter.initialize(
currentTrackId,
nodeData.data.backendId,
paramId,
nodeId,
currentValue
);
}
}
});
slider.addEventListener("pointerdown", (e) => {
e.stopPropagation();
@ -6863,7 +6974,7 @@ function nodeEditor() {
}
}
// Send to backend
// Send to backend in real-time
if (nodeData.data.backendId !== null) {
const currentTrackId = getCurrentMidiTrack();
if (currentTrackId !== null) {
@ -6879,6 +6990,16 @@ function nodeEditor() {
}
}
});
// Finalize parameter change for undo/redo when slider is released
slider.addEventListener("change", (e) => {
const newValue = parseFloat(e.target.value);
if (paramAction) {
actions.graphSetParameter.finalize(paramAction, newValue);
paramAction = null;
}
});
});
// Handle Load Sample button for SimpleSampler
@ -7234,9 +7355,9 @@ function nodeEditor() {
}
}, 10);
// Send to backend
// Send to backend (skip if action is handling it)
console.log("Backend IDs - output:", outputNode.data.backendId, "input:", inputNode.data.backendId);
if (outputNode.data.backendId !== null && inputNode.data.backendId !== null) {
if (!suppressActionRecording && outputNode.data.backendId !== null && inputNode.data.backendId !== null) {
const currentTrackId = getCurrentMidiTrack();
if (currentTrackId === null) return;
@ -7298,7 +7419,7 @@ function nodeEditor() {
);
});
} else {
// Normal connection in main graph
// Normal connection in main graph (skip if action is handling it)
console.log(`Connecting: node ${outputNode.data.backendId} port ${outputPort} -> node ${inputNode.data.backendId} port ${inputPort}`);
invoke("graph_connect", {
trackId: currentTrackId,
@ -7308,6 +7429,25 @@ function nodeEditor() {
toPort: inputPort
}).then(() => {
console.log("Connection successful");
// Record action for undo
redoStack.length = 0;
undoStack.push({
name: "graphAddConnection",
action: {
trackId: currentTrackId,
fromNode: outputNode.data.backendId,
fromPort: outputPort,
toNode: inputNode.data.backendId,
toPort: inputPort,
// Store frontend IDs for disconnection
frontendFromId: connection.output_id,
frontendToId: connection.input_id,
fromPortClass: connection.output_class,
toPortClass: connection.input_class
}
});
updateMenu();
}).catch(err => {
console.error("Failed to connect nodes:", err);
showError("Connection failed: " + err);
@ -7364,8 +7504,8 @@ function nodeEditor() {
}
}
// Send to backend
if (outputNode.data.backendId !== null && inputNode.data.backendId !== null) {
// Send to backend (skip if action is handling it)
if (!suppressActionRecording && outputNode.data.backendId !== null && inputNode.data.backendId !== null) {
const currentTrackId = getCurrentMidiTrack();
if (currentTrackId !== null) {
invoke("graph_disconnect", {
@ -7374,6 +7514,25 @@ function nodeEditor() {
fromPort: outputPort,
toNode: inputNode.data.backendId,
toPort: inputPort
}).then(() => {
// Record action for undo
redoStack.length = 0;
undoStack.push({
name: "graphRemoveConnection",
action: {
trackId: currentTrackId,
fromNode: outputNode.data.backendId,
fromPort: outputPort,
toNode: inputNode.data.backendId,
toPort: inputPort,
// Store frontend IDs for reconnection
frontendFromId: connection.output_id,
frontendToId: connection.input_id,
fromPortClass: connection.output_class,
toPortClass: connection.input_class
}
});
updateMenu();
}).catch(err => {
console.error("Failed to disconnect nodes:", err);
});