Unverified Commit 90aebb6c authored by pythongosssss's avatar pythongosssss Committed by GitHub
Browse files

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
parent eab211bb
// @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);
}
}
// @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;
}
.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;
}
\ No newline at end of file
// @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();
}
}
}
});
}
}
// @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,
})
);
});
}
}
// @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;
}
}
// @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
),
];
};
}
// @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) {}
},
},
],
};
}
}
This diff is collapsed.
......@@ -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();
}
......
/**
* @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);
}
};
}
import { $el } from "./ui.js";
import { api } from "./api.js";
// Simple date formatter
const parts = {
......@@ -25,6 +26,19 @@ function formatDate(text, date) {
});
}
export function clone(obj) {
try {
if (typeof structuredClone !== "undefined") {
return structuredClone(obj);
}
} catch (error) {
// structuredClone is stricter than using JSON.parse/stringify so fallback to that
}
return JSON.parse(JSON.stringify(obj));
}
export function applyTextReplacements(app, value) {
return value.replace(/%([^%]+)%/g, function (match, text) {
const split = text.split(".");
......@@ -86,3 +100,57 @@ export async function addStylesheet(urlOrFile, relativeTo) {
});
});
}
/**
* @param { string } filename
* @param { Blob } blob
*/
export function downloadBlob(filename, blob) {
const url = URL.createObjectURL(blob);
const a = $el("a", {
href: url,
download: filename,
style: { display: "none" },
parent: document.body,
});
a.click();
setTimeout(function () {
a.remove();
window.URL.revokeObjectURL(url);
}, 0);
}
/**
* @template T
* @param {string} name
* @param {T} [defaultValue]
* @param {(currentValue: any, previousValue: any)=>void} [onChanged]
* @returns {T}
*/
export function prop(target, name, defaultValue, onChanged) {
let currentValue;
Object.defineProperty(target, name, {
get() {
return currentValue;
},
set(newValue) {
const prevValue = currentValue;
currentValue = newValue;
onChanged?.(currentValue, prevValue, target, name);
},
});
return defaultValue;
}
export function getStorageValue(id) {
const clientId = api.clientId ?? api.initialClientId;
return (clientId && sessionStorage.getItem(`${id}:${clientId}`)) ?? localStorage.getItem(id);
}
export function setStorageValue(id, value) {
const clientId = api.clientId ?? api.initialClientId;
if (clientId) {
sessionStorage.setItem(`${id}:${clientId}`, value);
}
localStorage.setItem(id, value);
}
\ No newline at end of file
// @ts-check
import { api } from "./api.js";
import { ChangeTracker } from "./changeTracker.js";
import { ComfyAsyncDialog } from "./ui/components/asyncDialog.js";
import { getStorageValue, setStorageValue } from "./utils.js";
function appendJsonExt(path) {
if (!path.toLowerCase().endsWith(".json")) {
path += ".json";
}
return path;
}
export function trimJsonExt(path) {
return path?.replace(/\.json$/, "");
}
export class ComfyWorkflowManager extends EventTarget {
/** @type {string | null} */
#activePromptId = null;
#unsavedCount = 0;
#activeWorkflow;
/** @type {Record<string, ComfyWorkflow>} */
workflowLookup = {};
/** @type {Array<ComfyWorkflow>} */
workflows = [];
/** @type {Array<ComfyWorkflow>} */
openWorkflows = [];
/** @type {Record<string, {workflow?: ComfyWorkflow, nodes?: Record<string, boolean>}>} */
queuedPrompts = {};
get activeWorkflow() {
return this.#activeWorkflow ?? this.openWorkflows[0];
}
get activePromptId() {
return this.#activePromptId;
}
get activePrompt() {
return this.queuedPrompts[this.#activePromptId];
}
/**
* @param {import("./app.js").ComfyApp} app
*/
constructor(app) {
super();
this.app = app;
ChangeTracker.init(app);
this.#bindExecutionEvents();
}
#bindExecutionEvents() {
// TODO: on reload, set active prompt based on the latest ws message
const emit = () => this.dispatchEvent(new CustomEvent("execute", { detail: this.activePrompt }));
let executing = null;
api.addEventListener("execution_start", (e) => {
this.#activePromptId = e.detail.prompt_id;
// This event can fire before the event is stored, so put a placeholder
this.queuedPrompts[this.#activePromptId] ??= { nodes: {} };
emit();
});
api.addEventListener("execution_cached", (e) => {
if (!this.activePrompt) return;
for (const n of e.detail.nodes) {
this.activePrompt.nodes[n] = true;
}
emit();
});
api.addEventListener("executed", (e) => {
if (!this.activePrompt) return;
this.activePrompt.nodes[e.detail.node] = true;
emit();
});
api.addEventListener("executing", (e) => {
if (!this.activePrompt) return;
if (executing) {
// Seems sometimes nodes that are cached fire executing but not executed
this.activePrompt.nodes[executing] = true;
}
executing = e.detail;
if (!executing) {
delete this.queuedPrompts[this.#activePromptId];
this.#activePromptId = null;
}
emit();
});
}
async loadWorkflows() {
try {
let favorites;
const resp = await api.getUserData("workflows/.index.json");
let info;
if (resp.status === 200) {
info = await resp.json();
favorites = new Set(info?.favorites ?? []);
} else {
favorites = new Set();
}
const workflows = (await api.listUserData("workflows", true, true)).map((w) => {
let workflow = this.workflowLookup[w[0]];
if (!workflow) {
workflow = new ComfyWorkflow(this, w[0], w.slice(1), favorites.has(w[0]));
this.workflowLookup[workflow.path] = workflow;
}
return workflow;
});
this.workflows = workflows;
} catch (error) {
alert("Error loading workflows: " + (error.message ?? error));
this.workflows = [];
}
}
async saveWorkflowMetadata() {
await api.storeUserData("workflows/.index.json", {
favorites: [...this.workflows.filter((w) => w.isFavorite).map((w) => w.path)],
});
}
/**
* @param {string | ComfyWorkflow | null} workflow
*/
setWorkflow(workflow) {
if (workflow && typeof workflow === "string") {
// Selected by path, i.e. on reload of last workflow
const found = this.workflows.find((w) => w.path === workflow);
if (found) {
workflow = found;
workflow.unsaved = !workflow || getStorageValue("Comfy.PreviousWorkflowUnsaved") === "true";
}
}
if (!(workflow instanceof ComfyWorkflow)) {
// Still not found, either reloading a deleted workflow or blank
workflow = new ComfyWorkflow(this, workflow || "Unsaved Workflow" + (this.#unsavedCount++ ? ` (${this.#unsavedCount})` : ""));
}
const index = this.openWorkflows.indexOf(workflow);
if (index === -1) {
// Opening a new workflow
this.openWorkflows.push(workflow);
}
this.#activeWorkflow = workflow;
setStorageValue("Comfy.PreviousWorkflow", this.activeWorkflow.path ?? "");
this.dispatchEvent(new CustomEvent("changeWorkflow"));
}
storePrompt({ nodes, id }) {
this.queuedPrompts[id] ??= {};
this.queuedPrompts[id].nodes = {
...nodes.reduce((p, n) => {
p[n] = false;
return p;
}, {}),
...this.queuedPrompts[id].nodes,
};
this.queuedPrompts[id].workflow = this.activeWorkflow;
}
/**
* @param {ComfyWorkflow} workflow
*/
async closeWorkflow(workflow, warnIfUnsaved = true) {
if (!workflow.isOpen) {
return true;
}
if (workflow.unsaved && warnIfUnsaved) {
const res = await ComfyAsyncDialog.prompt({
title: "Save Changes?",
message: `Do you want to save changes to "${workflow.path ?? workflow.name}" before closing?`,
actions: ["Yes", "No", "Cancel"],
});
if (res === "Yes") {
const active = this.activeWorkflow;
if (active !== workflow) {
// We need to switch to the workflow to save it
await workflow.load();
}
if (!(await workflow.save())) {
// Save was canceled, restore the previous workflow
if (active !== workflow) {
await active.load();
}
return;
}
} else if (res === "Cancel") {
return;
}
}
workflow.changeTracker = null;
this.openWorkflows.splice(this.openWorkflows.indexOf(workflow), 1);
if (this.openWorkflows.length) {
this.#activeWorkflow = this.openWorkflows[0];
await this.#activeWorkflow.load();
} else {
// Load default
await this.app.loadGraphData();
}
}
}
export class ComfyWorkflow {
#name;
#path;
#pathParts;
#isFavorite = false;
/** @type {ChangeTracker | null} */
changeTracker = null;
unsaved = false;
get name() {
return this.#name;
}
get path() {
return this.#path;
}
get pathParts() {
return this.#pathParts;
}
get isFavorite() {
return this.#isFavorite;
}
get isOpen() {
return !!this.changeTracker;
}
/**
* @overload
* @param {ComfyWorkflowManager} manager
* @param {string} path
*/
/**
* @overload
* @param {ComfyWorkflowManager} manager
* @param {string} path
* @param {string[]} pathParts
* @param {boolean} isFavorite
*/
/**
* @param {ComfyWorkflowManager} manager
* @param {string} path
* @param {string[]} [pathParts]
* @param {boolean} [isFavorite]
*/
constructor(manager, path, pathParts, isFavorite) {
this.manager = manager;
if (pathParts) {
this.#updatePath(path, pathParts);
this.#isFavorite = isFavorite;
} else {
this.#name = path;
this.unsaved = true;
}
}
/**
* @param {string} path
* @param {string[]} [pathParts]
*/
#updatePath(path, pathParts) {
this.#path = path;
if (!pathParts) {
if (!path.includes("\\")) {
pathParts = path.split("/");
} else {
pathParts = path.split("\\");
}
}
this.#pathParts = pathParts;
this.#name = trimJsonExt(pathParts[pathParts.length - 1]);
}
async getWorkflowData() {
const resp = await api.getUserData("workflows/" + this.path);
if (resp.status !== 200) {
alert(`Error loading workflow file '${this.path}': ${resp.status} ${resp.statusText}`);
return;
}
return await resp.json();
}
load = async () => {
if (this.isOpen) {
await this.manager.app.loadGraphData(this.changeTracker.activeState, true, this);
} else {
const data = await this.getWorkflowData();
if (!data) return;
await this.manager.app.loadGraphData(data, true, this);
}
};
async save(saveAs = false) {
if (!this.path || saveAs) {
return !!(await this.#save(null, false));
} else {
return !!(await this.#save(this.path, true));
}
}
/**
* @param {boolean} value
*/
async favorite(value) {
try {
if (this.#isFavorite === value) return;
this.#isFavorite = value;
await this.manager.saveWorkflowMetadata();
this.manager.dispatchEvent(new CustomEvent("favorite", { detail: this }));
} catch (error) {
alert("Error favoriting workflow " + this.path + "\n" + (error.message ?? error));
}
}
/**
* @param {string} path
*/
async rename(path) {
path = appendJsonExt(path);
let resp = await api.moveUserData("workflows/" + this.path, "workflows/" + path);
if (resp.status === 409) {
if (!confirm(`Workflow '${path}' already exists, do you want to overwrite it?`)) return resp;
resp = await api.moveUserData("workflows/" + this.path, "workflows/" + path, { overwrite: true });
}
if (resp.status !== 200) {
alert(`Error renaming workflow file '${this.path}': ${resp.status} ${resp.statusText}`);
return;
}
const isFav = this.isFavorite;
if (isFav) {
await this.favorite(false);
}
path = (await resp.json()).substring("workflows/".length);
this.#updatePath(path, null);
if (isFav) {
await this.favorite(true);
}
this.manager.dispatchEvent(new CustomEvent("rename", { detail: this }));
setStorageValue("Comfy.PreviousWorkflow", this.path ?? "");
}
async insert() {
const data = await this.getWorkflowData();
if (!data) return;
const old = localStorage.getItem("litegrapheditor_clipboard");
const graph = new LGraph(data);
const canvas = new LGraphCanvas(null, graph, { skip_events: true, skip_render: true });
canvas.selectNodes();
canvas.copyToClipboard();
this.manager.app.canvas.pasteFromClipboard();
localStorage.setItem("litegrapheditor_clipboard", old);
}
async delete() {
// TODO: fix delete of current workflow - should mark workflow as unsaved and when saving use old name by default
try {
if (this.isFavorite) {
await this.favorite(false);
}
await api.deleteUserData("workflows/" + this.path);
this.unsaved = true;
this.#path = null;
this.#pathParts = null;
this.manager.workflows.splice(this.manager.workflows.indexOf(this), 1);
this.manager.dispatchEvent(new CustomEvent("delete", { detail: this }));
} catch (error) {
alert(`Error deleting workflow: ${error.message || error}`);
}
}
track() {
if (this.changeTracker) {
this.changeTracker.restore();
} else {
this.changeTracker = new ChangeTracker(this);
}
}
/**
* @param {string|null} path
* @param {boolean} overwrite
*/
async #save(path, overwrite) {
if (!path) {
path = prompt("Save workflow as:", trimJsonExt(this.path) ?? this.name ?? "workflow");
if (!path) return;
}
path = appendJsonExt(path);
const p = await this.manager.app.graphToPrompt();
const json = JSON.stringify(p.workflow, null, 2);
let resp = await api.storeUserData("workflows/" + path, json, { stringify: false, throwOnError: false, overwrite });
if (resp.status === 409) {
if (!confirm(`Workflow '${path}' already exists, do you want to overwrite it?`)) return;
resp = await api.storeUserData("workflows/" + path, json, { stringify: false });
}
if (resp.status !== 200) {
alert(`Error saving workflow '${this.path}': ${resp.status} ${resp.statusText}`);
return;
}
path = (await resp.json()).substring("workflows/".length);
if (!this.path) {
// Saved new workflow, patch this instance
this.#updatePath(path, null);
await this.manager.loadWorkflows();
this.unsaved = false;
this.manager.dispatchEvent(new CustomEvent("rename", { detail: this }));
setStorageValue("Comfy.PreviousWorkflow", this.path ?? "");
} else if (path !== this.path) {
// Saved as, open the new copy
await this.manager.loadWorkflows();
const workflow = this.manager.workflowLookup[path];
await workflow.load();
} else {
// Normal save
this.unsaved = false;
this.manager.dispatchEvent(new CustomEvent("save", { detail: this }));
}
return true;
}
}
@import url("scripts/ui/menu/menu.css");
:root {
--fg-color: #000;
--bg-color: #fff;
......@@ -10,12 +12,24 @@
--border-color: #4e4e4e;
--tr-even-bg-color: #222;
--tr-odd-bg-color: #353535;
--primary-bg: #236692;
--primary-fg: #ffffff;
--primary-hover-bg: #3485bb;
--primary-hover-fg: #ffffff;
--content-bg: #e0e0e0;
--content-fg: #000;
--content-hover-bg: #adadad;
--content-hover-fg: #000;
}
@media (prefers-color-scheme: dark) {
:root {
--fg-color: #fff;
--bg-color: #202020;
--content-bg: #4e4e4e;
--content-fg: #fff;
--content-hover-bg: #222;
--content-hover-fg: #fff;
}
}
......@@ -26,11 +40,41 @@ body {
overflow: hidden;
background-color: var(--bg-color);
color: var(--fg-color);
grid-template-columns: auto 1fr auto;
grid-template-rows: auto auto 1fr auto;
min-height: -webkit-fill-available;
max-height: -webkit-fill-available;
min-width: -webkit-fill-available;
max-width: -webkit-fill-available;
}
.comfyui-body-top {
order: 0;
grid-column: 1/-1;
z-index: 10;
}
.comfyui-body-left {
order: 1;
z-index: 10;
}
#graph-canvas {
width: 100%;
height: 100%;
order: 2;
grid-column: 1/-1;
}
.comfyui-body-right {
order: 3;
z-index: 10;
}
.comfyui-body-bottom {
order: 4;
grid-column: 1/-1;
z-index: 10;
}
.comfy-multiline-input {
......@@ -364,6 +408,37 @@ dialog::backdrop {
background: rgba(0, 0, 0, 0.5);
}
.comfy-dialog.comfyui-dialog {
top: 0;
}
.comfy-dialog.comfy-modal {
font-family: Arial, sans-serif;
border-color: var(--bg-color);
box-shadow: none;
border: 2px solid var(--border-color);
}
.comfy-dialog .comfy-modal-content {
flex-direction: row;
flex-wrap: wrap;
gap: 10px;
color: var(--fg-color);
}
.comfy-dialog .comfy-modal-content h3 {
margin-top: 0;
}
.comfy-dialog .comfy-modal-content > p {
width: 100%;
}
.comfy-dialog .comfy-modal-content > .comfyui-button {
flex: 1;
justify-content: center;
}
#comfy-settings-dialog {
padding: 0;
width: 41rem;
......
......@@ -10,24 +10,24 @@ export interface ComfyExtension {
* Allows any initialisation, e.g. loading resources. Called after the canvas is created but before nodes are added
* @param app The ComfyUI app instance
*/
init(app: ComfyApp): Promise<void>;
init?(app: ComfyApp): Promise<void>;
/**
* Allows any additonal setup, called after the application is fully set up and running
* @param app The ComfyUI app instance
*/
setup(app: ComfyApp): Promise<void>;
setup?(app: ComfyApp): Promise<void>;
/**
* Called before nodes are registered with the graph
* @param defs The collection of node definitions, add custom ones or edit existing ones
* @param app The ComfyUI app instance
*/
addCustomNodeDefs(defs: Record<string, ComfyObjectInfo>, app: ComfyApp): Promise<void>;
addCustomNodeDefs?(defs: Record<string, ComfyObjectInfo>, app: ComfyApp): Promise<void>;
/**
* Allows the extension to add custom widgets
* @param app The ComfyUI app instance
* @returns An array of {[widget name]: widget data}
*/
getCustomWidgets(
getCustomWidgets?(
app: ComfyApp
): Promise<
Record<string, (node, inputName, inputData, app) => { widget?: IWidget; minWidth?: number; minHeight?: number }>
......@@ -38,12 +38,12 @@ export interface ComfyExtension {
* @param nodeData The original node object info config object
* @param app The ComfyUI app instance
*/
beforeRegisterNodeDef(nodeType: typeof LGraphNode, nodeData: ComfyObjectInfo, app: ComfyApp): Promise<void>;
beforeRegisterNodeDef?(nodeType: typeof LGraphNode, nodeData: ComfyObjectInfo, app: ComfyApp): Promise<void>;
/**
* Allows the extension to register additional nodes with LGraph after standard nodes are added
* @param app The ComfyUI app instance
*/
registerCustomNodes(app: ComfyApp): Promise<void>;
registerCustomNodes?(app: ComfyApp): Promise<void>;
/**
* Allows the extension to modify a node that has been reloaded onto the graph.
* If you break something in the backend and want to patch workflows in the frontend
......@@ -51,13 +51,13 @@ export interface ComfyExtension {
* @param node The node that has been loaded
* @param app The ComfyUI app instance
*/
loadedGraphNode(node: LGraphNode, app: ComfyApp);
loadedGraphNode?(node: LGraphNode, app: ComfyApp);
/**
* Allows the extension to run code after the constructor of the node
* @param node The node that has been created
* @param app The ComfyUI app instance
*/
nodeCreated(node: LGraphNode, app: ComfyApp);
nodeCreated?(node: LGraphNode, app: ComfyApp);
}
export type ComfyObjectInfo = {
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment