ComfyUI/web/scripts/ui/menu/workflows.js

764 lines
21 KiB
JavaScript
Raw Normal View History

// @ts-check
import { ComfyButton } from "../components/button.js";
import { prop, getStorageValue, setStorageValue } from "../../utils.js";
import { $el } from "../../ui.js";
import { api } from "../../api.js";
import { ComfyPopup } from "../components/popup.js";
import { createSpinner } from "../spinner.js";
import { ComfyWorkflow, trimJsonExt } from "../../workflows.js";
import { ComfyAsyncDialog } from "../components/asyncDialog.js";
export class ComfyWorkflowsMenu {
#first = true;
element = $el("div.comfyui-workflows");
get open() {
return this.popup.open;
}
set open(open) {
this.popup.open = open;
}
/**
* @param {import("../../app.js").ComfyApp} app
*/
constructor(app) {
this.app = app;
this.#bindEvents();
const classList = {
"comfyui-workflows-button": true,
"comfyui-button": true,
unsaved: getStorageValue("Comfy.PreviousWorkflowUnsaved") === "true",
running: false,
};
this.buttonProgress = $el("div.comfyui-workflows-button-progress");
this.workflowLabel = $el("span.comfyui-workflows-label", "");
this.button = new ComfyButton({
content: $el("div.comfyui-workflows-button-inner", [$el("i.mdi.mdi-graph"), this.workflowLabel, this.buttonProgress]),
icon: "chevron-down",
classList,
});
this.element.append(this.button.element);
this.popup = new ComfyPopup({ target: this.element, classList: "comfyui-workflows-popup" });
this.content = new ComfyWorkflowsContent(app, this.popup);
this.popup.children = [this.content.element];
this.popup.addEventListener("change", () => {
this.button.icon = "chevron-" + (this.popup.open ? "up" : "down");
});
this.button.withPopup(this.popup);
this.unsaved = prop(this, "unsaved", classList.unsaved, (v) => {
classList.unsaved = v;
this.button.classList = classList;
setStorageValue("Comfy.PreviousWorkflowUnsaved", v);
});
}
#updateProgress = () => {
const prompt = this.app.workflowManager.activePrompt;
let percent = 0;
if (this.app.workflowManager.activeWorkflow === prompt?.workflow) {
const total = Object.values(prompt.nodes);
const done = total.filter(Boolean);
percent = (done.length / total.length) * 100;
}
this.buttonProgress.style.width = percent + "%";
};
#updateActive = () => {
const active = this.app.workflowManager.activeWorkflow;
this.button.tooltip = active.path;
this.workflowLabel.textContent = active.name;
this.unsaved = active.unsaved;
if (this.#first) {
this.#first = false;
this.content.load();
}
this.#updateProgress();
};
#bindEvents() {
this.app.workflowManager.addEventListener("changeWorkflow", this.#updateActive);
this.app.workflowManager.addEventListener("rename", this.#updateActive);
this.app.workflowManager.addEventListener("delete", this.#updateActive);
this.app.workflowManager.addEventListener("save", () => {
this.unsaved = this.app.workflowManager.activeWorkflow.unsaved;
});
this.app.workflowManager.addEventListener("execute", (e) => {
this.#updateProgress();
});
api.addEventListener("graphChanged", () => {
this.unsaved = true;
});
}
#getMenuOptions(callback) {
const menu = [];
const directories = new Map();
for (const workflow of this.app.workflowManager.workflows || []) {
const path = workflow.pathParts;
if (!path) continue;
let parent = menu;
let currentPath = "";
for (let i = 0; i < path.length - 1; i++) {
currentPath += "/" + path[i];
let newParent = directories.get(currentPath);
if (!newParent) {
newParent = {
title: path[i],
has_submenu: true,
submenu: {
options: [],
},
};
parent.push(newParent);
newParent = newParent.submenu.options;
directories.set(currentPath, newParent);
}
parent = newParent;
}
parent.push({
title: trimJsonExt(path[path.length - 1]),
callback: () => callback(workflow),
});
}
return menu;
}
#getFavoriteMenuOptions(callback) {
const menu = [];
for (const workflow of this.app.workflowManager.workflows || []) {
if (workflow.isFavorite) {
menu.push({
title: "⭐ " + workflow.name,
callback: () => callback(workflow),
});
}
}
return menu;
}
/**
* @param {import("../../app.js").ComfyApp} app
*/
registerExtension(app) {
const self = this;
app.registerExtension({
name: "Comfy.Workflows",
async beforeRegisterNodeDef(nodeType) {
function getImageWidget(node) {
const inputs = { ...node.constructor?.nodeData?.input?.required, ...node.constructor?.nodeData?.input?.optional };
for (const input in inputs) {
if (inputs[input][0] === "IMAGEUPLOAD") {
const imageWidget = node.widgets.find((w) => w.name === (inputs[input]?.[1]?.widget ?? "image"));
if (imageWidget) return imageWidget;
}
}
}
function setWidgetImage(node, widget, img) {
const url = new URL(img.src);
const filename = url.searchParams.get("filename");
const subfolder = url.searchParams.get("subfolder");
const type = url.searchParams.get("type");
const imageId = `${subfolder ? subfolder + "/" : ""}${filename} [${type}]`;
widget.value = imageId;
node.imgs = [img];
app.graph.setDirtyCanvas(true, true);
}
/**
* @param {HTMLImageElement} img
* @param {ComfyWorkflow} workflow
*/
async function sendToWorkflow(img, workflow) {
await workflow.load();
let options = [];
const nodes = app.graph.computeExecutionOrder(false);
for (const node of nodes) {
const widget = getImageWidget(node);
if (widget == null) continue;
if (node.title?.toLowerCase().includes("input")) {
options = [{ widget, node }];
break;
} else {
options.push({ widget, node });
}
}
if (!options.length) {
alert("No image nodes have been found in this workflow!");
return;
} else if (options.length > 1) {
const dialog = new WidgetSelectionDialog(options);
const res = await dialog.show(app);
if (!res) return;
options = [res];
}
setWidgetImage(options[0].node, options[0].widget, img);
}
const getExtraMenuOptions = nodeType.prototype["getExtraMenuOptions"];
nodeType.prototype["getExtraMenuOptions"] = function (_, options) {
const r = getExtraMenuOptions?.apply?.(this, arguments);
if (app.ui.settings.getSettingValue("Comfy.UseNewMenu", false) === true) {
const t = /** @type { {imageIndex?: number, overIndex?: number, imgs: string[]} } */ /** @type {any} */ (this);
let img;
if (t.imageIndex != null) {
// An image is selected so select that
img = t.imgs?.[t.imageIndex];
} else if (t.overIndex != null) {
// No image is selected but one is hovered
img = t.img?.s[t.overIndex];
}
if (img) {
let pos = options.findIndex((o) => o.content === "Save Image");
if (pos === -1) {
pos = 0;
} else {
pos++;
}
options.splice(pos, 0, {
content: "Send to workflow",
has_submenu: true,
submenu: {
options: [
{
callback: () => sendToWorkflow(img, app.workflowManager.activeWorkflow),
title: "[Current workflow]",
},
...self.#getFavoriteMenuOptions(sendToWorkflow.bind(null, img)),
null,
...self.#getMenuOptions(sendToWorkflow.bind(null, img)),
],
},
});
}
}
return r;
};
},
});
}
}
export class ComfyWorkflowsContent {
element = $el("div.comfyui-workflows-panel");
treeState = {};
treeFiles = {};
/** @type { Map<ComfyWorkflow, WorkflowElement> } */
openFiles = new Map();
/** @type {WorkflowElement} */
activeElement = null;
/**
* @param {import("../../app.js").ComfyApp} app
* @param {ComfyPopup} popup
*/
constructor(app, popup) {
this.app = app;
this.popup = popup;
this.actions = $el("div.comfyui-workflows-actions", [
new ComfyButton({
content: "Default",
icon: "file-code",
iconSize: 18,
classList: "comfyui-button primary",
tooltip: "Load default workflow",
action: () => {
popup.open = false;
app.loadGraphData();
app.resetView();
},
}).element,
new ComfyButton({
content: "Browse",
icon: "folder",
iconSize: 18,
tooltip: "Browse for an image or exported workflow",
action: () => {
popup.open = false;
app.ui.loadFile();
},
}).element,
new ComfyButton({
content: "Blank",
icon: "plus-thick",
iconSize: 18,
tooltip: "Create a new blank workflow",
action: () => {
app.workflowManager.setWorkflow(null);
app.clean();
app.graph.clear();
app.workflowManager.activeWorkflow.track();
popup.open = false;
},
}).element,
]);
this.spinner = createSpinner();
this.element.replaceChildren(this.actions, this.spinner);
this.popup.addEventListener("open", () => this.load());
this.popup.addEventListener("close", () => this.element.replaceChildren(this.actions, this.spinner));
this.app.workflowManager.addEventListener("favorite", (e) => {
const workflow = e["detail"];
const button = this.treeFiles[workflow.path]?.primary;
if (!button) return; // Can happen when a workflow is renamed
button.icon = this.#getFavoriteIcon(workflow);
button.overIcon = this.#getFavoriteOverIcon(workflow);
this.updateFavorites();
});
for (const e of ["save", "open", "close", "changeWorkflow"]) {
// TODO: dont be lazy and just update the specific element
app.workflowManager.addEventListener(e, () => this.updateOpen());
}
this.app.workflowManager.addEventListener("rename", () => this.load());
this.app.workflowManager.addEventListener("execute", (e) => this.#updateActive());
}
async load() {
await this.app.workflowManager.loadWorkflows();
this.updateTree();
this.updateFavorites();
this.updateOpen();
this.element.replaceChildren(this.actions, this.openElement, this.favoritesElement, this.treeElement);
}
updateOpen() {
const current = this.openElement;
this.openFiles.clear();
this.openElement = $el("div.comfyui-workflows-open", [
$el("h3", "Open"),
...this.app.workflowManager.openWorkflows.map((w) => {
const wrapper = new WorkflowElement(this, w, {
primary: { element: $el("i.mdi.mdi-18px.mdi-progress-pencil") },
buttons: [
this.#getRenameButton(w),
new ComfyButton({
icon: "close",
iconSize: 18,
classList: "comfyui-button comfyui-workflows-file-action",
tooltip: "Close workflow",
action: (e) => {
e.stopImmediatePropagation();
this.app.workflowManager.closeWorkflow(w);
},
}),
],
});
if (w.unsaved) {
wrapper.element.classList.add("unsaved");
}
if(w === this.app.workflowManager.activeWorkflow) {
wrapper.element.classList.add("active");
}
this.openFiles.set(w, wrapper);
return wrapper.element;
}),
]);
this.#updateActive();
current?.replaceWith(this.openElement);
}
updateFavorites() {
const current = this.favoritesElement;
const favorites = [...this.app.workflowManager.workflows.filter((w) => w.isFavorite)];
this.favoritesElement = $el("div.comfyui-workflows-favorites", [
$el("h3", "Favorites"),
...favorites
.map((w) => {
return this.#getWorkflowElement(w).element;
})
.filter(Boolean),
]);
current?.replaceWith(this.favoritesElement);
}
filterTree() {
if (!this.filterText) {
this.treeRoot.classList.remove("filtered");
// Unfilter whole tree
for (const item of Object.values(this.treeFiles)) {
item.element.parentElement.style.removeProperty("display");
this.showTreeParents(item.element.parentElement);
}
return;
}
this.treeRoot.classList.add("filtered");
const searchTerms = this.filterText.toLocaleLowerCase().split(" ");
for (const item of Object.values(this.treeFiles)) {
const parts = item.workflow.pathParts;
let termIndex = 0;
let valid = false;
for (const part of parts) {
let currentIndex = 0;
do {
currentIndex = part.indexOf(searchTerms[termIndex], currentIndex);
if (currentIndex > -1) currentIndex += searchTerms[termIndex].length;
} while (currentIndex !== -1 && ++termIndex < searchTerms.length);
if (termIndex >= searchTerms.length) {
valid = true;
break;
}
}
if (valid) {
item.element.parentElement.style.removeProperty("display");
this.showTreeParents(item.element.parentElement);
} else {
item.element.parentElement.style.display = "none";
this.hideTreeParents(item.element.parentElement);
}
}
}
hideTreeParents(element) {
// Hide all parents if no children are visible
if (element.parentElement?.classList.contains("comfyui-workflows-tree") === false) {
for (let i = 1; i < element.parentElement.children.length; i++) {
const c = element.parentElement.children[i];
if (c.style.display !== "none") {
return;
}
}
element.parentElement.style.display = "none";
this.hideTreeParents(element.parentElement);
}
}
showTreeParents(element) {
if (element.parentElement?.classList.contains("comfyui-workflows-tree") === false) {
element.parentElement.style.removeProperty("display");
this.showTreeParents(element.parentElement);
}
}
updateTree() {
const current = this.treeElement;
const nodes = {};
let typingTimeout;
this.treeFiles = {};
this.treeRoot = $el("ul.comfyui-workflows-tree");
this.treeElement = $el("section", [
$el("header", [
$el("h3", "Browse"),
$el("div.comfy-ui-workflows-search", [
$el("i.mdi.mdi-18px.mdi-magnify"),
$el("input", {
placeholder: "Search",
value: this.filterText ?? "",
oninput: (e) => {
this.filterText = e.target["value"]?.trim();
clearTimeout(typingTimeout);
typingTimeout = setTimeout(() => this.filterTree(), 250);
},
}),
]),
]),
this.treeRoot,
]);
for (const workflow of this.app.workflowManager.workflows) {
if (!workflow.pathParts) continue;
let currentPath = "";
let currentRoot = this.treeRoot;
for (let i = 0; i < workflow.pathParts.length; i++) {
currentPath += (currentPath ? "\\" : "") + workflow.pathParts[i];
const parentNode = nodes[currentPath] ?? this.#createNode(currentPath, workflow, i, currentRoot);
nodes[currentPath] = parentNode;
currentRoot = parentNode;
}
}
current?.replaceWith(this.treeElement);
this.filterTree();
}
#expandNode(el, workflow, thisPath, i) {
const expanded = !el.classList.toggle("closed");
if (expanded) {
let c = "";
for (let j = 0; j <= i; j++) {
c += (c ? "\\" : "") + workflow.pathParts[j];
this.treeState[c] = true;
}
} else {
let c = thisPath;
for (let j = i + 1; j < workflow.pathParts.length; j++) {
c += (c ? "\\" : "") + workflow.pathParts[j];
delete this.treeState[c];
}
delete this.treeState[thisPath];
}
}
#updateActive() {
this.#removeActive();
const active = this.app.workflowManager.activePrompt;
if (!active?.workflow) return;
const open = this.openFiles.get(active.workflow);
if (!open) return;
this.activeElement = open;
const total = Object.values(active.nodes);
const done = total.filter(Boolean);
const percent = done.length / total.length;
open.element.classList.add("running");
open.element.style.setProperty("--progress", percent * 100 + "%");
open.primary.element.classList.remove("mdi-progress-pencil");
open.primary.element.classList.add("mdi-play");
}
#removeActive() {
if (!this.activeElement) return;
this.activeElement.element.classList.remove("running");
this.activeElement.element.style.removeProperty("--progress");
this.activeElement.primary.element.classList.add("mdi-progress-pencil");
this.activeElement.primary.element.classList.remove("mdi-play");
}
/** @param {ComfyWorkflow} workflow */
#getFavoriteIcon(workflow) {
return workflow.isFavorite ? "star" : "file-outline";
}
/** @param {ComfyWorkflow} workflow */
#getFavoriteOverIcon(workflow) {
return workflow.isFavorite ? "star-off" : "star-outline";
}
/** @param {ComfyWorkflow} workflow */
#getFavoriteTooltip(workflow) {
return workflow.isFavorite ? "Remove this workflow from your favorites" : "Add this workflow to your favorites";
}
/** @param {ComfyWorkflow} workflow */
#getFavoriteButton(workflow, primary) {
return new ComfyButton({
icon: this.#getFavoriteIcon(workflow),
overIcon: this.#getFavoriteOverIcon(workflow),
iconSize: 18,
classList: "comfyui-button comfyui-workflows-file-action-favorite" + (primary ? " comfyui-workflows-file-action-primary" : ""),
tooltip: this.#getFavoriteTooltip(workflow),
action: (e) => {
e.stopImmediatePropagation();
workflow.favorite(!workflow.isFavorite);
},
});
}
/** @param {ComfyWorkflow} workflow */
#getDeleteButton(workflow) {
const deleteButton = new ComfyButton({
icon: "delete",
tooltip: "Delete this workflow",
classList: "comfyui-button comfyui-workflows-file-action",
iconSize: 18,
action: async (e, btn) => {
e.stopImmediatePropagation();
if (btn.icon === "delete-empty") {
btn.enabled = false;
await workflow.delete();
await this.load();
} else {
btn.icon = "delete-empty";
btn.element.style.background = "red";
}
},
});
deleteButton.element.addEventListener("mouseleave", () => {
deleteButton.icon = "delete";
deleteButton.element.style.removeProperty("background");
});
return deleteButton;
}
/** @param {ComfyWorkflow} workflow */
#getInsertButton(workflow) {
return new ComfyButton({
icon: "file-move-outline",
iconSize: 18,
tooltip: "Insert this workflow into the current workflow",
classList: "comfyui-button comfyui-workflows-file-action",
action: (e) => {
if (!this.app.shiftDown) {
this.popup.open = false;
}
e.stopImmediatePropagation();
if (!this.app.shiftDown) {
this.popup.open = false;
}
workflow.insert();
},
});
}
/** @param {ComfyWorkflow} workflow */
#getRenameButton(workflow) {
return new ComfyButton({
icon: "pencil",
tooltip: workflow.path ? "Rename this workflow" : "This workflow can't be renamed as it hasn't been saved.",
classList: "comfyui-button comfyui-workflows-file-action",
iconSize: 18,
enabled: !!workflow.path,
action: async (e) => {
e.stopImmediatePropagation();
const newName = prompt("Enter new name", workflow.path);
if (newName) {
await workflow.rename(newName);
}
},
});
}
/** @param {ComfyWorkflow} workflow */
#getWorkflowElement(workflow) {
return new WorkflowElement(this, workflow, {
primary: this.#getFavoriteButton(workflow, true),
buttons: [this.#getInsertButton(workflow), this.#getRenameButton(workflow), this.#getDeleteButton(workflow)],
});
}
/** @param {ComfyWorkflow} workflow */
#createLeafNode(workflow) {
const fileNode = this.#getWorkflowElement(workflow);
this.treeFiles[workflow.path] = fileNode;
return fileNode;
}
#createNode(currentPath, workflow, i, currentRoot) {
const part = workflow.pathParts[i];
const parentNode = $el("ul" + (this.treeState[currentPath] ? "" : ".closed"), {
$: (el) => {
el.onclick = (e) => {
this.#expandNode(el, workflow, currentPath, i);
e.stopImmediatePropagation();
};
},
});
currentRoot.append(parentNode);
// Create a node for the current part and an inner UL for its children if it isnt a leaf node
const leaf = i === workflow.pathParts.length - 1;
let nodeElement;
if (leaf) {
nodeElement = this.#createLeafNode(workflow).element;
} else {
nodeElement = $el("li", [$el("i.mdi.mdi-18px.mdi-folder"), $el("span", part)]);
}
parentNode.append(nodeElement);
return parentNode;
}
}
class WorkflowElement {
/**
* @param { ComfyWorkflowsContent } parent
* @param { ComfyWorkflow } workflow
*/
constructor(parent, workflow, { tagName = "li", primary, buttons }) {
this.parent = parent;
this.workflow = workflow;
this.primary = primary;
this.buttons = buttons;
this.element = $el(
tagName + ".comfyui-workflows-tree-file",
{
onclick: () => {
workflow.load();
this.parent.popup.open = false;
},
title: this.workflow.path,
},
[this.primary?.element, $el("span", workflow.name), ...buttons.map((b) => b.element)]
);
}
}
class WidgetSelectionDialog extends ComfyAsyncDialog {
#options;
/**
* @param {Array<{widget: {name: string}, node: {pos: [number, number], title: string, id: string, type: string}}>} options
*/
constructor(options) {
super();
this.#options = options;
}
show(app) {
this.element.classList.add("comfy-widget-selection-dialog");
return super.show(
$el("div", [
$el("h2", "Select image target"),
$el(
"p",
"This workflow has multiple image loader nodes, you can rename a node to include 'input' in the title for it to be automatically selected, or select one below."
),
$el(
"section",
this.#options.map((opt) => {
return $el("div.comfy-widget-selection-item", [
$el("span", { dataset: { id: opt.node.id } }, `${opt.node.title ?? opt.node.type} ${opt.widget.name}`),
$el(
"button.comfyui-button",
{
onclick: () => {
app.canvas.ds.offset[0] = -opt.node.pos[0] + 50;
app.canvas.ds.offset[1] = -opt.node.pos[1] + 50;
app.canvas.selectNode(opt.node);
app.graph.setDirtyCanvas(true, true);
},
},
"Show"
),
$el(
"button.comfyui-button.primary",
{
onclick: () => {
this.close(opt);
},
},
"Select"
),
]);
})
),
])
);
}
}