diff --git a/src/actions/index.js b/src/actions/index.js index d443d00..d1513e6 100644 --- a/src/actions/index.js +++ b/src/actions/index.js @@ -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}`); + } + } + }, + }, }; diff --git a/src/main.js b/src/main.js index b3615a6..d6a95fc 100644 --- a/src/main.js +++ b/src/main.js @@ -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); });