|
|
|
import { app } from "../../scripts/app.js";
|
|
|
|
import { api } from "../../scripts/api.js"
|
|
|
|
|
|
|
|
const MAX_HISTORY = 50;
|
|
|
|
|
|
|
|
let undo = [];
|
|
|
|
let redo = [];
|
|
|
|
let activeState = null;
|
|
|
|
let isOurLoad = false;
|
|
|
|
function checkState() {
|
|
|
|
const currentState = app.graph.serialize();
|
|
|
|
if (!graphEqual(activeState, currentState)) {
|
|
|
|
undo.push(activeState);
|
|
|
|
if (undo.length > MAX_HISTORY) {
|
|
|
|
undo.shift();
|
|
|
|
}
|
|
|
|
activeState = clone(currentState);
|
|
|
|
redo.length = 0;
|
|
|
|
api.dispatchEvent(new CustomEvent("graphChanged", { detail: activeState }));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
const loadGraphData = app.loadGraphData;
|
|
|
|
app.loadGraphData = async function () {
|
|
|
|
const v = await loadGraphData.apply(this, arguments);
|
|
|
|
if (isOurLoad) {
|
|
|
|
isOurLoad = false;
|
|
|
|
} else {
|
|
|
|
checkState();
|
|
|
|
}
|
|
|
|
return v;
|
|
|
|
};
|
|
|
|
|
|
|
|
function clone(obj) {
|
|
|
|
try {
|
|
|
|
if (typeof structuredClone !== "undefined") {
|
|
|
|
return structuredClone(obj);
|
|
|
|
}
|
|
|
|
} catch (error) {
|
|
|
|
// structuredClone is stricter than using JSON.parse/stringify so fallback to that
|
|
|
|
}
|
|
|
|
|
|
|
|
return JSON.parse(JSON.stringify(obj));
|
|
|
|
}
|
|
|
|
|
|
|
|
function graphEqual(a, b, root = true) {
|
|
|
|
if (a === b) return true;
|
|
|
|
|
|
|
|
if (typeof a == "object" && a && typeof b == "object" && b) {
|
|
|
|
const keys = Object.getOwnPropertyNames(a);
|
|
|
|
|
|
|
|
if (keys.length != Object.getOwnPropertyNames(b).length) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
for (const key of keys) {
|
|
|
|
let av = a[key];
|
|
|
|
let bv = b[key];
|
|
|
|
if (root && key === "nodes") {
|
|
|
|
// Nodes need to be sorted as the order changes when selecting nodes
|
|
|
|
av = [...av].sort((a, b) => a.id - b.id);
|
|
|
|
bv = [...bv].sort((a, b) => a.id - b.id);
|
|
|
|
}
|
|
|
|
if (!graphEqual(av, bv, false)) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
const undoRedo = async (e) => {
|
|
|
|
const updateState = async (source, target) => {
|
|
|
|
const prevState = source.pop();
|
|
|
|
if (prevState) {
|
|
|
|
target.push(activeState);
|
|
|
|
isOurLoad = true;
|
|
|
|
await app.loadGraphData(prevState, false);
|
|
|
|
activeState = prevState;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if (e.ctrlKey || e.metaKey) {
|
|
|
|
if (e.key === "y") {
|
|
|
|
updateState(redo, undo);
|
|
|
|
return true;
|
|
|
|
} else if (e.key === "z") {
|
|
|
|
updateState(undo, redo);
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
const bindInput = (activeEl) => {
|
|
|
|
if (activeEl && activeEl.tagName !== "CANVAS" && activeEl.tagName !== "BODY") {
|
|
|
|
for (const evt of ["change", "input", "blur"]) {
|
|
|
|
if (`on${evt}` in activeEl) {
|
|
|
|
const listener = () => {
|
|
|
|
checkState();
|
|
|
|
activeEl.removeEventListener(evt, listener);
|
|
|
|
};
|
|
|
|
activeEl.addEventListener(evt, listener);
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
let keyIgnored = false;
|
|
|
|
window.addEventListener(
|
|
|
|
"keydown",
|
|
|
|
(e) => {
|
|
|
|
requestAnimationFrame(async () => {
|
|
|
|
let activeEl;
|
|
|
|
// If we are auto queue in change mode then we do want to trigger on inputs
|
|
|
|
if (!app.ui.autoQueueEnabled || app.ui.autoQueueMode === "instant") {
|
|
|
|
activeEl = document.activeElement;
|
|
|
|
if (activeEl?.tagName === "INPUT" || activeEl?.type === "textarea") {
|
|
|
|
// Ignore events on inputs, they have their native history
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
keyIgnored = e.key === "Control" || e.key === "Shift" || e.key === "Alt" || e.key === "Meta";
|
|
|
|
if (keyIgnored) return;
|
|
|
|
|
|
|
|
// Check if this is a ctrl+z ctrl+y
|
|
|
|
if (await undoRedo(e)) return;
|
|
|
|
|
|
|
|
// If our active element is some type of input then handle changes after they're done
|
|
|
|
if (bindInput(activeEl)) return;
|
|
|
|
checkState();
|
|
|
|
});
|
|
|
|
},
|
|
|
|
true
|
|
|
|
);
|
|
|
|
|
|
|
|
window.addEventListener("keyup", (e) => {
|
|
|
|
if (keyIgnored) {
|
|
|
|
keyIgnored = false;
|
|
|
|
checkState();
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
// Handle clicking DOM elements (e.g. widgets)
|
|
|
|
window.addEventListener("mouseup", () => {
|
|
|
|
checkState();
|
|
|
|
});
|
|
|
|
|
|
|
|
// Handle prompt queue event for dynamic widget changes
|
|
|
|
api.addEventListener("promptQueued", () => {
|
|
|
|
checkState();
|
|
|
|
});
|
|
|
|
|
|
|
|
// Handle litegraph clicks
|
|
|
|
const processMouseUp = LGraphCanvas.prototype.processMouseUp;
|
|
|
|
LGraphCanvas.prototype.processMouseUp = function (e) {
|
|
|
|
const v = processMouseUp.apply(this, arguments);
|
|
|
|
checkState();
|
|
|
|
return v;
|
|
|
|
};
|
|
|
|
const processMouseDown = LGraphCanvas.prototype.processMouseDown;
|
|
|
|
LGraphCanvas.prototype.processMouseDown = function (e) {
|
|
|
|
const v = processMouseDown.apply(this, arguments);
|
|
|
|
checkState();
|
|
|
|
return v;
|
|
|
|
};
|
|
|
|
|
|
|
|
// Handle litegraph context menu for COMBO widgets
|
|
|
|
const close = LiteGraph.ContextMenu.prototype.close;
|
|
|
|
LiteGraph.ContextMenu.prototype.close = function(e) {
|
|
|
|
const v = close.apply(this, arguments);
|
|
|
|
checkState();
|
|
|
|
return v;
|
|
|
|
}
|