mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2025-01-11 18:35:17 +00:00
b6779d8df3
Fixes an issue where under certain conditions, the ComfyUI custom undo / redo functions would not run when intended to. When trying to undo an action like deleting several nodes, instead the native browser undo runs - e.g. a textarea gets focus and the last typed text is undone. Clicking outside the text area and typing again just keeps doing the same thing.
255 lines
6.6 KiB
JavaScript
Vendored
255 lines
6.6 KiB
JavaScript
Vendored
// @ts-check
|
|
|
|
import { api } from "./api.js";
|
|
import { clone } from "./utils.js";
|
|
|
|
export class ChangeTracker {
|
|
static MAX_HISTORY = 50;
|
|
#app;
|
|
undo = [];
|
|
redo = [];
|
|
activeState = null;
|
|
isOurLoad = false;
|
|
/** @type { import("./workflows").ComfyWorkflow | null } */
|
|
workflow;
|
|
|
|
ds;
|
|
nodeOutputs;
|
|
|
|
get app() {
|
|
return this.#app ?? this.workflow.manager.app;
|
|
}
|
|
|
|
constructor(workflow) {
|
|
this.workflow = workflow;
|
|
}
|
|
|
|
#setApp(app) {
|
|
this.#app = app;
|
|
}
|
|
|
|
store() {
|
|
this.ds = { scale: this.app.canvas.ds.scale, offset: [...this.app.canvas.ds.offset] };
|
|
}
|
|
|
|
restore() {
|
|
if (this.ds) {
|
|
this.app.canvas.ds.scale = this.ds.scale;
|
|
this.app.canvas.ds.offset = this.ds.offset;
|
|
}
|
|
if (this.nodeOutputs) {
|
|
this.app.nodeOutputs = this.nodeOutputs;
|
|
}
|
|
}
|
|
|
|
checkState() {
|
|
if (!this.app.graph) return;
|
|
|
|
const currentState = this.app.graph.serialize();
|
|
if (!this.activeState) {
|
|
this.activeState = clone(currentState);
|
|
return;
|
|
}
|
|
if (!ChangeTracker.graphEqual(this.activeState, currentState)) {
|
|
this.undo.push(this.activeState);
|
|
if (this.undo.length > ChangeTracker.MAX_HISTORY) {
|
|
this.undo.shift();
|
|
}
|
|
this.activeState = clone(currentState);
|
|
this.redo.length = 0;
|
|
this.workflow.unsaved = true;
|
|
api.dispatchEvent(new CustomEvent("graphChanged", { detail: this.activeState }));
|
|
}
|
|
}
|
|
|
|
async updateState(source, target) {
|
|
const prevState = source.pop();
|
|
if (prevState) {
|
|
target.push(this.activeState);
|
|
this.isOurLoad = true;
|
|
await this.app.loadGraphData(prevState, false, false, this.workflow);
|
|
this.activeState = prevState;
|
|
}
|
|
}
|
|
|
|
async undoRedo(e) {
|
|
if (e.ctrlKey || e.metaKey) {
|
|
if (e.key === "y") {
|
|
this.updateState(this.redo, this.undo);
|
|
return true;
|
|
} else if (e.key === "z") {
|
|
this.updateState(this.undo, this.redo);
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
/** @param { import("./app.js").ComfyApp } app */
|
|
static init(app) {
|
|
const changeTracker = () => app.workflowManager.activeWorkflow?.changeTracker ?? globalTracker;
|
|
globalTracker.#setApp(app);
|
|
|
|
const loadGraphData = app.loadGraphData;
|
|
app.loadGraphData = async function () {
|
|
const v = await loadGraphData.apply(this, arguments);
|
|
const ct = changeTracker();
|
|
if (ct.isOurLoad) {
|
|
ct.isOurLoad = false;
|
|
} else {
|
|
ct.checkState();
|
|
}
|
|
return v;
|
|
};
|
|
|
|
let keyIgnored = false;
|
|
window.addEventListener(
|
|
"keydown",
|
|
(e) => {
|
|
const activeEl = document.activeElement;
|
|
requestAnimationFrame(async () => {
|
|
let bindInputEl;
|
|
// If we are auto queue in change mode then we do want to trigger on inputs
|
|
if (!app.ui.autoQueueEnabled || app.ui.autoQueueMode === "instant") {
|
|
if (activeEl?.tagName === "INPUT" || activeEl?.["type"] === "textarea") {
|
|
// Ignore events on inputs, they have their native history
|
|
return;
|
|
}
|
|
bindInputEl = activeEl;
|
|
}
|
|
|
|
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 changeTracker().undoRedo(e)) return;
|
|
|
|
// If our active element is some type of input then handle changes after they're done
|
|
if (ChangeTracker.bindInput(bindInputEl)) return;
|
|
changeTracker().checkState();
|
|
});
|
|
},
|
|
true
|
|
);
|
|
|
|
window.addEventListener("keyup", (e) => {
|
|
if (keyIgnored) {
|
|
keyIgnored = false;
|
|
changeTracker().checkState();
|
|
}
|
|
});
|
|
|
|
// Handle clicking DOM elements (e.g. widgets)
|
|
window.addEventListener("mouseup", () => {
|
|
changeTracker().checkState();
|
|
});
|
|
|
|
// Handle prompt queue event for dynamic widget changes
|
|
api.addEventListener("promptQueued", () => {
|
|
changeTracker().checkState();
|
|
});
|
|
|
|
// Handle litegraph clicks
|
|
const processMouseUp = LGraphCanvas.prototype.processMouseUp;
|
|
LGraphCanvas.prototype.processMouseUp = function (e) {
|
|
const v = processMouseUp.apply(this, arguments);
|
|
changeTracker().checkState();
|
|
return v;
|
|
};
|
|
const processMouseDown = LGraphCanvas.prototype.processMouseDown;
|
|
LGraphCanvas.prototype.processMouseDown = function (e) {
|
|
const v = processMouseDown.apply(this, arguments);
|
|
changeTracker().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);
|
|
changeTracker().checkState();
|
|
return v;
|
|
};
|
|
|
|
// Detects nodes being added via the node search dialog
|
|
const onNodeAdded = LiteGraph.LGraph.prototype.onNodeAdded;
|
|
LiteGraph.LGraph.prototype.onNodeAdded = function () {
|
|
const v = onNodeAdded?.apply(this, arguments);
|
|
if (!app?.configuringGraph) {
|
|
const ct = changeTracker();
|
|
if (!ct.isOurLoad) {
|
|
ct.checkState();
|
|
}
|
|
}
|
|
return v;
|
|
};
|
|
|
|
// Store node outputs
|
|
api.addEventListener("executed", ({ detail }) => {
|
|
const prompt = app.workflowManager.queuedPrompts[detail.prompt_id];
|
|
if (!prompt?.workflow) return;
|
|
const nodeOutputs = (prompt.workflow.changeTracker.nodeOutputs ??= {});
|
|
const output = nodeOutputs[detail.node];
|
|
if (detail.merge && output) {
|
|
for (const k in detail.output ?? {}) {
|
|
const v = output[k];
|
|
if (v instanceof Array) {
|
|
output[k] = v.concat(detail.output[k]);
|
|
} else {
|
|
output[k] = detail.output[k];
|
|
}
|
|
}
|
|
} else {
|
|
nodeOutputs[detail.node] = detail.output;
|
|
}
|
|
});
|
|
}
|
|
|
|
static bindInput(app, activeEl) {
|
|
if (activeEl && activeEl.tagName !== "CANVAS" && activeEl.tagName !== "BODY") {
|
|
for (const evt of ["change", "input", "blur"]) {
|
|
if (`on${evt}` in activeEl) {
|
|
const listener = () => {
|
|
app.workflowManager.activeWorkflow.changeTracker.checkState();
|
|
activeEl.removeEventListener(evt, listener);
|
|
};
|
|
activeEl.addEventListener(evt, listener);
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
static graphEqual(a, b, path = "") {
|
|
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 (!path && 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);
|
|
} else if (path === "extra.ds") {
|
|
// Ignore view changes
|
|
continue;
|
|
}
|
|
if (!ChangeTracker.graphEqual(av, bv, path + (path ? "." : "") + key)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const globalTracker = new ChangeTracker({}); |