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:
pythongosssss
2024-06-25 11:49:25 +01:00
committed by GitHub
parent eab211bb1e
commit 90aebb6c86
35 changed files with 3986 additions and 312 deletions

View 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;
}
}

View 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;
}
}

View 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));
}
}

View 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");
}
};
}

View 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));
}
}

View File

@@ -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";
}

View 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);
}
}

View 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;
}

View 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;
}

View 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();
}
}
}
});
}
}

View 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,
})
);
});
}
}

View 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;
}
}

View 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
),
];
};
}

View 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) {}
},
},
],
};
}
}

View 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"
),
]);
})
),
])
);
}
}

View File

@@ -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
View 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);
}
};
}