mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2025-09-13 13:05:07 +00:00
New Menu & Workflow Management (#3112)
* menu * wip * wip * wip * wip * wip * workflow saving/loading * Support inserting workflows Move buttosn to top of lists * fix session storage implement renaming * temp * refactor, better workflow instance management * wip * progress on progress * added send to workflow various fixes * Support multiple image loaders * Support dynamic size breakpoints based on content * various fixes add close unsaved warning * Add filtering tree * prevent renaming unsaved * fix zindex on hover * fix top offset * use filename as workflow name * resize on setting change * hide element until it is drawn * remove glow * Fix export name * Fix test, revert accidental changes to groupNode * Fix colors on all themes * show hover items on smaller screen (mobile) * remove debugging code * dialog fix * Dont reorder open workflows Allow elements around canvas * Toggle body display on setting change * Fix menu disappearing on chrome * Increase delay when typing, remove margin on Safari, fix dialog location * Fix overflow issue on iOS * Add reset view button Prevent view changes causing history entries * Bottom menu wip * Various fixes * Fix merge * Fix breaking old menu position * Fix merge adding restore view to loadGraphData
This commit is contained in:
64
web/scripts/ui/components/asyncDialog.js
Normal file
64
web/scripts/ui/components/asyncDialog.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import { ComfyDialog } from "../dialog.js";
|
||||
import { $el } from "../../ui.js";
|
||||
|
||||
export class ComfyAsyncDialog extends ComfyDialog {
|
||||
#resolve;
|
||||
|
||||
constructor(actions) {
|
||||
super(
|
||||
"dialog.comfy-dialog.comfyui-dialog",
|
||||
actions?.map((opt) => {
|
||||
if (typeof opt === "string") {
|
||||
opt = { text: opt };
|
||||
}
|
||||
return $el("button.comfyui-button", {
|
||||
type: "button",
|
||||
textContent: opt.text,
|
||||
onclick: () => this.close(opt.value ?? opt.text),
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
show(html) {
|
||||
this.element.addEventListener("close", () => {
|
||||
this.close();
|
||||
});
|
||||
|
||||
super.show(html);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.#resolve = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
showModal(html) {
|
||||
this.element.addEventListener("close", () => {
|
||||
this.close();
|
||||
});
|
||||
|
||||
super.show(html);
|
||||
this.element.showModal();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.#resolve = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
close(result = null) {
|
||||
this.#resolve(result);
|
||||
this.element.close();
|
||||
super.close();
|
||||
}
|
||||
|
||||
static async prompt({ title = null, message, actions }) {
|
||||
const dialog = new ComfyAsyncDialog(actions);
|
||||
const content = [$el("span", message)];
|
||||
if (title) {
|
||||
content.unshift($el("h3", title));
|
||||
}
|
||||
const res = await dialog.showModal(content);
|
||||
dialog.element.remove();
|
||||
return res;
|
||||
}
|
||||
}
|
163
web/scripts/ui/components/button.js
Normal file
163
web/scripts/ui/components/button.js
Normal file
@@ -0,0 +1,163 @@
|
||||
// @ts-check
|
||||
|
||||
import { $el } from "../../ui.js";
|
||||
import { applyClasses, toggleElement } from "../utils.js";
|
||||
import { prop } from "../../utils.js";
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* icon?: string;
|
||||
* overIcon?: string;
|
||||
* iconSize?: number;
|
||||
* content?: string | HTMLElement;
|
||||
* tooltip?: string;
|
||||
* enabled?: boolean;
|
||||
* action?: (e: Event, btn: ComfyButton) => void,
|
||||
* classList?: import("../utils.js").ClassList,
|
||||
* visibilitySetting?: { id: string, showValue: any },
|
||||
* app?: import("../../app.js").ComfyApp
|
||||
* }} ComfyButtonProps
|
||||
*/
|
||||
export class ComfyButton {
|
||||
#over = 0;
|
||||
#popupOpen = false;
|
||||
isOver = false;
|
||||
iconElement = $el("i.mdi");
|
||||
contentElement = $el("span");
|
||||
/**
|
||||
* @type {import("./popup.js").ComfyPopup}
|
||||
*/
|
||||
popup;
|
||||
|
||||
/**
|
||||
* @param {ComfyButtonProps} opts
|
||||
*/
|
||||
constructor({
|
||||
icon,
|
||||
overIcon,
|
||||
iconSize,
|
||||
content,
|
||||
tooltip,
|
||||
action,
|
||||
classList = "comfyui-button",
|
||||
visibilitySetting,
|
||||
app,
|
||||
enabled = true,
|
||||
}) {
|
||||
this.element = $el("button", {
|
||||
onmouseenter: () => {
|
||||
this.isOver = true;
|
||||
if(this.overIcon) {
|
||||
this.updateIcon();
|
||||
}
|
||||
},
|
||||
onmouseleave: () => {
|
||||
this.isOver = false;
|
||||
if(this.overIcon) {
|
||||
this.updateIcon();
|
||||
}
|
||||
}
|
||||
|
||||
}, [this.iconElement, this.contentElement]);
|
||||
|
||||
this.icon = prop(this, "icon", icon, toggleElement(this.iconElement, { onShow: this.updateIcon }));
|
||||
this.overIcon = prop(this, "overIcon", overIcon, () => {
|
||||
if(this.isOver) {
|
||||
this.updateIcon();
|
||||
}
|
||||
});
|
||||
this.iconSize = prop(this, "iconSize", iconSize, this.updateIcon);
|
||||
this.content = prop(
|
||||
this,
|
||||
"content",
|
||||
content,
|
||||
toggleElement(this.contentElement, {
|
||||
onShow: (el, v) => {
|
||||
if (typeof v === "string") {
|
||||
el.textContent = v;
|
||||
} else {
|
||||
el.replaceChildren(v);
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
this.tooltip = prop(this, "tooltip", tooltip, (v) => {
|
||||
if (v) {
|
||||
this.element.title = v;
|
||||
} else {
|
||||
this.element.removeAttribute("title");
|
||||
}
|
||||
});
|
||||
this.classList = prop(this, "classList", classList, this.updateClasses);
|
||||
this.hidden = prop(this, "hidden", false, this.updateClasses);
|
||||
this.enabled = prop(this, "enabled", enabled, () => {
|
||||
this.updateClasses();
|
||||
this.element.disabled = !this.enabled;
|
||||
});
|
||||
this.action = prop(this, "action", action);
|
||||
this.element.addEventListener("click", (e) => {
|
||||
if (this.popup) {
|
||||
// we are either a touch device or triggered by click not hover
|
||||
if (!this.#over) {
|
||||
this.popup.toggle();
|
||||
}
|
||||
}
|
||||
this.action?.(e, this);
|
||||
});
|
||||
|
||||
if (visibilitySetting?.id) {
|
||||
const settingUpdated = () => {
|
||||
this.hidden = app.ui.settings.getSettingValue(visibilitySetting.id) !== visibilitySetting.showValue;
|
||||
};
|
||||
app.ui.settings.addEventListener(visibilitySetting.id + ".change", settingUpdated);
|
||||
settingUpdated();
|
||||
}
|
||||
}
|
||||
|
||||
updateIcon = () => (this.iconElement.className = `mdi mdi-${(this.isOver && this.overIcon) || this.icon}${this.iconSize ? " mdi-" + this.iconSize + "px" : ""}`);
|
||||
updateClasses = () => {
|
||||
const internalClasses = [];
|
||||
if (this.hidden) {
|
||||
internalClasses.push("hidden");
|
||||
}
|
||||
if (!this.enabled) {
|
||||
internalClasses.push("disabled");
|
||||
}
|
||||
if (this.popup) {
|
||||
if (this.#popupOpen) {
|
||||
internalClasses.push("popup-open");
|
||||
} else {
|
||||
internalClasses.push("popup-closed");
|
||||
}
|
||||
}
|
||||
applyClasses(this.element, this.classList, ...internalClasses);
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param { import("./popup.js").ComfyPopup } popup
|
||||
* @param { "click" | "hover" } mode
|
||||
*/
|
||||
withPopup(popup, mode = "click") {
|
||||
this.popup = popup;
|
||||
|
||||
if (mode === "hover") {
|
||||
for (const el of [this.element, this.popup.element]) {
|
||||
el.addEventListener("mouseenter", () => {
|
||||
this.popup.open = !!++this.#over;
|
||||
});
|
||||
el.addEventListener("mouseleave", () => {
|
||||
this.popup.open = !!--this.#over;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
popup.addEventListener("change", () => {
|
||||
this.#popupOpen = popup.open;
|
||||
this.updateClasses();
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
45
web/scripts/ui/components/buttonGroup.js
Normal file
45
web/scripts/ui/components/buttonGroup.js
Normal file
@@ -0,0 +1,45 @@
|
||||
// @ts-check
|
||||
|
||||
import { $el } from "../../ui.js";
|
||||
import { ComfyButton } from "./button.js";
|
||||
import { prop } from "../../utils.js";
|
||||
|
||||
export class ComfyButtonGroup {
|
||||
element = $el("div.comfyui-button-group");
|
||||
|
||||
/** @param {Array<ComfyButton | HTMLElement>} buttons */
|
||||
constructor(...buttons) {
|
||||
this.buttons = prop(this, "buttons", buttons, () => this.update());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ComfyButton} button
|
||||
* @param {number} index
|
||||
*/
|
||||
insert(button, index) {
|
||||
this.buttons.splice(index, 0, button);
|
||||
this.update();
|
||||
}
|
||||
|
||||
/** @param {ComfyButton} button */
|
||||
append(button) {
|
||||
this.buttons.push(button);
|
||||
this.update();
|
||||
}
|
||||
|
||||
/** @param {ComfyButton|number} indexOrButton */
|
||||
remove(indexOrButton) {
|
||||
if (typeof indexOrButton !== "number") {
|
||||
indexOrButton = this.buttons.indexOf(indexOrButton);
|
||||
}
|
||||
if (indexOrButton > -1) {
|
||||
const r = this.buttons.splice(indexOrButton, 1);
|
||||
this.update();
|
||||
return r;
|
||||
}
|
||||
}
|
||||
|
||||
update() {
|
||||
this.element.replaceChildren(...this.buttons.map((b) => b["element"] ?? b));
|
||||
}
|
||||
}
|
128
web/scripts/ui/components/popup.js
Normal file
128
web/scripts/ui/components/popup.js
Normal file
@@ -0,0 +1,128 @@
|
||||
// @ts-check
|
||||
|
||||
import { prop } from "../../utils.js";
|
||||
import { $el } from "../../ui.js";
|
||||
import { applyClasses } from "../utils.js";
|
||||
|
||||
export class ComfyPopup extends EventTarget {
|
||||
element = $el("div.comfyui-popup");
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* target: HTMLElement,
|
||||
* container?: HTMLElement,
|
||||
* classList?: import("../utils.js").ClassList,
|
||||
* ignoreTarget?: boolean,
|
||||
* closeOnEscape?: boolean,
|
||||
* position?: "absolute" | "relative",
|
||||
* horizontal?: "left" | "right"
|
||||
* }} param0
|
||||
* @param {...HTMLElement} children
|
||||
*/
|
||||
constructor(
|
||||
{
|
||||
target,
|
||||
container = document.body,
|
||||
classList = "",
|
||||
ignoreTarget = true,
|
||||
closeOnEscape = true,
|
||||
position = "absolute",
|
||||
horizontal = "left",
|
||||
},
|
||||
...children
|
||||
) {
|
||||
super();
|
||||
this.target = target;
|
||||
this.ignoreTarget = ignoreTarget;
|
||||
this.container = container;
|
||||
this.position = position;
|
||||
this.closeOnEscape = closeOnEscape;
|
||||
this.horizontal = horizontal;
|
||||
|
||||
container.append(this.element);
|
||||
|
||||
this.children = prop(this, "children", children, () => {
|
||||
this.element.replaceChildren(...this.children);
|
||||
this.update();
|
||||
});
|
||||
this.classList = prop(this, "classList", classList, () => applyClasses(this.element, this.classList, "comfyui-popup", horizontal));
|
||||
this.open = prop(this, "open", false, (v, o) => {
|
||||
if (v === o) return;
|
||||
if (v) {
|
||||
this.#show();
|
||||
} else {
|
||||
this.#hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.open = !this.open;
|
||||
}
|
||||
|
||||
#hide() {
|
||||
this.element.classList.remove("open");
|
||||
window.removeEventListener("resize", this.update);
|
||||
window.removeEventListener("click", this.#clickHandler, { capture: true });
|
||||
window.removeEventListener("keydown", this.#escHandler, { capture: true });
|
||||
|
||||
this.dispatchEvent(new CustomEvent("close"));
|
||||
this.dispatchEvent(new CustomEvent("change"));
|
||||
}
|
||||
|
||||
#show() {
|
||||
this.element.classList.add("open");
|
||||
this.update();
|
||||
|
||||
window.addEventListener("resize", this.update);
|
||||
window.addEventListener("click", this.#clickHandler, { capture: true });
|
||||
if (this.closeOnEscape) {
|
||||
window.addEventListener("keydown", this.#escHandler, { capture: true });
|
||||
}
|
||||
|
||||
this.dispatchEvent(new CustomEvent("open"));
|
||||
this.dispatchEvent(new CustomEvent("change"));
|
||||
}
|
||||
|
||||
#escHandler = (e) => {
|
||||
if (e.key === "Escape") {
|
||||
this.open = false;
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
};
|
||||
|
||||
#clickHandler = (e) => {
|
||||
/** @type {any} */
|
||||
const target = e.target;
|
||||
if (!this.element.contains(target) && this.ignoreTarget && !this.target.contains(target)) {
|
||||
this.open = false;
|
||||
}
|
||||
};
|
||||
|
||||
update = () => {
|
||||
const rect = this.target.getBoundingClientRect();
|
||||
this.element.style.setProperty("--bottom", "unset");
|
||||
if (this.position === "absolute") {
|
||||
if (this.horizontal === "left") {
|
||||
this.element.style.setProperty("--left", rect.left + "px");
|
||||
} else {
|
||||
this.element.style.setProperty("--left", rect.right - this.element.clientWidth + "px");
|
||||
}
|
||||
this.element.style.setProperty("--top", rect.bottom + "px");
|
||||
this.element.style.setProperty("--limit", rect.bottom + "px");
|
||||
} else {
|
||||
this.element.style.setProperty("--left", 0 + "px");
|
||||
this.element.style.setProperty("--top", rect.height + "px");
|
||||
this.element.style.setProperty("--limit", rect.height + "px");
|
||||
}
|
||||
|
||||
const thisRect = this.element.getBoundingClientRect();
|
||||
if (thisRect.height < 30) {
|
||||
// Move up instead
|
||||
this.element.style.setProperty("--top", "unset");
|
||||
this.element.style.setProperty("--bottom", rect.height + 5 + "px");
|
||||
this.element.style.setProperty("--limit", rect.height + 5 + "px");
|
||||
}
|
||||
};
|
||||
}
|
43
web/scripts/ui/components/splitButton.js
Normal file
43
web/scripts/ui/components/splitButton.js
Normal file
@@ -0,0 +1,43 @@
|
||||
// @ts-check
|
||||
|
||||
import { $el } from "../../ui.js";
|
||||
import { ComfyButton } from "./button.js";
|
||||
import { prop } from "../../utils.js";
|
||||
import { ComfyPopup } from "./popup.js";
|
||||
|
||||
export class ComfySplitButton {
|
||||
/**
|
||||
* @param {{
|
||||
* primary: ComfyButton,
|
||||
* mode?: "hover" | "click",
|
||||
* horizontal?: "left" | "right",
|
||||
* position?: "relative" | "absolute"
|
||||
* }} param0
|
||||
* @param {Array<ComfyButton> | Array<HTMLElement>} items
|
||||
*/
|
||||
constructor({ primary, mode, horizontal = "left", position = "relative" }, ...items) {
|
||||
this.arrow = new ComfyButton({
|
||||
icon: "chevron-down",
|
||||
});
|
||||
this.element = $el("div.comfyui-split-button" + (mode === "hover" ? ".hover" : ""), [
|
||||
$el("div.comfyui-split-primary", primary.element),
|
||||
$el("div.comfyui-split-arrow", this.arrow.element),
|
||||
]);
|
||||
this.popup = new ComfyPopup({
|
||||
target: this.element,
|
||||
container: position === "relative" ? this.element : document.body,
|
||||
classList: "comfyui-split-button-popup" + (mode === "hover" ? " hover" : ""),
|
||||
closeOnEscape: mode === "click",
|
||||
position,
|
||||
horizontal,
|
||||
});
|
||||
|
||||
this.arrow.withPopup(this.popup, mode);
|
||||
|
||||
this.items = prop(this, "items", items, () => this.update());
|
||||
}
|
||||
|
||||
update() {
|
||||
this.popup.element.replaceChildren(...this.items.map((b) => b.element ?? b));
|
||||
}
|
||||
}
|
@@ -1,20 +1,26 @@
|
||||
import { $el } from "../ui.js";
|
||||
|
||||
export class ComfyDialog {
|
||||
constructor() {
|
||||
this.element = $el("div.comfy-modal", { parent: document.body }, [
|
||||
export class ComfyDialog extends EventTarget {
|
||||
#buttons;
|
||||
|
||||
constructor(type = "div", buttons = null) {
|
||||
super();
|
||||
this.#buttons = buttons;
|
||||
this.element = $el(type + ".comfy-modal", { parent: document.body }, [
|
||||
$el("div.comfy-modal-content", [$el("p", { $: (p) => (this.textElement = p) }), ...this.createButtons()]),
|
||||
]);
|
||||
}
|
||||
|
||||
createButtons() {
|
||||
return [
|
||||
$el("button", {
|
||||
type: "button",
|
||||
textContent: "Close",
|
||||
onclick: () => this.close(),
|
||||
}),
|
||||
];
|
||||
return (
|
||||
this.#buttons ?? [
|
||||
$el("button", {
|
||||
type: "button",
|
||||
textContent: "Close",
|
||||
onclick: () => this.close(),
|
||||
}),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
close() {
|
||||
@@ -25,7 +31,7 @@ export class ComfyDialog {
|
||||
if (typeof html === "string") {
|
||||
this.textElement.innerHTML = html;
|
||||
} else {
|
||||
this.textElement.replaceChildren(html);
|
||||
this.textElement.replaceChildren(...(html instanceof Array ? html : [html]));
|
||||
}
|
||||
this.element.style.display = "flex";
|
||||
}
|
||||
|
302
web/scripts/ui/menu/index.js
Normal file
302
web/scripts/ui/menu/index.js
Normal file
@@ -0,0 +1,302 @@
|
||||
// @ts-check
|
||||
|
||||
import { $el } from "../../ui.js";
|
||||
import { downloadBlob } from "../../utils.js";
|
||||
import { ComfyButton } from "../components/button.js";
|
||||
import { ComfyButtonGroup } from "../components/buttonGroup.js";
|
||||
import { ComfySplitButton } from "../components/splitButton.js";
|
||||
import { ComfyViewHistoryButton } from "./viewHistory.js";
|
||||
import { ComfyQueueButton } from "./queueButton.js";
|
||||
import { ComfyWorkflowsMenu } from "./workflows.js";
|
||||
import { ComfyViewQueueButton } from "./viewQueue.js";
|
||||
import { getInteruptButton } from "./interruptButton.js";
|
||||
|
||||
const collapseOnMobile = (t) => {
|
||||
(t.element ?? t).classList.add("comfyui-menu-mobile-collapse");
|
||||
return t;
|
||||
};
|
||||
const showOnMobile = (t) => {
|
||||
(t.element ?? t).classList.add("lt-lg-show");
|
||||
return t;
|
||||
};
|
||||
|
||||
export class ComfyAppMenu {
|
||||
#sizeBreak = "lg";
|
||||
#lastSizeBreaks = {
|
||||
lg: null,
|
||||
md: null,
|
||||
sm: null,
|
||||
xs: null,
|
||||
};
|
||||
#sizeBreaks = Object.keys(this.#lastSizeBreaks);
|
||||
#cachedInnerSize = null;
|
||||
#cacheTimeout = null;
|
||||
|
||||
/**
|
||||
* @param { import("../../app.js").ComfyApp } app
|
||||
*/
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
|
||||
this.workflows = new ComfyWorkflowsMenu(app);
|
||||
const getSaveButton = (t) =>
|
||||
new ComfyButton({
|
||||
icon: "content-save",
|
||||
tooltip: "Save the current workflow",
|
||||
action: () => app.workflowManager.activeWorkflow.save(),
|
||||
content: t,
|
||||
});
|
||||
|
||||
this.logo = $el("h1.comfyui-logo.nlg-hide", { title: "ComfyUI" }, "ComfyUI");
|
||||
this.saveButton = new ComfySplitButton(
|
||||
{
|
||||
primary: getSaveButton(),
|
||||
mode: "hover",
|
||||
position: "absolute",
|
||||
},
|
||||
getSaveButton("Save"),
|
||||
new ComfyButton({
|
||||
icon: "content-save-edit",
|
||||
content: "Save As",
|
||||
tooltip: "Save the current graph as a new workflow",
|
||||
action: () => app.workflowManager.activeWorkflow.save(true),
|
||||
}),
|
||||
new ComfyButton({
|
||||
icon: "download",
|
||||
content: "Export",
|
||||
tooltip: "Export the current workflow as JSON",
|
||||
action: () => this.exportWorkflow("workflow", "workflow"),
|
||||
}),
|
||||
new ComfyButton({
|
||||
icon: "api",
|
||||
content: "Export (API Format)",
|
||||
tooltip: "Export the current workflow as JSON for use with the ComfyUI API",
|
||||
action: () => this.exportWorkflow("workflow_api", "output"),
|
||||
visibilitySetting: { id: "Comfy.DevMode", showValue: true },
|
||||
app,
|
||||
})
|
||||
);
|
||||
this.actionsGroup = new ComfyButtonGroup(
|
||||
new ComfyButton({
|
||||
icon: "refresh",
|
||||
content: "Refresh",
|
||||
tooltip: "Refresh widgets in nodes to find new models or files",
|
||||
action: () => app.refreshComboInNodes(),
|
||||
}),
|
||||
new ComfyButton({
|
||||
icon: "clipboard-edit-outline",
|
||||
content: "Clipspace",
|
||||
tooltip: "Open Clipspace window",
|
||||
action: () => app["openClipspace"](),
|
||||
}),
|
||||
new ComfyButton({
|
||||
icon: "fit-to-page-outline",
|
||||
content: "Reset View",
|
||||
tooltip: "Reset the canvas view",
|
||||
action: () => app.resetView(),
|
||||
}),
|
||||
new ComfyButton({
|
||||
icon: "cancel",
|
||||
content: "Clear",
|
||||
tooltip: "Clears current workflow",
|
||||
action: () => {
|
||||
if (!app.ui.settings.getSettingValue("Comfy.ConfirmClear", true) || confirm("Clear workflow?")) {
|
||||
app.clean();
|
||||
app.graph.clear();
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
this.settingsGroup = new ComfyButtonGroup(
|
||||
new ComfyButton({
|
||||
icon: "cog",
|
||||
content: "Settings",
|
||||
tooltip: "Open settings",
|
||||
action: () => {
|
||||
app.ui.settings.show();
|
||||
},
|
||||
})
|
||||
);
|
||||
this.viewGroup = new ComfyButtonGroup(
|
||||
new ComfyViewHistoryButton(app).element,
|
||||
new ComfyViewQueueButton(app).element,
|
||||
getInteruptButton("nlg-hide").element
|
||||
);
|
||||
this.mobileMenuButton = new ComfyButton({
|
||||
icon: "menu",
|
||||
action: (_, btn) => {
|
||||
btn.icon = this.element.classList.toggle("expanded") ? "menu-open" : "menu";
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
},
|
||||
classList: "comfyui-button comfyui-menu-button",
|
||||
});
|
||||
|
||||
this.element = $el("nav.comfyui-menu.lg", { style: { display: "none" } }, [
|
||||
this.logo,
|
||||
this.workflows.element,
|
||||
this.saveButton.element,
|
||||
collapseOnMobile(this.actionsGroup).element,
|
||||
$el("section.comfyui-menu-push"),
|
||||
collapseOnMobile(this.settingsGroup).element,
|
||||
collapseOnMobile(this.viewGroup).element,
|
||||
|
||||
getInteruptButton("lt-lg-show").element,
|
||||
new ComfyQueueButton(app).element,
|
||||
showOnMobile(this.mobileMenuButton).element,
|
||||
]);
|
||||
|
||||
let resizeHandler;
|
||||
this.menuPositionSetting = app.ui.settings.addSetting({
|
||||
id: "Comfy.UseNewMenu",
|
||||
defaultValue: "Disabled",
|
||||
name: "[Beta] Use new menu and workflow management. Note: On small screens the menu will always be at the top.",
|
||||
type: "combo",
|
||||
options: ["Disabled", "Top", "Bottom"],
|
||||
onChange: async (v) => {
|
||||
if (v && v !== "Disabled") {
|
||||
if (!resizeHandler) {
|
||||
resizeHandler = () => {
|
||||
this.calculateSizeBreak();
|
||||
};
|
||||
window.addEventListener("resize", resizeHandler);
|
||||
}
|
||||
this.updatePosition(v);
|
||||
} else {
|
||||
if (resizeHandler) {
|
||||
window.removeEventListener("resize", resizeHandler);
|
||||
resizeHandler = null;
|
||||
}
|
||||
document.body.style.removeProperty("display");
|
||||
app.ui.menuContainer.style.removeProperty("display");
|
||||
this.element.style.display = "none";
|
||||
app.ui.restoreMenuPosition();
|
||||
}
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
updatePosition(v) {
|
||||
document.body.style.display = "grid";
|
||||
this.app.ui.menuContainer.style.display = "none";
|
||||
this.element.style.removeProperty("display");
|
||||
this.position = v;
|
||||
if (v === "Bottom") {
|
||||
this.app.bodyBottom.append(this.element);
|
||||
} else {
|
||||
this.app.bodyTop.prepend(this.element);
|
||||
}
|
||||
this.calculateSizeBreak();
|
||||
}
|
||||
|
||||
updateSizeBreak(idx, prevIdx, direction) {
|
||||
const newSize = this.#sizeBreaks[idx];
|
||||
if (newSize === this.#sizeBreak) return;
|
||||
this.#cachedInnerSize = null;
|
||||
clearTimeout(this.#cacheTimeout);
|
||||
|
||||
this.#sizeBreak = this.#sizeBreaks[idx];
|
||||
for (let i = 0; i < this.#sizeBreaks.length; i++) {
|
||||
const sz = this.#sizeBreaks[i];
|
||||
if (sz === this.#sizeBreak) {
|
||||
this.element.classList.add(sz);
|
||||
} else {
|
||||
this.element.classList.remove(sz);
|
||||
}
|
||||
if (i < idx) {
|
||||
this.element.classList.add("lt-" + sz);
|
||||
} else {
|
||||
this.element.classList.remove("lt-" + sz);
|
||||
}
|
||||
}
|
||||
|
||||
if (idx) {
|
||||
// We're on a small screen, force the menu at the top
|
||||
if (this.position !== "Top") {
|
||||
this.updatePosition("Top");
|
||||
}
|
||||
} else if (this.position != this.menuPositionSetting.value) {
|
||||
// Restore user position
|
||||
this.updatePosition(this.menuPositionSetting.value);
|
||||
}
|
||||
|
||||
// Allow multiple updates, but prevent bouncing
|
||||
if (!direction) {
|
||||
direction = prevIdx - idx;
|
||||
} else if (direction != prevIdx - idx) {
|
||||
return;
|
||||
}
|
||||
this.calculateSizeBreak(direction);
|
||||
}
|
||||
|
||||
calculateSizeBreak(direction = 0) {
|
||||
let idx = this.#sizeBreaks.indexOf(this.#sizeBreak);
|
||||
const currIdx = idx;
|
||||
const innerSize = this.calculateInnerSize(idx);
|
||||
if (window.innerWidth >= this.#lastSizeBreaks[this.#sizeBreaks[idx - 1]]) {
|
||||
if (idx > 0) {
|
||||
idx--;
|
||||
}
|
||||
} else if (innerSize > this.element.clientWidth) {
|
||||
this.#lastSizeBreaks[this.#sizeBreak] = Math.max(window.innerWidth, innerSize);
|
||||
// We need to shrink
|
||||
if (idx < this.#sizeBreaks.length - 1) {
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
|
||||
this.updateSizeBreak(idx, currIdx, direction);
|
||||
}
|
||||
|
||||
calculateInnerSize(idx) {
|
||||
// Cache the inner size to prevent too much calculation when resizing the window
|
||||
clearTimeout(this.#cacheTimeout);
|
||||
if (this.#cachedInnerSize) {
|
||||
// Extend cache time
|
||||
this.#cacheTimeout = setTimeout(() => (this.#cachedInnerSize = null), 100);
|
||||
} else {
|
||||
let innerSize = 0;
|
||||
let count = 1;
|
||||
for (const c of this.element.children) {
|
||||
if (c.classList.contains("comfyui-menu-push")) continue; // ignore right push
|
||||
if (idx && c.classList.contains("comfyui-menu-mobile-collapse")) continue; // ignore collapse items
|
||||
innerSize += c.clientWidth;
|
||||
count++;
|
||||
}
|
||||
innerSize += 8 * count;
|
||||
this.#cachedInnerSize = innerSize;
|
||||
this.#cacheTimeout = setTimeout(() => (this.#cachedInnerSize = null), 100);
|
||||
}
|
||||
return this.#cachedInnerSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} defaultName
|
||||
*/
|
||||
getFilename(defaultName) {
|
||||
if (this.app.ui.settings.getSettingValue("Comfy.PromptFilename", true)) {
|
||||
defaultName = prompt("Save workflow as:", defaultName);
|
||||
if (!defaultName) return;
|
||||
if (!defaultName.toLowerCase().endsWith(".json")) {
|
||||
defaultName += ".json";
|
||||
}
|
||||
}
|
||||
return defaultName;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} [filename]
|
||||
* @param { "workflow" | "output" } [promptProperty]
|
||||
*/
|
||||
async exportWorkflow(filename, promptProperty) {
|
||||
if (this.app.workflowManager.activeWorkflow?.path) {
|
||||
filename = this.app.workflowManager.activeWorkflow.name;
|
||||
}
|
||||
const p = await this.app.graphToPrompt();
|
||||
const json = JSON.stringify(p[promptProperty], null, 2);
|
||||
const blob = new Blob([json], { type: "application/json" });
|
||||
const file = this.getFilename(filename);
|
||||
if (!file) return;
|
||||
downloadBlob(file, blob);
|
||||
}
|
||||
}
|
23
web/scripts/ui/menu/interruptButton.js
Normal file
23
web/scripts/ui/menu/interruptButton.js
Normal file
@@ -0,0 +1,23 @@
|
||||
// @ts-check
|
||||
|
||||
import { api } from "../../api.js";
|
||||
import { ComfyButton } from "../components/button.js";
|
||||
|
||||
export function getInteruptButton(visibility) {
|
||||
const btn = new ComfyButton({
|
||||
icon: "close",
|
||||
tooltip: "Cancel current generation",
|
||||
enabled: false,
|
||||
action: () => {
|
||||
api.interrupt();
|
||||
},
|
||||
classList: ["comfyui-button", "comfyui-interrupt-button", visibility],
|
||||
});
|
||||
|
||||
api.addEventListener("status", ({ detail }) => {
|
||||
const sz = detail?.exec_info?.queue_remaining;
|
||||
btn.enabled = sz > 0;
|
||||
});
|
||||
|
||||
return btn;
|
||||
}
|
701
web/scripts/ui/menu/menu.css
Normal file
701
web/scripts/ui/menu/menu.css
Normal file
@@ -0,0 +1,701 @@
|
||||
.relative {
|
||||
position: relative;
|
||||
}
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
.mdi.rotate270::before {
|
||||
transform: rotate(270deg);
|
||||
}
|
||||
|
||||
/* Generic */
|
||||
.comfyui-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.comfyui-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.primary .comfyui-button,
|
||||
.primary.comfyui-button {
|
||||
background-color: var(--primary-bg) !important;
|
||||
color: var(--primary-fg) !important;
|
||||
}
|
||||
|
||||
.primary .comfyui-button:not(:disabled):hover,
|
||||
.primary.comfyui-button:not(:disabled):hover {
|
||||
background-color: var(--primary-hover-bg) !important;
|
||||
color: var(--primary-hover-fg) !important;
|
||||
}
|
||||
|
||||
/* Popup */
|
||||
.comfyui-popup {
|
||||
position: absolute;
|
||||
left: var(--left);
|
||||
right: var(--right);
|
||||
top: var(--top);
|
||||
bottom: var(--bottom);
|
||||
z-index: 2000;
|
||||
max-height: calc(100vh - var(--limit) - 10px);
|
||||
box-shadow: 3px 3px 5px 0px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.comfyui-popup:not(.open) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.comfyui-popup.right.open {
|
||||
border-top-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-bottom-left-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* Split button */
|
||||
.comfyui-split-button {
|
||||
position: relative;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.comfyui-split-primary {
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
.comfyui-split-primary .comfyui-button {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
border-right: 1px solid var(--comfy-menu-bg);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.comfyui-split-arrow .comfyui-button {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
padding-left: 2px;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.comfyui-split-button-popup {
|
||||
white-space: nowrap;
|
||||
background-color: var(--content-bg);
|
||||
color: var(--content-fg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.comfyui-split-button-popup.hover {
|
||||
z-index: 2001;
|
||||
}
|
||||
.comfyui-split-button-popup > .comfyui-button {
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
color: var(--fg-color);
|
||||
padding: 8px 12px 8px 8px;
|
||||
}
|
||||
|
||||
.comfyui-split-button-popup > .comfyui-button:not(:disabled):hover {
|
||||
background-color: var(--comfy-input-bg);
|
||||
}
|
||||
|
||||
/* Button group */
|
||||
.comfyui-button-group {
|
||||
display: flex;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.comfyui-button-group > .comfyui-button,
|
||||
.comfyui-button-group > .comfyui-button-wrapper > .comfyui-button {
|
||||
padding: 4px 10px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* Menu */
|
||||
.comfyui-menu {
|
||||
width: 100vw;
|
||||
background: var(--comfy-menu-bg);
|
||||
color: var(--fg-color);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-size: 0.8em;
|
||||
display: flex;
|
||||
padding: 4px 8px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
box-sizing: border-box;
|
||||
z-index: 1000;
|
||||
order: 0;
|
||||
grid-column: 1/-1;
|
||||
overflow: auto;
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
.comfyui-menu>* {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.comfyui-menu .mdi::before {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.comfyui-menu .comfyui-button {
|
||||
background: var(--comfy-input-bg);
|
||||
color: var(--fg-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.comfyui-menu .comfyui-button:not(:disabled):hover {
|
||||
background: var(--border-color);
|
||||
color: var(--content-fg);
|
||||
}
|
||||
|
||||
.comfyui-menu .comfyui-split-button-popup > .comfyui-button {
|
||||
border-radius: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.comfyui-menu .comfyui-split-button-popup > .comfyui-button:not(:disabled):hover {
|
||||
background-color: var(--comfy-input-bg);
|
||||
}
|
||||
|
||||
.comfyui-menu .comfyui-split-button-popup.left {
|
||||
border-top-right-radius: 4px;
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.comfyui-menu .comfyui-button.popup-open {
|
||||
background-color: var(--content-bg);
|
||||
color: var(--content-fg);
|
||||
}
|
||||
|
||||
.comfyui-menu-push {
|
||||
margin-left: -0.8em;
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
.comfyui-logo {
|
||||
font-size: 1.2em;
|
||||
margin: 0;
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Workflows */
|
||||
.comfyui-workflows-button {
|
||||
flex-direction: row-reverse;
|
||||
max-width: 200px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.comfyui-workflows-button.popup-open {
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
.comfyui-workflows-button.unsaved {
|
||||
font-style: italic;
|
||||
}
|
||||
.comfyui-workflows-button-progress {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: green;
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.comfyui-workflows-button > span {
|
||||
flex: auto;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
}
|
||||
.comfyui-workflows-button-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
width: 150px;
|
||||
}
|
||||
.comfyui-workflows-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
direction: rtl;
|
||||
flex: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.comfyui-workflows-button.unsaved .comfyui-workflows-label {
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.comfyui-workflows-button.unsaved .comfyui-workflows-label:after {
|
||||
content: "*";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
.comfyui-workflows-button-inner .mdi-graph::before {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.comfyui-workflows-popup {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-size: 0.8em;
|
||||
padding: 10px;
|
||||
overflow: auto;
|
||||
background-color: var(--content-bg);
|
||||
color: var(--content-fg);
|
||||
border-top-right-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-bottom-left-radius: 4px;
|
||||
z-index: 400;
|
||||
}
|
||||
|
||||
.comfyui-workflows-panel {
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
.comfyui-workflows-panel .lds-ring {
|
||||
transform: translate(-50%);
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 75px;
|
||||
}
|
||||
|
||||
.comfyui-workflows-panel h3 {
|
||||
margin: 10px 0 10px 0;
|
||||
font-size: 11px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.comfyui-workflows-panel section header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.comfy-ui-workflows-search .mdi {
|
||||
position: relative;
|
||||
top: 2px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.comfy-ui-workflows-search input {
|
||||
background-color: var(--comfy-input-bg);
|
||||
color: var(--input-text);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 4px 10px;
|
||||
margin-left: -24px;
|
||||
text-indent: 18px;
|
||||
}
|
||||
.comfy-ui-workflows-search input:placeholder-shown {
|
||||
width: 10px;
|
||||
}
|
||||
.comfy-ui-workflows-search input:placeholder-shown:focus {
|
||||
width: auto;
|
||||
}
|
||||
.comfyui-workflows-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.comfyui-workflows-actions .comfyui-button {
|
||||
background: var(--comfy-input-bg);
|
||||
color: var(--input-text);
|
||||
}
|
||||
|
||||
.comfyui-workflows-actions .comfyui-button:not(:disabled):hover {
|
||||
background: var(--primary-bg);
|
||||
color: var(--primary-fg);
|
||||
}
|
||||
|
||||
.comfyui-workflows-favorites,
|
||||
.comfyui-workflows-open {
|
||||
border-bottom: 1px solid var(--comfy-input-bg);
|
||||
padding-bottom: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.comfyui-workflows-open .active {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.comfyui-workflows-favorites:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.comfyui-workflows-tree {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.comfyui-workflows-tree:empty::after {
|
||||
content: "No saved workflows";
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
.comfyui-workflows-tree > ul {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.comfyui-workflows-tree > ul ul {
|
||||
margin: 0;
|
||||
padding: 0 0 0 25px;
|
||||
}
|
||||
|
||||
.comfyui-workflows-tree:not(.filtered) .closed > ul {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.comfyui-workflows-tree li,
|
||||
.comfyui-workflows-tree-file {
|
||||
--item-height: 32px;
|
||||
list-style-type: none;
|
||||
height: var(--item-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.comfyui-workflows-tree-file.active::before,
|
||||
.comfyui-workflows-tree li:hover::before,
|
||||
.comfyui-workflows-tree-file:hover::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
left: 0;
|
||||
height: var(--item-height);
|
||||
background-color: var(--content-hover-bg);
|
||||
color: var(--content-hover-fg);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.comfyui-workflows-tree-file.active::before {
|
||||
background-color: var(--primary-bg);
|
||||
color: var(--primary-fg);
|
||||
}
|
||||
|
||||
.comfyui-workflows-tree-file.running:not(:hover)::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: var(--progress, 0);
|
||||
left: 0;
|
||||
height: var(--item-height);
|
||||
background-color: green;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.comfyui-workflows-tree-file.unsaved span {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.comfyui-workflows-tree-file span {
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
.comfyui-workflows-tree-file span + .comfyui-workflows-file-action {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.comfyui-workflows-tree-file .comfyui-workflows-file-action {
|
||||
background-color: transparent;
|
||||
color: var(--fg-color);
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.lg ~ .comfyui-workflows-popup .comfyui-workflows-tree-file:not(:hover) .comfyui-workflows-file-action {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.comfyui-workflows-tree-file .comfyui-workflows-file-action:hover {
|
||||
background-color: var(--primary-bg);
|
||||
color: var(--primary-fg);
|
||||
}
|
||||
|
||||
.comfyui-workflows-tree-file .comfyui-workflows-file-action-primary {
|
||||
background-color: transparent;
|
||||
color: var(--fg-color);
|
||||
padding: 2px 4px;
|
||||
margin: 0 -4px;
|
||||
}
|
||||
|
||||
.comfyui-workflows-file-action-favorite .mdi-star {
|
||||
color: orange;
|
||||
}
|
||||
|
||||
/* View List */
|
||||
.comfyui-view-list-popup {
|
||||
padding: 10px;
|
||||
background-color: var(--content-bg);
|
||||
color: var(--content-fg);
|
||||
min-width: 170px;
|
||||
min-height: 435px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.comfyui-view-list-popup h3 {
|
||||
margin: 0 0 5px 0;
|
||||
}
|
||||
.comfyui-view-list-items {
|
||||
width: 100%;
|
||||
background: var(--comfy-menu-bg);
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex: auto;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
.comfyui-view-list-items section {
|
||||
max-height: 400px;
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: auto auto auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
padding: 5px 0;
|
||||
}
|
||||
.comfyui-view-list-items section + section {
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin-top: 10px;
|
||||
padding-top: 5px;
|
||||
}
|
||||
.comfyui-view-list-items section h5 {
|
||||
grid-column: 1 / 4;
|
||||
text-align: center;
|
||||
margin: 5px;
|
||||
}
|
||||
.comfyui-view-list-items span {
|
||||
text-align: center;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.comfyui-view-list-popup header {
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
.comfyui-view-list-popup header .comfyui-button {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.comfyui-view-list-popup header .comfyui-button:not(:disabled):hover {
|
||||
border: 1px solid var(--comfy-menu-bg);
|
||||
}
|
||||
/* Queue button */
|
||||
.comfyui-queue-button .comfyui-split-primary .comfyui-button {
|
||||
padding-right: 12px;
|
||||
}
|
||||
.comfyui-queue-count {
|
||||
margin-left: 5px;
|
||||
border-radius: 10px;
|
||||
background-color: rgb(8, 80, 153);
|
||||
padding: 2px 4px;
|
||||
font-size: 10px;
|
||||
min-width: 1em;
|
||||
display: inline-block;
|
||||
}
|
||||
/* Queue options*/
|
||||
.comfyui-queue-options {
|
||||
padding: 10px;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.comfyui-queue-batch {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--comfy-menu-bg);
|
||||
padding-right: 10px;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.comfyui-queue-batch input {
|
||||
width: 145px;
|
||||
}
|
||||
|
||||
.comfyui-queue-batch .comfyui-queue-batch-value {
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.comfyui-queue-mode {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.comfyui-queue-mode span {
|
||||
font-weight: bold;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.comfyui-queue-mode label {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
justify-content: start;
|
||||
gap: 5px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.comfyui-queue-mode label input {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/** Send to workflow widget selection dialog */
|
||||
.comfy-widget-selection-dialog {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.comfy-widget-selection-dialog div {
|
||||
color: var(--fg-color);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
.comfy-widget-selection-dialog h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.comfy-widget-selection-dialog section {
|
||||
width: fit-content;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.comfy-widget-selection-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.comfy-widget-selection-item span {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.comfy-widget-selection-item span::before {
|
||||
content: '#' attr(data-id);
|
||||
opacity: 0.5;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.comfy-modal .comfy-widget-selection-item button {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
/***** Responsive *****/
|
||||
.lg.comfyui-menu .lt-lg-show {
|
||||
display: none !important;
|
||||
}
|
||||
.comfyui-menu:not(.lg) .nlg-hide {
|
||||
display: none !important;
|
||||
}
|
||||
/** Large screen */
|
||||
.lg.comfyui-menu>.comfyui-menu-mobile-collapse .comfyui-button span,
|
||||
.lg.comfyui-menu>.comfyui-menu-mobile-collapse.comfyui-button span {
|
||||
display: none;
|
||||
}
|
||||
.lg.comfyui-menu>.comfyui-menu-mobile-collapse .comfyui-popup .comfyui-button span {
|
||||
display: unset;
|
||||
}
|
||||
|
||||
/** Non large screen */
|
||||
.lt-lg.comfyui-menu {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.lt-lg.comfyui-menu > *:not(.comfyui-menu-mobile-collapse) {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.lt-lg.comfyui-menu > .comfyui-menu-mobile-collapse {
|
||||
order: 9999;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.comfyui-body-bottom .lt-lg.comfyui-menu > .comfyui-menu-mobile-collapse {
|
||||
order: -1;
|
||||
}
|
||||
|
||||
.comfyui-body-bottom .lt-lg.comfyui-menu > .comfyui-menu-button {
|
||||
top: unset;
|
||||
bottom: 4px;
|
||||
}
|
||||
|
||||
.lt-lg.comfyui-menu > .comfyui-menu-mobile-collapse.comfyui-button-group {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.lt-lg.comfyui-menu > .comfyui-menu-mobile-collapse .comfyui-button,
|
||||
.lt-lg.comfyui-menu > .comfyui-menu-mobile-collapse.comfyui-button {
|
||||
padding: 10px;
|
||||
}
|
||||
.lt-lg.comfyui-menu > .comfyui-menu-mobile-collapse .comfyui-button,
|
||||
.lt-lg.comfyui-menu > .comfyui-menu-mobile-collapse .comfyui-button-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.lt-lg.comfyui-menu > .comfyui-menu-mobile-collapse .comfyui-popup {
|
||||
position: static;
|
||||
background-color: var(--comfy-input-bg);
|
||||
max-width: unset;
|
||||
max-height: 50vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.lt-lg.comfyui-menu:not(.expanded) > .comfyui-menu-mobile-collapse {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.lt-lg .comfyui-queue-button {
|
||||
margin-right: 44px;
|
||||
}
|
||||
|
||||
.lt-lg .comfyui-menu-button {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
.lt-lg.comfyui-menu > .comfyui-menu-mobile-collapse .comfyui-view-list-popup {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.lt-lg.comfyui-menu .comfyui-workflows-popup {
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
/** Small */
|
||||
.lt-md .comfyui-workflows-button-inner {
|
||||
width: unset !important;
|
||||
}
|
||||
.lt-md .comfyui-workflows-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/** Extra small */
|
||||
.lt-sm .comfyui-queue-button {
|
||||
margin-right: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.lt-sm .comfyui-queue-button .comfyui-button {
|
||||
justify-content: center;
|
||||
}
|
||||
.lt-sm .comfyui-interrupt-button {
|
||||
margin-right: 45px;
|
||||
}
|
||||
.comfyui-body-bottom .lt-sm.comfyui-menu > .comfyui-menu-button{
|
||||
bottom: 41px;
|
||||
}
|
93
web/scripts/ui/menu/queueButton.js
Normal file
93
web/scripts/ui/menu/queueButton.js
Normal file
@@ -0,0 +1,93 @@
|
||||
// @ts-check
|
||||
|
||||
import { ComfyButton } from "../components/button.js";
|
||||
import { $el } from "../../ui.js";
|
||||
import { api } from "../../api.js";
|
||||
import { ComfySplitButton } from "../components/splitButton.js";
|
||||
import { ComfyQueueOptions } from "./queueOptions.js";
|
||||
import { prop } from "../../utils.js";
|
||||
|
||||
export class ComfyQueueButton {
|
||||
element = $el("div.comfyui-queue-button");
|
||||
#internalQueueSize = 0;
|
||||
|
||||
queuePrompt = async (e) => {
|
||||
this.#internalQueueSize += this.queueOptions.batchCount;
|
||||
// Hold shift to queue front
|
||||
await this.app.queuePrompt(-e.shiftKey, this.queueOptions.batchCount);
|
||||
};
|
||||
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
this.queueSizeElement = $el("span.comfyui-queue-count", {
|
||||
textContent: "?",
|
||||
});
|
||||
|
||||
const queue = new ComfyButton({
|
||||
content: $el("div", [
|
||||
$el("span", {
|
||||
textContent: "Queue",
|
||||
}),
|
||||
this.queueSizeElement,
|
||||
]),
|
||||
icon: "play",
|
||||
classList: "comfyui-button",
|
||||
action: this.queuePrompt,
|
||||
});
|
||||
|
||||
this.queueOptions = new ComfyQueueOptions(app);
|
||||
|
||||
const btn = new ComfySplitButton(
|
||||
{
|
||||
primary: queue,
|
||||
mode: "click",
|
||||
position: "absolute",
|
||||
horizontal: "right",
|
||||
},
|
||||
this.queueOptions.element
|
||||
);
|
||||
btn.element.classList.add("primary");
|
||||
this.element.append(btn.element);
|
||||
|
||||
this.autoQueueMode = prop(this, "autoQueueMode", "", () => {
|
||||
switch (this.autoQueueMode) {
|
||||
case "instant":
|
||||
queue.icon = "infinity";
|
||||
break;
|
||||
case "change":
|
||||
queue.icon = "auto-mode";
|
||||
break;
|
||||
default:
|
||||
queue.icon = "play";
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
this.queueOptions.addEventListener("autoQueueMode", (e) => (this.autoQueueMode = e["detail"]));
|
||||
|
||||
api.addEventListener("graphChanged", () => {
|
||||
if (this.autoQueueMode === "change") {
|
||||
if (this.#internalQueueSize) {
|
||||
this.graphHasChanged = true;
|
||||
} else {
|
||||
this.graphHasChanged = false;
|
||||
this.queuePrompt();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
api.addEventListener("status", ({ detail }) => {
|
||||
this.#internalQueueSize = detail?.exec_info?.queue_remaining;
|
||||
if (this.#internalQueueSize != null) {
|
||||
this.queueSizeElement.textContent = this.#internalQueueSize > 99 ? "99+" : this.#internalQueueSize + "";
|
||||
this.queueSizeElement.title = `${this.#internalQueueSize} prompts in queue`;
|
||||
if (!this.#internalQueueSize && !app.lastExecutionError) {
|
||||
if (this.autoQueueMode === "instant" || (this.autoQueueMode === "change" && this.graphHasChanged)) {
|
||||
this.graphHasChanged = false;
|
||||
this.queuePrompt();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
77
web/scripts/ui/menu/queueOptions.js
Normal file
77
web/scripts/ui/menu/queueOptions.js
Normal file
@@ -0,0 +1,77 @@
|
||||
// @ts-check
|
||||
|
||||
import { $el } from "../../ui.js";
|
||||
import { prop } from "../../utils.js";
|
||||
|
||||
export class ComfyQueueOptions extends EventTarget {
|
||||
element = $el("div.comfyui-queue-options");
|
||||
|
||||
constructor(app) {
|
||||
super();
|
||||
this.app = app;
|
||||
|
||||
this.batchCountInput = $el("input", {
|
||||
className: "comfyui-queue-batch-value",
|
||||
type: "number",
|
||||
min: "1",
|
||||
value: "1",
|
||||
oninput: () => (this.batchCount = +this.batchCountInput.value),
|
||||
});
|
||||
|
||||
this.batchCountRange = $el("input", {
|
||||
type: "range",
|
||||
min: "1",
|
||||
max: "100",
|
||||
value: "1",
|
||||
oninput: () => (this.batchCount = +this.batchCountRange.value),
|
||||
});
|
||||
|
||||
this.element.append(
|
||||
$el("div.comfyui-queue-batch", [
|
||||
$el(
|
||||
"label",
|
||||
{
|
||||
textContent: "Batch count: ",
|
||||
},
|
||||
this.batchCountInput
|
||||
),
|
||||
this.batchCountRange,
|
||||
])
|
||||
);
|
||||
|
||||
const createOption = (text, value, checked = false) =>
|
||||
$el(
|
||||
"label",
|
||||
{ textContent: text },
|
||||
$el("input", {
|
||||
type: "radio",
|
||||
name: "AutoQueueMode",
|
||||
checked,
|
||||
value,
|
||||
oninput: (e) => (this.autoQueueMode = e.target["value"]),
|
||||
})
|
||||
);
|
||||
|
||||
this.autoQueueEl = $el("div.comfyui-queue-mode", [
|
||||
$el("span", "Auto Queue:"),
|
||||
createOption("Disabled", "", true),
|
||||
createOption("Instant", "instant"),
|
||||
createOption("On Change", "change"),
|
||||
]);
|
||||
|
||||
this.element.append(this.autoQueueEl);
|
||||
|
||||
this.batchCount = prop(this, "batchCount", 1, () => {
|
||||
this.batchCountInput.value = this.batchCount + "";
|
||||
this.batchCountRange.value = this.batchCount + "";
|
||||
});
|
||||
|
||||
this.autoQueueMode = prop(this, "autoQueueMode", "Disabled", () => {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("autoQueueMode", {
|
||||
detail: this.autoQueueMode,
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
27
web/scripts/ui/menu/viewHistory.js
Normal file
27
web/scripts/ui/menu/viewHistory.js
Normal file
@@ -0,0 +1,27 @@
|
||||
// @ts-check
|
||||
|
||||
import { ComfyButton } from "../components/button.js";
|
||||
import { ComfyViewList, ComfyViewListButton } from "./viewList.js";
|
||||
|
||||
export class ComfyViewHistoryButton extends ComfyViewListButton {
|
||||
constructor(app) {
|
||||
super(app, {
|
||||
button: new ComfyButton({
|
||||
content: "View History",
|
||||
icon: "history",
|
||||
tooltip: "View history",
|
||||
classList: "comfyui-button comfyui-history-button",
|
||||
}),
|
||||
list: ComfyViewHistoryList,
|
||||
mode: "History",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class ComfyViewHistoryList extends ComfyViewList {
|
||||
async loadItems() {
|
||||
const items = await super.loadItems();
|
||||
items["History"].reverse();
|
||||
return items;
|
||||
}
|
||||
}
|
203
web/scripts/ui/menu/viewList.js
Normal file
203
web/scripts/ui/menu/viewList.js
Normal file
@@ -0,0 +1,203 @@
|
||||
// @ts-check
|
||||
|
||||
import { ComfyButton } from "../components/button.js";
|
||||
import { $el } from "../../ui.js";
|
||||
import { api } from "../../api.js";
|
||||
import { ComfyPopup } from "../components/popup.js";
|
||||
|
||||
export class ComfyViewListButton {
|
||||
get open() {
|
||||
return this.popup.open;
|
||||
}
|
||||
|
||||
set open(open) {
|
||||
this.popup.open = open;
|
||||
}
|
||||
|
||||
constructor(app, { button, list, mode }) {
|
||||
this.app = app;
|
||||
this.button = button;
|
||||
this.element = $el("div.comfyui-button-wrapper", this.button.element);
|
||||
this.popup = new ComfyPopup({
|
||||
target: this.element,
|
||||
container: this.element,
|
||||
horizontal: "right",
|
||||
});
|
||||
this.list = new (list ?? ComfyViewList)(app, mode, this.popup);
|
||||
this.popup.children = [this.list.element];
|
||||
this.popup.addEventListener("open", () => {
|
||||
this.list.update();
|
||||
});
|
||||
this.popup.addEventListener("close", () => {
|
||||
this.list.close();
|
||||
});
|
||||
this.button.withPopup(this.popup);
|
||||
|
||||
api.addEventListener("status", () => {
|
||||
if (this.popup.open) {
|
||||
this.popup.update();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class ComfyViewList {
|
||||
popup;
|
||||
|
||||
constructor(app, mode, popup) {
|
||||
this.app = app;
|
||||
this.mode = mode;
|
||||
this.popup = popup;
|
||||
this.type = mode.toLowerCase();
|
||||
|
||||
this.items = $el(`div.comfyui-${this.type}-items.comfyui-view-list-items`);
|
||||
this.clear = new ComfyButton({
|
||||
icon: "cancel",
|
||||
content: "Clear",
|
||||
action: async () => {
|
||||
this.showSpinner(false);
|
||||
await api.clearItems(this.type);
|
||||
await this.update();
|
||||
},
|
||||
});
|
||||
|
||||
this.refresh = new ComfyButton({
|
||||
icon: "refresh",
|
||||
content: "Refresh",
|
||||
action: async () => {
|
||||
await this.update(false);
|
||||
},
|
||||
});
|
||||
|
||||
this.element = $el(`div.comfyui-${this.type}-popup.comfyui-view-list-popup`, [
|
||||
$el("h3", mode),
|
||||
$el("header", [this.clear.element, this.refresh.element]),
|
||||
this.items,
|
||||
]);
|
||||
|
||||
api.addEventListener("status", () => {
|
||||
if (this.popup.open) {
|
||||
this.update();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async close() {
|
||||
this.items.replaceChildren();
|
||||
}
|
||||
|
||||
async update(resize = true) {
|
||||
this.showSpinner(resize);
|
||||
const res = await this.loadItems();
|
||||
let any = false;
|
||||
|
||||
const names = Object.keys(res);
|
||||
const sections = names
|
||||
.map((section) => {
|
||||
const items = res[section];
|
||||
if (items?.length) {
|
||||
any = true;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
if (names.length > 1) {
|
||||
rows.push($el("h5", section));
|
||||
}
|
||||
rows.push(...items.flatMap((item) => this.createRow(item, section)));
|
||||
return $el("section", rows);
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
if (any) {
|
||||
this.items.replaceChildren(...sections);
|
||||
} else {
|
||||
this.items.replaceChildren($el("h5", "None"));
|
||||
}
|
||||
|
||||
this.popup.update();
|
||||
this.clear.enabled = this.refresh.enabled = true;
|
||||
this.element.style.removeProperty("height");
|
||||
}
|
||||
|
||||
showSpinner(resize = true) {
|
||||
// if (!this.spinner) {
|
||||
// this.spinner = createSpinner();
|
||||
// }
|
||||
// if (!resize) {
|
||||
// this.element.style.height = this.element.clientHeight + "px";
|
||||
// }
|
||||
// this.clear.enabled = this.refresh.enabled = false;
|
||||
// this.items.replaceChildren(
|
||||
// $el(
|
||||
// "div",
|
||||
// {
|
||||
// style: {
|
||||
// fontSize: "18px",
|
||||
// },
|
||||
// },
|
||||
// this.spinner
|
||||
// )
|
||||
// );
|
||||
// this.popup.update();
|
||||
}
|
||||
|
||||
async loadItems() {
|
||||
return await api.getItems(this.type);
|
||||
}
|
||||
|
||||
getRow(item, section) {
|
||||
return {
|
||||
text: item.prompt[0] + "",
|
||||
actions: [
|
||||
{
|
||||
text: "Load",
|
||||
action: async () => {
|
||||
try {
|
||||
await this.app.loadGraphData(item.prompt[3].extra_pnginfo.workflow);
|
||||
if (item.outputs) {
|
||||
this.app.nodeOutputs = item.outputs;
|
||||
}
|
||||
} catch (error) {
|
||||
alert("Error loading workflow: " + error.message);
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "Delete",
|
||||
action: async () => {
|
||||
try {
|
||||
await api.deleteItem(this.type, item.prompt[1]);
|
||||
this.update();
|
||||
} catch (error) {}
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
createRow = (item, section) => {
|
||||
const row = this.getRow(item, section);
|
||||
return [
|
||||
$el("span", row.text),
|
||||
...row.actions.map(
|
||||
(a) =>
|
||||
new ComfyButton({
|
||||
content: a.text,
|
||||
action: async (e, btn) => {
|
||||
btn.enabled = false;
|
||||
try {
|
||||
await a.action();
|
||||
} catch (error) {
|
||||
throw error;
|
||||
} finally {
|
||||
btn.enabled = true;
|
||||
}
|
||||
},
|
||||
}).element
|
||||
),
|
||||
];
|
||||
};
|
||||
}
|
55
web/scripts/ui/menu/viewQueue.js
Normal file
55
web/scripts/ui/menu/viewQueue.js
Normal file
@@ -0,0 +1,55 @@
|
||||
// @ts-check
|
||||
|
||||
import { ComfyButton } from "../components/button.js";
|
||||
import { ComfyViewList, ComfyViewListButton } from "./viewList.js";
|
||||
import { api } from "../../api.js";
|
||||
|
||||
export class ComfyViewQueueButton extends ComfyViewListButton {
|
||||
constructor(app) {
|
||||
super(app, {
|
||||
button: new ComfyButton({
|
||||
content: "View Queue",
|
||||
icon: "format-list-numbered",
|
||||
tooltip: "View queue",
|
||||
classList: "comfyui-button comfyui-queue-button",
|
||||
}),
|
||||
list: ComfyViewQueueList,
|
||||
mode: "Queue",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class ComfyViewQueueList extends ComfyViewList {
|
||||
getRow = (item, section) => {
|
||||
if (section !== "Running") {
|
||||
return super.getRow(item, section);
|
||||
}
|
||||
return {
|
||||
text: item.prompt[0] + "",
|
||||
actions: [
|
||||
{
|
||||
text: "Load",
|
||||
action: async () => {
|
||||
try {
|
||||
await this.app.loadGraphData(item.prompt[3].extra_pnginfo.workflow);
|
||||
if (item.outputs) {
|
||||
this.app.nodeOutputs = item.outputs;
|
||||
}
|
||||
} catch (error) {
|
||||
alert("Error loading workflow: " + error.message);
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "Cancel",
|
||||
action: async () => {
|
||||
try {
|
||||
await api.interrupt();
|
||||
} catch (error) {}
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
764
web/scripts/ui/menu/workflows.js
Normal file
764
web/scripts/ui/menu/workflows.js
Normal file
@@ -0,0 +1,764 @@
|
||||
// @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"
|
||||
),
|
||||
]);
|
||||
})
|
||||
),
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
@@ -47,6 +47,17 @@ export class ComfySettingsDialog extends ComfyDialog {
|
||||
return Object.values(this.settingsLookup);
|
||||
}
|
||||
|
||||
#dispatchChange(id, value, oldValue) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent(id + ".change", {
|
||||
detail: {
|
||||
value,
|
||||
oldValue
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async load() {
|
||||
if (this.app.storageLocation === "browser") {
|
||||
this.settingsValues = localStorage;
|
||||
@@ -56,7 +67,9 @@ export class ComfySettingsDialog extends ComfyDialog {
|
||||
|
||||
// Trigger onChange for any settings added before load
|
||||
for (const id in this.settingsLookup) {
|
||||
this.settingsLookup[id].onChange?.(this.settingsValues[this.getId(id)]);
|
||||
const value = this.settingsValues[this.getId(id)];
|
||||
this.settingsLookup[id].onChange?.(value);
|
||||
this.#dispatchChange(id, value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +103,7 @@ export class ComfySettingsDialog extends ComfyDialog {
|
||||
if (id in this.settingsLookup) {
|
||||
this.settingsLookup[id].onChange?.(value, oldValue);
|
||||
}
|
||||
this.#dispatchChange(id, value, oldValue);
|
||||
|
||||
await api.storeSetting(id, value);
|
||||
}
|
||||
@@ -136,6 +150,8 @@ export class ComfySettingsDialog extends ComfyDialog {
|
||||
onChange,
|
||||
name,
|
||||
render: () => {
|
||||
if (type === "hidden") return;
|
||||
|
||||
const setter = (v) => {
|
||||
if (onChange) {
|
||||
onChange(v, value);
|
||||
@@ -310,7 +326,7 @@ export class ComfySettingsDialog extends ComfyDialog {
|
||||
},
|
||||
[$el("th"), $el("th", { style: { width: "33%" } })]
|
||||
),
|
||||
...this.settings.sort((a, b) => a.name.localeCompare(b.name)).map((s) => s.render())
|
||||
...this.settings.sort((a, b) => a.name.localeCompare(b.name)).map((s) => s.render()).filter(Boolean)
|
||||
);
|
||||
this.element.showModal();
|
||||
}
|
||||
|
56
web/scripts/ui/utils.js
Normal file
56
web/scripts/ui/utils.js
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* @typedef { string | string[] | Record<string, boolean> } ClassList
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param { HTMLElement } element
|
||||
* @param { ClassList } classList
|
||||
* @param { string[] } requiredClasses
|
||||
*/
|
||||
export function applyClasses(element, classList, ...requiredClasses) {
|
||||
classList ??= "";
|
||||
|
||||
let str;
|
||||
if (typeof classList === "string") {
|
||||
str = classList;
|
||||
} else if (classList instanceof Array) {
|
||||
str = classList.join(" ");
|
||||
} else {
|
||||
str = Object.entries(classList).reduce((p, c) => {
|
||||
if (c[1]) {
|
||||
p += (p.length ? " " : "") + c[0];
|
||||
}
|
||||
return p;
|
||||
}, "");
|
||||
}
|
||||
element.className = str;
|
||||
if (requiredClasses) {
|
||||
element.classList.add(...requiredClasses);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param { HTMLElement } element
|
||||
* @param { { onHide?: (el: HTMLElement) => void, onShow?: (el: HTMLElement, value) => void } } [param1]
|
||||
* @returns
|
||||
*/
|
||||
export function toggleElement(element, { onHide, onShow } = {}) {
|
||||
let placeholder;
|
||||
let hidden;
|
||||
return (value) => {
|
||||
if (value) {
|
||||
if (hidden) {
|
||||
hidden = false;
|
||||
placeholder.replaceWith(element);
|
||||
}
|
||||
onShow?.(element, value);
|
||||
} else {
|
||||
if (!placeholder) {
|
||||
placeholder = document.createComment("");
|
||||
}
|
||||
hidden = true;
|
||||
element.replaceWith(placeholder);
|
||||
onHide?.(element);
|
||||
}
|
||||
};
|
||||
}
|
Reference in New Issue
Block a user