You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
101 lines
3.0 KiB
101 lines
3.0 KiB
import type { WorkflowNode, WorkflowEdge, NodeGroup } from "@/types";
|
|
import type { EdgeStyle } from "./workflowStore";
|
|
|
|
export interface UndoSnapshot {
|
|
nodes: WorkflowNode[];
|
|
edges: WorkflowEdge[];
|
|
groups: Record<string, NodeGroup>;
|
|
edgeStyle: EdgeStyle;
|
|
}
|
|
|
|
const MAX_HISTORY = 50;
|
|
|
|
export class UndoManager {
|
|
private undoStack: UndoSnapshot[] = [];
|
|
private redoStack: UndoSnapshot[] = [];
|
|
|
|
get canUndo(): boolean {
|
|
return this.undoStack.length > 0;
|
|
}
|
|
|
|
get canRedo(): boolean {
|
|
return this.redoStack.length > 0;
|
|
}
|
|
|
|
push(snapshot: UndoSnapshot): void {
|
|
this.undoStack.push(snapshot);
|
|
if (this.undoStack.length > MAX_HISTORY) {
|
|
this.undoStack.shift();
|
|
}
|
|
this.redoStack = [];
|
|
}
|
|
|
|
undo(currentState: UndoSnapshot): UndoSnapshot | null {
|
|
const previous = this.undoStack.pop();
|
|
if (!previous) return null;
|
|
this.redoStack.push(currentState);
|
|
return previous;
|
|
}
|
|
|
|
redo(currentState: UndoSnapshot): UndoSnapshot | null {
|
|
const next = this.redoStack.pop();
|
|
if (!next) return null;
|
|
this.undoStack.push(currentState);
|
|
return next;
|
|
}
|
|
|
|
clear(): void {
|
|
this.undoStack = [];
|
|
this.redoStack = [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Deep-clone that preserves string references.
|
|
*
|
|
* Objects and arrays get new containers so mutations don't leak between
|
|
* snapshots, but strings (immutable in JS) are returned by reference.
|
|
* This avoids duplicating multi-MB base64 blobs across undo history.
|
|
*
|
|
* Matches JSON.parse(JSON.stringify()) semantics for plain JSON-like
|
|
* objects/arrays:
|
|
* - `undefined` values are dropped from objects, become `null` in arrays
|
|
* - functions are dropped from objects, become `null` in arrays
|
|
*
|
|
* Does NOT call toJSON() on objects. Objects with custom toJSON methods
|
|
* are treated as plain objects (their enumerable own properties are cloned).
|
|
*/
|
|
export function clonePreservingStrings<T>(value: T): T {
|
|
if (value === null || typeof value !== "object") {
|
|
// Primitives (string, number, boolean, null) returned directly.
|
|
// Strings share the reference — this is the whole point.
|
|
return value;
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
const result: unknown[] = new Array(value.length);
|
|
for (let i = 0; i < value.length; i++) {
|
|
const elem = value[i];
|
|
// Match JSON behavior: undefined & functions become null in arrays
|
|
if (elem === undefined || typeof elem === "function") {
|
|
result[i] = null;
|
|
} else {
|
|
result[i] = clonePreservingStrings(elem);
|
|
}
|
|
}
|
|
return result as T;
|
|
}
|
|
|
|
// Plain object
|
|
const result: Record<string, unknown> = {};
|
|
const keys = Object.keys(value);
|
|
for (let i = 0; i < keys.length; i++) {
|
|
const key = keys[i];
|
|
if (!Object.prototype.hasOwnProperty.call(value, key)) continue;
|
|
const val = (value as Record<string, unknown>)[key];
|
|
// Match JSON behavior: skip undefined & functions in objects
|
|
if (val === undefined || typeof val === "function") continue;
|
|
result[key] = clonePreservingStrings(val);
|
|
}
|
|
return result as T;
|
|
}
|
|
|