working ctrl z
This commit is contained in:
@@ -17,6 +17,7 @@ import { embedWorkflow, extractWorkflow } from './pngMetadata';
|
|||||||
import { captureViewportBlob as captureWorkflowViewportBlob } from './workflowCapture';
|
import { captureViewportBlob as captureWorkflowViewportBlob } from './workflowCapture';
|
||||||
import tonoIconUrl from '../../resources/icon_1024.png';
|
import tonoIconUrl from '../../resources/icon_1024.png';
|
||||||
import { hydrateWorkflowState } from './workflowHydration';
|
import { hydrateWorkflowState } from './workflowHydration';
|
||||||
|
import useUndoRedo from './useUndoRedo';
|
||||||
import { packWorkflow, unpackWorkflow } from './workflowPacking';
|
import { packWorkflow, unpackWorkflow } from './workflowPacking';
|
||||||
import { serializeWorkflowState } from './workflowSerialization';
|
import { serializeWorkflowState } from './workflowSerialization';
|
||||||
import { sortNodesForParentOrder } from './nodeHierarchy.js';
|
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) ────────────
|
// ── Main flow component (needs ReactFlowProvider ancestor) ────────────
|
||||||
|
|
||||||
function Flow() {
|
function Flow() {
|
||||||
@@ -875,7 +878,9 @@ function Flow() {
|
|||||||
const suppressPaneContextMenuUntilRef = useRef(0);
|
const suppressPaneContextMenuUntilRef = useRef(0);
|
||||||
const loadNodeOutputRequestVersionsRef = useRef(new Map());
|
const loadNodeOutputRequestVersionsRef = useRef(new Map());
|
||||||
const journalContentRef = useRef('');
|
const journalContentRef = useRef('');
|
||||||
|
const pendingUndoSnapshotRef = useRef(null);
|
||||||
const reactFlow = useReactFlow();
|
const reactFlow = useReactFlow();
|
||||||
|
const undoRedo = useUndoRedo();
|
||||||
|
|
||||||
const scheduleAutoRun = useCallback(() => {
|
const scheduleAutoRun = useCallback(() => {
|
||||||
clearTimeout(autoRunTimer.current);
|
clearTimeout(autoRunTimer.current);
|
||||||
@@ -1390,6 +1395,7 @@ function Flow() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
undoRedo.pushSnapshot(reactFlow.getNodes(), reactFlow.getEdges(), nextIdRef.current);
|
||||||
setEdges((eds) => {
|
setEdges((eds) => {
|
||||||
// Enforce single connection per input handle
|
// Enforce single connection per input handle
|
||||||
const filtered = eds.filter(
|
const filtered = eds.filter(
|
||||||
@@ -1420,6 +1426,9 @@ function Flow() {
|
|||||||
}, [reactFlow, refreshAnnotationNodeOutputs, refreshGroupNode, refreshLoadNodeOutputs, setEdges]); // scheduleAutoRun is stable (no deps)
|
}, [reactFlow, refreshAnnotationNodeOutputs, refreshGroupNode, refreshLoadNodeOutputs, setEdges]); // scheduleAutoRun is stable (no deps)
|
||||||
|
|
||||||
const handleEdgesChange = useCallback((changes) => {
|
const handleEdgesChange = useCallback((changes) => {
|
||||||
|
if (changes.some((c) => c.type === 'remove')) {
|
||||||
|
undoRedo.pushSnapshot(reactFlow.getNodes(), reactFlow.getEdges(), nextIdRef.current);
|
||||||
|
}
|
||||||
const currentEdges = reactFlow.getEdges();
|
const currentEdges = reactFlow.getEdges();
|
||||||
onEdgesChange(changes);
|
onEdgesChange(changes);
|
||||||
|
|
||||||
@@ -1462,6 +1471,25 @@ function Flow() {
|
|||||||
}, [onEdgesChange, reactFlow, refreshAnnotationNodeOutputs, refreshGroupNode, refreshLoadNodeOutputs]);
|
}, [onEdgesChange, reactFlow, refreshAnnotationNodeOutputs, refreshGroupNode, refreshLoadNodeOutputs]);
|
||||||
|
|
||||||
const handleNodesChange = useCallback((changes) => {
|
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 currentNodes = reactFlow.getNodes();
|
||||||
const selectedGroupIds = new Set(
|
const selectedGroupIds = new Set(
|
||||||
changes
|
changes
|
||||||
@@ -1475,6 +1503,10 @@ function Flow() {
|
|||||||
.map((change) => String(change.id)),
|
.map((change) => String(change.id)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (removedIds.size > 0) {
|
||||||
|
undoRedo.pushSnapshot(reactFlow.getNodes(), reactFlow.getEdges(), nextIdRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
onNodesChange(changes);
|
onNodesChange(changes);
|
||||||
|
|
||||||
if (selectedGroupIds.size > 0) {
|
if (selectedGroupIds.size > 0) {
|
||||||
@@ -2251,7 +2283,7 @@ function Flow() {
|
|||||||
const imageHeight = Math.ceil(bounds.height * (1 + pad * 2));
|
const imageHeight = Math.ceil(bounds.height * (1 + pad * 2));
|
||||||
const vp = getViewportForBounds(bounds, imageWidth, imageHeight, 0.5, 1, pad);
|
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, {
|
const blob = await captureWorkflowViewportBlob(viewportEl, {
|
||||||
backgroundColor: CANVAS_COLORS.bgDeep,
|
backgroundColor: CANVAS_COLORS.bgDeep,
|
||||||
width: imageWidth,
|
width: imageWidth,
|
||||||
@@ -2264,19 +2296,18 @@ function Flow() {
|
|||||||
});
|
});
|
||||||
if (!blob) throw new Error('Capture returned empty');
|
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);
|
const stampedBlob = await stampLogoOnBlob(blob);
|
||||||
let workflow = serializeWorkflowState(allNodes, reactFlow.getEdges());
|
let workflow = serializeWorkflowState(allNodes, reactFlow.getEdges());
|
||||||
if (journalContentRef.current) workflow.journalContent = journalContentRef.current;
|
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) => {
|
workflow = await packWorkflow(workflow, nodeDefsRef.current, (packed, total) => {
|
||||||
setStatus({ text: `Packing files… (${packed}/${total})`, level: 'info' });
|
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);
|
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';
|
const defaultName = 'workflow-packed.png';
|
||||||
|
|
||||||
if (window.pywebview?.api?.choose_save_workflow_png_path) {
|
if (window.pywebview?.api?.choose_save_workflow_png_path) {
|
||||||
@@ -2417,7 +2448,6 @@ function Flow() {
|
|||||||
const onNodeDragStart = useCallback((event, node) => {
|
const onNodeDragStart = useCallback((event, node) => {
|
||||||
activeDragNodeIdRef.current = String(node.id);
|
activeDragNodeIdRef.current = String(node.id);
|
||||||
dragStateRef.current = null;
|
dragStateRef.current = null;
|
||||||
|
|
||||||
if (!(event.ctrlKey || event.metaKey)) {
|
if (!(event.ctrlKey || event.metaKey)) {
|
||||||
duplicateDragRef.current = null;
|
duplicateDragRef.current = null;
|
||||||
const currentNodes = reactFlow.getNodes();
|
const currentNodes = reactFlow.getNodes();
|
||||||
@@ -2878,6 +2908,25 @@ function Flow() {
|
|||||||
return () => window.removeEventListener('keydown', handler);
|
return () => window.removeEventListener('keydown', handler);
|
||||||
}, [runWorkflow]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const handleCopy = (event) => {
|
const handleCopy = (event) => {
|
||||||
if (isEditableTarget(event.target)) return;
|
if (isEditableTarget(event.target)) return;
|
||||||
|
|||||||
63
frontend/src/useUndoRedo.js
Normal file
63
frontend/src/useUndoRedo.js
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
@@ -82,8 +82,6 @@ export async function packWorkflow(workflowData, nodeDefs, onProgress) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[packWorkflow] FILE_PICKER paths found:', [...filePaths]);
|
|
||||||
|
|
||||||
if (filePaths.size === 0) {
|
if (filePaths.size === 0) {
|
||||||
return workflowData;
|
return workflowData;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user