From 5ea16d4e435d9c789239f4d1a63e882d8657ac1e Mon Sep 17 00:00:00 2001 From: matei jordache Date: Tue, 31 Mar 2026 21:02:26 -0700 Subject: [PATCH] working ctrl z --- frontend/src/App.jsx | 63 +++++++++++++++++++++++++++++---- frontend/src/useUndoRedo.js | 63 +++++++++++++++++++++++++++++++++ frontend/src/workflowPacking.js | 2 -- 3 files changed, 119 insertions(+), 9 deletions(-) create mode 100644 frontend/src/useUndoRedo.js diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 85b25d0..6a1ecf0 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -17,6 +17,7 @@ import { embedWorkflow, extractWorkflow } from './pngMetadata'; import { captureViewportBlob as captureWorkflowViewportBlob } from './workflowCapture'; import tonoIconUrl from '../../resources/icon_1024.png'; import { hydrateWorkflowState } from './workflowHydration'; +import useUndoRedo from './useUndoRedo'; import { packWorkflow, unpackWorkflow } from './workflowPacking'; import { serializeWorkflowState } from './workflowSerialization'; import { sortNodesForParentOrder } from './nodeHierarchy.js'; @@ -847,6 +848,8 @@ function ContextMenu({ ); } +const DEBUG = false; // set to true for verbose logging + // ── Main flow component (needs ReactFlowProvider ancestor) ──────────── function Flow() { @@ -875,7 +878,9 @@ function Flow() { const suppressPaneContextMenuUntilRef = useRef(0); const loadNodeOutputRequestVersionsRef = useRef(new Map()); const journalContentRef = useRef(''); + const pendingUndoSnapshotRef = useRef(null); const reactFlow = useReactFlow(); + const undoRedo = useUndoRedo(); const scheduleAutoRun = useCallback(() => { clearTimeout(autoRunTimer.current); @@ -1390,6 +1395,7 @@ function Flow() { }; } + undoRedo.pushSnapshot(reactFlow.getNodes(), reactFlow.getEdges(), nextIdRef.current); setEdges((eds) => { // Enforce single connection per input handle const filtered = eds.filter( @@ -1420,6 +1426,9 @@ function Flow() { }, [reactFlow, refreshAnnotationNodeOutputs, refreshGroupNode, refreshLoadNodeOutputs, setEdges]); // scheduleAutoRun is stable (no deps) const handleEdgesChange = useCallback((changes) => { + if (changes.some((c) => c.type === 'remove')) { + undoRedo.pushSnapshot(reactFlow.getNodes(), reactFlow.getEdges(), nextIdRef.current); + } const currentEdges = reactFlow.getEdges(); onEdgesChange(changes); @@ -1462,6 +1471,25 @@ function Flow() { }, [onEdgesChange, reactFlow, refreshAnnotationNodeOutputs, refreshGroupNode, refreshLoadNodeOutputs]); const handleNodesChange = useCallback((changes) => { + // Stash undo snapshot when a drag begins + const isDragStart = changes.some((c) => c.type === 'position' && c.dragging); + if (isDragStart && !pendingUndoSnapshotRef.current) { + if (DEBUG) console.log('[undo] drag started, stashing snapshot'); + pendingUndoSnapshotRef.current = { + nodes: structuredClone(reactFlow.getNodes()), + edges: structuredClone(reactFlow.getEdges()), + nextId: nextIdRef.current, + }; + } + // Commit stashed snapshot when drag ends + const isDragEnd = changes.some((c) => c.type === 'position' && c.dragging === false); + if (isDragEnd && pendingUndoSnapshotRef.current) { + if (DEBUG) console.log('[undo] drag ended, pushing snapshot'); + const s = pendingUndoSnapshotRef.current; + undoRedo.pushSnapshot(s.nodes, s.edges, s.nextId); + pendingUndoSnapshotRef.current = null; + } + const currentNodes = reactFlow.getNodes(); const selectedGroupIds = new Set( changes @@ -1475,6 +1503,10 @@ function Flow() { .map((change) => String(change.id)), ); + if (removedIds.size > 0) { + undoRedo.pushSnapshot(reactFlow.getNodes(), reactFlow.getEdges(), nextIdRef.current); + } + onNodesChange(changes); if (selectedGroupIds.size > 0) { @@ -2251,7 +2283,7 @@ function Flow() { const imageHeight = Math.ceil(bounds.height * (1 + pad * 2)); const vp = getViewportForBounds(bounds, imageWidth, imageHeight, 0.5, 1, pad); - console.log('[pack] capturing viewport…'); + if (DEBUG) console.log('[pack] capturing viewport…'); const blob = await captureWorkflowViewportBlob(viewportEl, { backgroundColor: CANVAS_COLORS.bgDeep, width: imageWidth, @@ -2264,19 +2296,18 @@ function Flow() { }); if (!blob) throw new Error('Capture returned empty'); - console.log('[pack] stamping logo…'); + if (DEBUG) console.log('[pack] stamping logo…'); const stampedBlob = await stampLogoOnBlob(blob); let workflow = serializeWorkflowState(allNodes, reactFlow.getEdges()); if (journalContentRef.current) workflow.journalContent = journalContentRef.current; - console.log('[pack] packing files…'); + if (DEBUG) console.log('[pack] packing files…'); workflow = await packWorkflow(workflow, nodeDefsRef.current, (packed, total) => { setStatus({ text: `Packing files… (${packed}/${total})`, level: 'info' }); }); - console.log('[pack] packed, embedding into PNG…', workflow.packed ? 'has packed files' : 'no packed files'); - + if (DEBUG) console.log('[pack] packed, embedding into PNG…', workflow.packed ? 'has packed files' : 'no packed files'); const finalBlob = await embedWorkflow(stampedBlob, workflow); - console.log('[pack] embed complete, blob size:', finalBlob.size, 'saving…'); + if (DEBUG) console.log('[pack] embed complete, blob size:', finalBlob.size); const defaultName = 'workflow-packed.png'; if (window.pywebview?.api?.choose_save_workflow_png_path) { @@ -2417,7 +2448,6 @@ function Flow() { const onNodeDragStart = useCallback((event, node) => { activeDragNodeIdRef.current = String(node.id); dragStateRef.current = null; - if (!(event.ctrlKey || event.metaKey)) { duplicateDragRef.current = null; const currentNodes = reactFlow.getNodes(); @@ -2878,6 +2908,25 @@ function Flow() { return () => window.removeEventListener('keydown', handler); }, [runWorkflow]); + useEffect(() => { + const handler = (e) => { + if (!(e.ctrlKey || e.metaKey) || e.key !== 'z') return; + if (isEditableTarget(e.target)) return; + e.preventDefault(); + if (e.shiftKey) { + if (undoRedo.redo(setNodes, setEdges, nextIdRef, () => reactFlow.getNodes(), () => reactFlow.getEdges())) { + setStatus({ text: 'Redo.', level: 'info' }); + } + } else { + if (undoRedo.undo(setNodes, setEdges, nextIdRef, () => reactFlow.getNodes(), () => reactFlow.getEdges())) { + setStatus({ text: 'Undo.', level: 'info' }); + } + } + }; + window.addEventListener('keydown', handler, true); + return () => window.removeEventListener('keydown', handler, true); + }, [reactFlow, setNodes, setEdges, undoRedo]); + useEffect(() => { const handleCopy = (event) => { if (isEditableTarget(event.target)) return; diff --git a/frontend/src/useUndoRedo.js b/frontend/src/useUndoRedo.js new file mode 100644 index 0000000..7893ecf --- /dev/null +++ b/frontend/src/useUndoRedo.js @@ -0,0 +1,63 @@ +import { useRef, useCallback } from 'react'; + +/** + * Snapshot-based undo/redo for nodes + edges. + * + * Call `pushSnapshot` before a mutation to save the current state. + * Call `undo` / `redo` to restore. + */ +export default function useUndoRedo({ maxHistory = 50 } = {}) { + const pastRef = useRef([]); + const futureRef = useRef([]); + + const pushSnapshot = useCallback((nodes, edges, nextId) => { + pastRef.current = [ + ...pastRef.current.slice(-(maxHistory - 1)), + { + nodes: structuredClone(nodes), + edges: structuredClone(edges), + nextId, + }, + ]; + futureRef.current = []; + }, [maxHistory]); + + const undo = useCallback((setNodes, setEdges, nextIdRef, getNodes, getEdges) => { + if (pastRef.current.length === 0) return false; + futureRef.current = [ + ...futureRef.current, + { + nodes: structuredClone(getNodes()), + edges: structuredClone(getEdges()), + nextId: nextIdRef.current, + }, + ]; + const snapshot = pastRef.current.pop(); + setNodes(snapshot.nodes); + setEdges(snapshot.edges); + nextIdRef.current = snapshot.nextId; + return true; + }, []); + + const redo = useCallback((setNodes, setEdges, nextIdRef, getNodes, getEdges) => { + if (futureRef.current.length === 0) return false; + pastRef.current = [ + ...pastRef.current, + { + nodes: structuredClone(getNodes()), + edges: structuredClone(getEdges()), + nextId: nextIdRef.current, + }, + ]; + const snapshot = futureRef.current.pop(); + setNodes(snapshot.nodes); + setEdges(snapshot.edges); + nextIdRef.current = snapshot.nextId; + return true; + }, []); + + const canUndo = useCallback(() => pastRef.current.length > 0, []); + const canRedo = useCallback(() => futureRef.current.length > 0, []); + + return { pushSnapshot, undo, redo, canUndo, canRedo }; +} diff --git a/frontend/src/workflowPacking.js b/frontend/src/workflowPacking.js index 2088f9f..fa571a7 100644 --- a/frontend/src/workflowPacking.js +++ b/frontend/src/workflowPacking.js @@ -82,8 +82,6 @@ export async function packWorkflow(workflowData, nodeDefs, onProgress) { } } - console.log('[packWorkflow] FILE_PICKER paths found:', [...filePaths]); - if (filePaths.size === 0) { return workflowData; }