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:
parent
eab211bb1e
commit
90aebb6c86
|
@ -2,6 +2,8 @@ import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
|
import glob
|
||||||
|
import shutil
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from comfy.cli_args import args
|
from comfy.cli_args import args
|
||||||
from folder_paths import user_directory
|
from folder_paths import user_directory
|
||||||
|
@ -56,16 +58,16 @@ class UserManager():
|
||||||
if os.path.commonpath((root_dir, user_root)) != root_dir:
|
if os.path.commonpath((root_dir, user_root)) != root_dir:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
parent = user_root
|
|
||||||
|
|
||||||
if file is not None:
|
if file is not None:
|
||||||
# prevent leaving /{type}/{user}
|
# prevent leaving /{type}/{user}
|
||||||
path = os.path.abspath(os.path.join(user_root, file))
|
path = os.path.abspath(os.path.join(user_root, file))
|
||||||
if os.path.commonpath((user_root, path)) != user_root:
|
if os.path.commonpath((user_root, path)) != user_root:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
parent = os.path.split(path)[0]
|
||||||
|
|
||||||
if create_dir and not os.path.exists(parent):
|
if create_dir and not os.path.exists(parent):
|
||||||
os.mkdir(parent)
|
os.makedirs(parent, exist_ok=True)
|
||||||
|
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
@ -108,33 +110,96 @@ class UserManager():
|
||||||
user_id = self.add_user(username)
|
user_id = self.add_user(username)
|
||||||
return web.json_response(user_id)
|
return web.json_response(user_id)
|
||||||
|
|
||||||
@routes.get("/userdata/{file}")
|
@routes.get("/userdata")
|
||||||
async def getuserdata(request):
|
async def listuserdata(request):
|
||||||
file = request.match_info.get("file", None)
|
directory = request.rel_url.query.get('dir', '')
|
||||||
if not file:
|
if not directory:
|
||||||
return web.Response(status=400)
|
return web.Response(status=400)
|
||||||
|
|
||||||
path = self.get_request_user_filepath(request, file)
|
path = self.get_request_user_filepath(request, directory)
|
||||||
if not path:
|
if not path:
|
||||||
return web.Response(status=403)
|
return web.Response(status=403)
|
||||||
|
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
return web.Response(status=404)
|
return web.Response(status=404)
|
||||||
|
|
||||||
return web.FileResponse(path)
|
recurse = request.rel_url.query.get('recurse', '').lower() == "true"
|
||||||
|
results = glob.glob(os.path.join(
|
||||||
|
glob.escape(path), '**/*'), recursive=recurse)
|
||||||
|
results = [os.path.relpath(x, path) for x in results if os.path.isfile(x)]
|
||||||
|
|
||||||
|
split_path = request.rel_url.query.get('split', '').lower() == "true"
|
||||||
|
if split_path:
|
||||||
|
results = [[x] + x.split(os.sep) for x in results]
|
||||||
|
|
||||||
@routes.post("/userdata/{file}")
|
return web.json_response(results)
|
||||||
async def post_userdata(request):
|
|
||||||
file = request.match_info.get("file", None)
|
def get_user_data_path(request, check_exists = False, param = "file"):
|
||||||
|
file = request.match_info.get(param, None)
|
||||||
if not file:
|
if not file:
|
||||||
return web.Response(status=400)
|
return web.Response(status=400)
|
||||||
|
|
||||||
path = self.get_request_user_filepath(request, file)
|
path = self.get_request_user_filepath(request, file)
|
||||||
if not path:
|
if not path:
|
||||||
return web.Response(status=403)
|
return web.Response(status=403)
|
||||||
|
|
||||||
|
if check_exists and not os.path.exists(path):
|
||||||
|
return web.Response(status=404)
|
||||||
|
|
||||||
|
return path
|
||||||
|
|
||||||
|
@routes.get("/userdata/{file}")
|
||||||
|
async def getuserdata(request):
|
||||||
|
path = get_user_data_path(request, check_exists=True)
|
||||||
|
if not isinstance(path, str):
|
||||||
|
return path
|
||||||
|
|
||||||
|
return web.FileResponse(path)
|
||||||
|
|
||||||
|
@routes.post("/userdata/{file}")
|
||||||
|
async def post_userdata(request):
|
||||||
|
path = get_user_data_path(request)
|
||||||
|
if not isinstance(path, str):
|
||||||
|
return path
|
||||||
|
|
||||||
|
overwrite = request.query["overwrite"] != "false"
|
||||||
|
if not overwrite and os.path.exists(path):
|
||||||
|
return web.Response(status=409)
|
||||||
|
|
||||||
body = await request.read()
|
body = await request.read()
|
||||||
|
|
||||||
with open(path, "wb") as f:
|
with open(path, "wb") as f:
|
||||||
f.write(body)
|
f.write(body)
|
||||||
|
|
||||||
return web.Response(status=200)
|
resp = os.path.relpath(path, self.get_request_user_filepath(request, None))
|
||||||
|
return web.json_response(resp)
|
||||||
|
|
||||||
|
@routes.delete("/userdata/{file}")
|
||||||
|
async def delete_userdata(request):
|
||||||
|
path = get_user_data_path(request, check_exists=True)
|
||||||
|
if not isinstance(path, str):
|
||||||
|
return path
|
||||||
|
|
||||||
|
os.remove(path)
|
||||||
|
|
||||||
|
return web.Response(status=204)
|
||||||
|
|
||||||
|
@routes.post("/userdata/{file}/move/{dest}")
|
||||||
|
async def move_userdata(request):
|
||||||
|
source = get_user_data_path(request, check_exists=True)
|
||||||
|
if not isinstance(source, str):
|
||||||
|
return source
|
||||||
|
|
||||||
|
dest = get_user_data_path(request, check_exists=False, param="dest")
|
||||||
|
if not isinstance(source, str):
|
||||||
|
return dest
|
||||||
|
|
||||||
|
overwrite = request.query["overwrite"] != "false"
|
||||||
|
if not overwrite and os.path.exists(dest):
|
||||||
|
return web.Response(status=409)
|
||||||
|
|
||||||
|
print(f"moving '{source}' -> '{dest}'")
|
||||||
|
shutil.move(source, dest)
|
||||||
|
|
||||||
|
resp = os.path.relpath(dest, self.get_request_user_filepath(request, None))
|
||||||
|
return web.json_response(resp)
|
||||||
|
|
|
@ -72,6 +72,7 @@ export function mockApi(config = {}) {
|
||||||
storeUserData: jest.fn((file, data) => {
|
storeUserData: jest.fn((file, data) => {
|
||||||
userData[file] = data;
|
userData[file] = data;
|
||||||
}),
|
}),
|
||||||
|
listUserData: jest.fn(() => [])
|
||||||
};
|
};
|
||||||
jest.mock("../../web/scripts/api", () => ({
|
jest.mock("../../web/scripts/api", () => ({
|
||||||
get api() {
|
get api() {
|
||||||
|
|
|
@ -63,6 +63,10 @@ const colorPalettes = {
|
||||||
"border-color": "#4e4e4e",
|
"border-color": "#4e4e4e",
|
||||||
"tr-even-bg-color": "#222",
|
"tr-even-bg-color": "#222",
|
||||||
"tr-odd-bg-color": "#353535",
|
"tr-odd-bg-color": "#353535",
|
||||||
|
"content-bg": "#4e4e4e",
|
||||||
|
"content-fg": "#fff",
|
||||||
|
"content-hover-bg": "#222",
|
||||||
|
"content-hover-fg": "#fff"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -120,6 +124,10 @@ const colorPalettes = {
|
||||||
"border-color": "#888",
|
"border-color": "#888",
|
||||||
"tr-even-bg-color": "#f9f9f9",
|
"tr-even-bg-color": "#f9f9f9",
|
||||||
"tr-odd-bg-color": "#fff",
|
"tr-odd-bg-color": "#fff",
|
||||||
|
"content-bg": "#e0e0e0",
|
||||||
|
"content-fg": "#222",
|
||||||
|
"content-hover-bg": "#adadad",
|
||||||
|
"content-hover-fg": "#222"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -176,6 +184,10 @@ const colorPalettes = {
|
||||||
"border-color": "#657b83", // Base00
|
"border-color": "#657b83", // Base00
|
||||||
"tr-even-bg-color": "#002b36",
|
"tr-even-bg-color": "#002b36",
|
||||||
"tr-odd-bg-color": "#073642",
|
"tr-odd-bg-color": "#073642",
|
||||||
|
"content-bg": "#657b83",
|
||||||
|
"content-fg": "#fdf6e3",
|
||||||
|
"content-hover-bg": "#002b36",
|
||||||
|
"content-hover-fg": "#fdf6e3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -244,7 +256,11 @@ const colorPalettes = {
|
||||||
"error-text": "#ff4444",
|
"error-text": "#ff4444",
|
||||||
"border-color": "#6e7581",
|
"border-color": "#6e7581",
|
||||||
"tr-even-bg-color": "#2b2f38",
|
"tr-even-bg-color": "#2b2f38",
|
||||||
"tr-odd-bg-color": "#242730"
|
"tr-odd-bg-color": "#242730",
|
||||||
|
"content-bg": "#6e7581",
|
||||||
|
"content-fg": "#fff",
|
||||||
|
"content-hover-bg": "#2b2f38",
|
||||||
|
"content-hover-fg": "#fff"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -313,7 +329,11 @@ const colorPalettes = {
|
||||||
"error-text": "#ff4444",
|
"error-text": "#ff4444",
|
||||||
"border-color": "#545d70",
|
"border-color": "#545d70",
|
||||||
"tr-even-bg-color": "#2e3440",
|
"tr-even-bg-color": "#2e3440",
|
||||||
"tr-odd-bg-color": "#161b22"
|
"tr-odd-bg-color": "#161b22",
|
||||||
|
"content-bg": "#545d70",
|
||||||
|
"content-fg": "#e5eaf0",
|
||||||
|
"content-hover-bg": "#2e3440",
|
||||||
|
"content-hover-fg": "#e5eaf0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -382,7 +402,11 @@ const colorPalettes = {
|
||||||
"error-text": "#ff4444",
|
"error-text": "#ff4444",
|
||||||
"border-color": "#30363d",
|
"border-color": "#30363d",
|
||||||
"tr-even-bg-color": "#161b22",
|
"tr-even-bg-color": "#161b22",
|
||||||
"tr-odd-bg-color": "#13171d"
|
"tr-odd-bg-color": "#13171d",
|
||||||
|
"content-bg": "#30363d",
|
||||||
|
"content-fg": "#e5eaf0",
|
||||||
|
"content-hover-bg": "#161b22",
|
||||||
|
"content-hover-fg": "#e5eaf0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -1278,4 +1278,4 @@ const ext = {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
app.registerExtension(ext);
|
app.registerExtension(ext);
|
|
@ -1,177 +0,0 @@
|
||||||
import { app } from "../../scripts/app.js";
|
|
||||||
import { api } from "../../scripts/api.js"
|
|
||||||
|
|
||||||
const MAX_HISTORY = 50;
|
|
||||||
|
|
||||||
let undo = [];
|
|
||||||
let redo = [];
|
|
||||||
let activeState = null;
|
|
||||||
let isOurLoad = false;
|
|
||||||
function checkState() {
|
|
||||||
const currentState = app.graph.serialize();
|
|
||||||
if (!graphEqual(activeState, currentState)) {
|
|
||||||
undo.push(activeState);
|
|
||||||
if (undo.length > MAX_HISTORY) {
|
|
||||||
undo.shift();
|
|
||||||
}
|
|
||||||
activeState = clone(currentState);
|
|
||||||
redo.length = 0;
|
|
||||||
api.dispatchEvent(new CustomEvent("graphChanged", { detail: activeState }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadGraphData = app.loadGraphData;
|
|
||||||
app.loadGraphData = async function () {
|
|
||||||
const v = await loadGraphData.apply(this, arguments);
|
|
||||||
if (isOurLoad) {
|
|
||||||
isOurLoad = false;
|
|
||||||
} else {
|
|
||||||
checkState();
|
|
||||||
}
|
|
||||||
return v;
|
|
||||||
};
|
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
function graphEqual(a, b, root = true) {
|
|
||||||
if (a === b) return true;
|
|
||||||
|
|
||||||
if (typeof a == "object" && a && typeof b == "object" && b) {
|
|
||||||
const keys = Object.getOwnPropertyNames(a);
|
|
||||||
|
|
||||||
if (keys.length != Object.getOwnPropertyNames(b).length) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const key of keys) {
|
|
||||||
let av = a[key];
|
|
||||||
let bv = b[key];
|
|
||||||
if (root && key === "nodes") {
|
|
||||||
// Nodes need to be sorted as the order changes when selecting nodes
|
|
||||||
av = [...av].sort((a, b) => a.id - b.id);
|
|
||||||
bv = [...bv].sort((a, b) => a.id - b.id);
|
|
||||||
}
|
|
||||||
if (!graphEqual(av, bv, false)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const undoRedo = async (e) => {
|
|
||||||
const updateState = async (source, target) => {
|
|
||||||
const prevState = source.pop();
|
|
||||||
if (prevState) {
|
|
||||||
target.push(activeState);
|
|
||||||
isOurLoad = true;
|
|
||||||
await app.loadGraphData(prevState, false);
|
|
||||||
activeState = prevState;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (e.ctrlKey || e.metaKey) {
|
|
||||||
if (e.key === "y") {
|
|
||||||
updateState(redo, undo);
|
|
||||||
return true;
|
|
||||||
} else if (e.key === "z") {
|
|
||||||
updateState(undo, redo);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const bindInput = (activeEl) => {
|
|
||||||
if (activeEl && activeEl.tagName !== "CANVAS" && activeEl.tagName !== "BODY") {
|
|
||||||
for (const evt of ["change", "input", "blur"]) {
|
|
||||||
if (`on${evt}` in activeEl) {
|
|
||||||
const listener = () => {
|
|
||||||
checkState();
|
|
||||||
activeEl.removeEventListener(evt, listener);
|
|
||||||
};
|
|
||||||
activeEl.addEventListener(evt, listener);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let keyIgnored = false;
|
|
||||||
window.addEventListener(
|
|
||||||
"keydown",
|
|
||||||
(e) => {
|
|
||||||
requestAnimationFrame(async () => {
|
|
||||||
let activeEl;
|
|
||||||
// If we are auto queue in change mode then we do want to trigger on inputs
|
|
||||||
if (!app.ui.autoQueueEnabled || app.ui.autoQueueMode === "instant") {
|
|
||||||
activeEl = document.activeElement;
|
|
||||||
if (activeEl?.tagName === "INPUT" || activeEl?.type === "textarea") {
|
|
||||||
// Ignore events on inputs, they have their native history
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
keyIgnored = e.key === "Control" || e.key === "Shift" || e.key === "Alt" || e.key === "Meta";
|
|
||||||
if (keyIgnored) return;
|
|
||||||
|
|
||||||
// Check if this is a ctrl+z ctrl+y
|
|
||||||
if (await undoRedo(e)) return;
|
|
||||||
|
|
||||||
// If our active element is some type of input then handle changes after they're done
|
|
||||||
if (bindInput(activeEl)) return;
|
|
||||||
checkState();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
window.addEventListener("keyup", (e) => {
|
|
||||||
if (keyIgnored) {
|
|
||||||
keyIgnored = false;
|
|
||||||
checkState();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle clicking DOM elements (e.g. widgets)
|
|
||||||
window.addEventListener("mouseup", () => {
|
|
||||||
checkState();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle prompt queue event for dynamic widget changes
|
|
||||||
api.addEventListener("promptQueued", () => {
|
|
||||||
checkState();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle litegraph clicks
|
|
||||||
const processMouseUp = LGraphCanvas.prototype.processMouseUp;
|
|
||||||
LGraphCanvas.prototype.processMouseUp = function (e) {
|
|
||||||
const v = processMouseUp.apply(this, arguments);
|
|
||||||
checkState();
|
|
||||||
return v;
|
|
||||||
};
|
|
||||||
const processMouseDown = LGraphCanvas.prototype.processMouseDown;
|
|
||||||
LGraphCanvas.prototype.processMouseDown = function (e) {
|
|
||||||
const v = processMouseDown.apply(this, arguments);
|
|
||||||
checkState();
|
|
||||||
return v;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle litegraph context menu for COMBO widgets
|
|
||||||
const close = LiteGraph.ContextMenu.prototype.close;
|
|
||||||
LiteGraph.ContextMenu.prototype.close = function(e) {
|
|
||||||
const v = close.apply(this, arguments);
|
|
||||||
checkState();
|
|
||||||
return v;
|
|
||||||
}
|
|
Binary file not shown.
|
@ -5,6 +5,7 @@
|
||||||
<title>ComfyUI</title>
|
<title>ComfyUI</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||||
<link rel="stylesheet" type="text/css" href="./lib/litegraph.css" />
|
<link rel="stylesheet" type="text/css" href="./lib/litegraph.css" />
|
||||||
|
<link rel="stylesheet" type="text/css" href="./lib/materialdesignicons.min.css" />
|
||||||
<link rel="stylesheet" type="text/css" href="./style.css" />
|
<link rel="stylesheet" type="text/css" href="./style.css" />
|
||||||
<link rel="stylesheet" type="text/css" href="./user.css" />
|
<link rel="stylesheet" type="text/css" href="./user.css" />
|
||||||
<script type="text/javascript" src="./lib/litegraph.core.js"></script>
|
<script type="text/javascript" src="./lib/litegraph.core.js"></script>
|
||||||
|
|
|
@ -4,7 +4,9 @@
|
||||||
"paths": {
|
"paths": {
|
||||||
"/*": ["./*"]
|
"/*": ["./*"]
|
||||||
},
|
},
|
||||||
"lib": ["DOM", "ES2022"]
|
"lib": ["DOM", "ES2022", "DOM.Iterable"],
|
||||||
|
"target": "ES2015",
|
||||||
|
"module": "es2020"
|
||||||
},
|
},
|
||||||
"include": ["."]
|
"include": ["."]
|
||||||
}
|
}
|
||||||
|
|
File diff suppressed because one or more lines are too long
|
@ -327,7 +327,7 @@ class ComfyApi extends EventTarget {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets user configuration data and where data should be stored
|
* Gets user configuration data and where data should be stored
|
||||||
* @returns { Promise<{ storage: "server" | "browser", users?: Promise<string, unknown>, migrated?: boolean }> }
|
* @returns { Promise<{ storage: "server" | "browser", users?: Promise<string, unknown>, migrated?: boolean }> }
|
||||||
*/
|
*/
|
||||||
async getUserConfig() {
|
async getUserConfig() {
|
||||||
return (await this.fetchApi("/users")).json();
|
return (await this.fetchApi("/users")).json();
|
||||||
|
@ -335,7 +335,7 @@ class ComfyApi extends EventTarget {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new user
|
* Creates a new user
|
||||||
* @param { string } username
|
* @param { string } username
|
||||||
* @returns The fetch response
|
* @returns The fetch response
|
||||||
*/
|
*/
|
||||||
createUser(username) {
|
createUser(username) {
|
||||||
|
@ -394,7 +394,7 @@ class ComfyApi extends EventTarget {
|
||||||
* Gets a user data file for the current user
|
* Gets a user data file for the current user
|
||||||
* @param { string } file The name of the userdata file to load
|
* @param { string } file The name of the userdata file to load
|
||||||
* @param { RequestInit } [options]
|
* @param { RequestInit } [options]
|
||||||
* @returns { Promise<unknown> } The fetch response object
|
* @returns { Promise<Response> } The fetch response object
|
||||||
*/
|
*/
|
||||||
async getUserData(file, options) {
|
async getUserData(file, options) {
|
||||||
return this.fetchApi(`/userdata/${encodeURIComponent(file)}`, options);
|
return this.fetchApi(`/userdata/${encodeURIComponent(file)}`, options);
|
||||||
|
@ -404,18 +404,75 @@ class ComfyApi extends EventTarget {
|
||||||
* Stores a user data file for the current user
|
* Stores a user data file for the current user
|
||||||
* @param { string } file The name of the userdata file to save
|
* @param { string } file The name of the userdata file to save
|
||||||
* @param { unknown } data The data to save to the file
|
* @param { unknown } data The data to save to the file
|
||||||
* @param { RequestInit & { stringify?: boolean, throwOnError?: boolean } } [options]
|
* @param { RequestInit & { overwrite?: boolean, stringify?: boolean, throwOnError?: boolean } } [options]
|
||||||
* @returns { Promise<void> }
|
* @returns { Promise<Response> }
|
||||||
*/
|
*/
|
||||||
async storeUserData(file, data, options = { stringify: true, throwOnError: true }) {
|
async storeUserData(file, data, options = { overwrite: true, stringify: true, throwOnError: true }) {
|
||||||
const resp = await this.fetchApi(`/userdata/${encodeURIComponent(file)}`, {
|
const resp = await this.fetchApi(`/userdata/${encodeURIComponent(file)}?overwrite=${options?.overwrite}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: options?.stringify ? JSON.stringify(data) : data,
|
body: options?.stringify ? JSON.stringify(data) : data,
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
if (resp.status !== 200) {
|
if (resp.status !== 200 && options?.throwOnError !== false) {
|
||||||
throw new Error(`Error storing user data file '${file}': ${resp.status} ${(await resp).statusText}`);
|
throw new Error(`Error storing user data file '${file}': ${resp.status} ${(await resp).statusText}`);
|
||||||
}
|
}
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a user data file for the current user
|
||||||
|
* @param { string } file The name of the userdata file to delete
|
||||||
|
*/
|
||||||
|
async deleteUserData(file) {
|
||||||
|
const resp = await this.fetchApi(`/userdata/${encodeURIComponent(file)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
if (resp.status !== 204) {
|
||||||
|
throw new Error(`Error removing user data file '${file}': ${resp.status} ${(resp).statusText}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move a user data file for the current user
|
||||||
|
* @param { string } source The userdata file to move
|
||||||
|
* @param { string } dest The destination for the file
|
||||||
|
*/
|
||||||
|
async moveUserData(source, dest, options = { overwrite: false }) {
|
||||||
|
const resp = await this.fetchApi(`/userdata/${encodeURIComponent(source)}/move/${encodeURIComponent(dest)}?overwrite=${options?.overwrite}`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @overload
|
||||||
|
* Lists user data files for the current user
|
||||||
|
* @param { string } dir The directory in which to list files
|
||||||
|
* @param { boolean } [recurse] If the listing should be recursive
|
||||||
|
* @param { true } [split] If the paths should be split based on the os path separator
|
||||||
|
* @returns { Promise<string[][]>> } The list of split file paths in the format [fullPath, ...splitPath]
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* @overload
|
||||||
|
* Lists user data files for the current user
|
||||||
|
* @param { string } dir The directory in which to list files
|
||||||
|
* @param { boolean } [recurse] If the listing should be recursive
|
||||||
|
* @param { false | undefined } [split] If the paths should be split based on the os path separator
|
||||||
|
* @returns { Promise<string[]>> } The list of files
|
||||||
|
*/
|
||||||
|
async listUserData(dir, recurse, split) {
|
||||||
|
const resp = await this.fetchApi(
|
||||||
|
`/userdata?${new URLSearchParams({
|
||||||
|
recurse,
|
||||||
|
dir,
|
||||||
|
split,
|
||||||
|
})}`
|
||||||
|
);
|
||||||
|
if (resp.status === 404) return [];
|
||||||
|
if (resp.status !== 200) {
|
||||||
|
throw new Error(`Error getting user data list '${dir}': ${resp.status} ${resp.statusText}`);
|
||||||
|
}
|
||||||
|
return resp.json();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -5,9 +5,11 @@ import { api } from "./api.js";
|
||||||
import { defaultGraph } from "./defaultGraph.js";
|
import { defaultGraph } from "./defaultGraph.js";
|
||||||
import { getPngMetadata, getWebpMetadata, importA1111, getLatentMetadata } from "./pnginfo.js";
|
import { getPngMetadata, getWebpMetadata, importA1111, getLatentMetadata } from "./pnginfo.js";
|
||||||
import { addDomClippingSetting } from "./domWidget.js";
|
import { addDomClippingSetting } from "./domWidget.js";
|
||||||
import { createImageHost, calculateImageGrid } from "./ui/imagePreview.js"
|
import { createImageHost, calculateImageGrid } from "./ui/imagePreview.js";
|
||||||
|
import { ComfyAppMenu } from "./ui/menu/index.js";
|
||||||
export const ANIM_PREVIEW_WIDGET = "$$comfy_animation_preview"
|
import { getStorageValue, setStorageValue } from "./utils.js";
|
||||||
|
import { ComfyWorkflowManager } from "./workflows.js";
|
||||||
|
export const ANIM_PREVIEW_WIDGET = "$$comfy_animation_preview";
|
||||||
|
|
||||||
function sanitizeNodeName(string) {
|
function sanitizeNodeName(string) {
|
||||||
let entityMap = {
|
let entityMap = {
|
||||||
|
@ -52,6 +54,12 @@ export class ComfyApp {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.ui = new ComfyUI(this);
|
this.ui = new ComfyUI(this);
|
||||||
this.logging = new ComfyLogging(this);
|
this.logging = new ComfyLogging(this);
|
||||||
|
this.workflowManager = new ComfyWorkflowManager(this);
|
||||||
|
this.bodyTop = $el("div.comfyui-body-top", { parent: document.body });
|
||||||
|
this.bodyLeft = $el("div.comfyui-body-left", { parent: document.body });
|
||||||
|
this.bodyRight = $el("div.comfyui-body-right", { parent: document.body });
|
||||||
|
this.bodyBottom = $el("div.comfyui-body-bottom", { parent: document.body });
|
||||||
|
this.menu = new ComfyAppMenu(this);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List of extensions that are registered with the app
|
* List of extensions that are registered with the app
|
||||||
|
@ -1313,11 +1321,15 @@ export class ComfyApp {
|
||||||
});
|
});
|
||||||
|
|
||||||
api.addEventListener("progress", ({ detail }) => {
|
api.addEventListener("progress", ({ detail }) => {
|
||||||
|
if (this.workflowManager.activePrompt?.workflow
|
||||||
|
&& this.workflowManager.activePrompt.workflow !== this.workflowManager.activeWorkflow) return;
|
||||||
this.progress = detail;
|
this.progress = detail;
|
||||||
this.graph.setDirtyCanvas(true, false);
|
this.graph.setDirtyCanvas(true, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
api.addEventListener("executing", ({ detail }) => {
|
api.addEventListener("executing", ({ detail }) => {
|
||||||
|
if (this.workflowManager.activePrompt ?.workflow
|
||||||
|
&& this.workflowManager.activePrompt.workflow !== this.workflowManager.activeWorkflow) return;
|
||||||
this.progress = null;
|
this.progress = null;
|
||||||
this.runningNodeId = detail;
|
this.runningNodeId = detail;
|
||||||
this.graph.setDirtyCanvas(true, false);
|
this.graph.setDirtyCanvas(true, false);
|
||||||
|
@ -1325,6 +1337,8 @@ export class ComfyApp {
|
||||||
});
|
});
|
||||||
|
|
||||||
api.addEventListener("executed", ({ detail }) => {
|
api.addEventListener("executed", ({ detail }) => {
|
||||||
|
if (this.workflowManager.activePrompt ?.workflow
|
||||||
|
&& this.workflowManager.activePrompt.workflow !== this.workflowManager.activeWorkflow) return;
|
||||||
const output = this.nodeOutputs[detail.node];
|
const output = this.nodeOutputs[detail.node];
|
||||||
if (detail.merge && output) {
|
if (detail.merge && output) {
|
||||||
for (const k in detail.output ?? {}) {
|
for (const k in detail.output ?? {}) {
|
||||||
|
@ -1433,6 +1447,11 @@ export class ComfyApp {
|
||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all(extensionPromises);
|
await Promise.all(extensionPromises);
|
||||||
|
try {
|
||||||
|
this.menu.workflows.registerExtension(this);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async #migrateSettings() {
|
async #migrateSettings() {
|
||||||
|
@ -1520,15 +1539,17 @@ export class ComfyApp {
|
||||||
*/
|
*/
|
||||||
async setup() {
|
async setup() {
|
||||||
await this.#setUser();
|
await this.#setUser();
|
||||||
await this.ui.settings.load();
|
|
||||||
await this.#loadExtensions();
|
|
||||||
|
|
||||||
// Create and mount the LiteGraph in the DOM
|
// Create and mount the LiteGraph in the DOM
|
||||||
const mainCanvas = document.createElement("canvas")
|
const mainCanvas = document.createElement("canvas")
|
||||||
mainCanvas.style.touchAction = "none"
|
mainCanvas.style.touchAction = "none"
|
||||||
const canvasEl = (this.canvasEl = Object.assign(mainCanvas, { id: "graph-canvas" }));
|
const canvasEl = (this.canvasEl = Object.assign(mainCanvas, { id: "graph-canvas" }));
|
||||||
canvasEl.tabIndex = "1";
|
canvasEl.tabIndex = "1";
|
||||||
document.body.prepend(canvasEl);
|
document.body.append(canvasEl);
|
||||||
|
this.resizeCanvas();
|
||||||
|
|
||||||
|
await Promise.all([this.workflowManager.loadWorkflows(), this.ui.settings.load()]);
|
||||||
|
await this.#loadExtensions();
|
||||||
|
|
||||||
addDomClippingSetting();
|
addDomClippingSetting();
|
||||||
this.#addProcessMouseHandler();
|
this.#addProcessMouseHandler();
|
||||||
|
@ -1541,7 +1562,7 @@ export class ComfyApp {
|
||||||
|
|
||||||
this.#addAfterConfigureHandler();
|
this.#addAfterConfigureHandler();
|
||||||
|
|
||||||
const canvas = (this.canvas = new LGraphCanvas(canvasEl, this.graph));
|
this.canvas = new LGraphCanvas(canvasEl, this.graph);
|
||||||
this.ctx = canvasEl.getContext("2d");
|
this.ctx = canvasEl.getContext("2d");
|
||||||
|
|
||||||
LiteGraph.release_link_on_empty_shows_menu = true;
|
LiteGraph.release_link_on_empty_shows_menu = true;
|
||||||
|
@ -1549,19 +1570,14 @@ export class ComfyApp {
|
||||||
|
|
||||||
this.graph.start();
|
this.graph.start();
|
||||||
|
|
||||||
function resizeCanvas() {
|
|
||||||
// Limit minimal scale to 1, see https://github.com/comfyanonymous/ComfyUI/pull/845
|
|
||||||
const scale = Math.max(window.devicePixelRatio, 1);
|
|
||||||
const { width, height } = canvasEl.getBoundingClientRect();
|
|
||||||
canvasEl.width = Math.round(width * scale);
|
|
||||||
canvasEl.height = Math.round(height * scale);
|
|
||||||
canvasEl.getContext("2d").scale(scale, scale);
|
|
||||||
canvas.draw(true, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure the canvas fills the window
|
// Ensure the canvas fills the window
|
||||||
resizeCanvas();
|
this.resizeCanvas();
|
||||||
window.addEventListener("resize", resizeCanvas);
|
window.addEventListener("resize", () => this.resizeCanvas());
|
||||||
|
const ro = new ResizeObserver(() => this.resizeCanvas());
|
||||||
|
ro.observe(this.bodyTop);
|
||||||
|
ro.observe(this.bodyLeft);
|
||||||
|
ro.observe(this.bodyRight);
|
||||||
|
ro.observe(this.bodyBottom);
|
||||||
|
|
||||||
await this.#invokeExtensionsAsync("init");
|
await this.#invokeExtensionsAsync("init");
|
||||||
await this.registerNodes();
|
await this.registerNodes();
|
||||||
|
@ -1573,7 +1589,8 @@ export class ComfyApp {
|
||||||
const loadWorkflow = async (json) => {
|
const loadWorkflow = async (json) => {
|
||||||
if (json) {
|
if (json) {
|
||||||
const workflow = JSON.parse(json);
|
const workflow = JSON.parse(json);
|
||||||
await this.loadGraphData(workflow);
|
const workflowName = getStorageValue("Comfy.PreviousWorkflow");
|
||||||
|
await this.loadGraphData(workflow, true, workflowName);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -1609,6 +1626,19 @@ export class ComfyApp {
|
||||||
await this.#invokeExtensionsAsync("setup");
|
await this.#invokeExtensionsAsync("setup");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resizeCanvas() {
|
||||||
|
// Limit minimal scale to 1, see https://github.com/comfyanonymous/ComfyUI/pull/845
|
||||||
|
const scale = Math.max(window.devicePixelRatio, 1);
|
||||||
|
|
||||||
|
// Clear fixed width and height while calculating rect so it uses 100% instead
|
||||||
|
this.canvasEl.height = this.canvasEl.width = "";
|
||||||
|
const { width, height } = this.canvasEl.getBoundingClientRect();
|
||||||
|
this.canvasEl.width = Math.round(width * scale);
|
||||||
|
this.canvasEl.height = Math.round(height * scale);
|
||||||
|
this.canvasEl.getContext("2d").scale(scale, scale);
|
||||||
|
this.canvas?.draw(true, true);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers nodes with the graph
|
* Registers nodes with the graph
|
||||||
*/
|
*/
|
||||||
|
@ -1795,12 +1825,29 @@ export class ComfyApp {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async changeWorkflow(callback, workflow = null) {
|
||||||
|
try {
|
||||||
|
this.workflowManager.activeWorkflow?.changeTracker?.store()
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
await callback();
|
||||||
|
try {
|
||||||
|
this.workflowManager.setWorkflow(workflow);
|
||||||
|
this.workflowManager.activeWorkflow?.track()
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Populates the graph with the specified workflow data
|
* Populates the graph with the specified workflow data
|
||||||
* @param {*} graphData A serialized graph object
|
* @param {*} graphData A serialized graph object
|
||||||
* @param { boolean } clean If the graph state, e.g. images, should be cleared
|
* @param { boolean } clean If the graph state, e.g. images, should be cleared
|
||||||
|
* @param { boolean } restore_view If the graph position should be restored
|
||||||
|
* @param { import("./workflows.js").ComfyWorkflowInstance | null } workflow The workflow
|
||||||
*/
|
*/
|
||||||
async loadGraphData(graphData, clean = true, restore_view = true) {
|
async loadGraphData(graphData, clean = true, restore_view = true, workflow = null) {
|
||||||
if (clean !== false) {
|
if (clean !== false) {
|
||||||
this.clean();
|
this.clean();
|
||||||
}
|
}
|
||||||
|
@ -1818,6 +1865,12 @@ export class ComfyApp {
|
||||||
{
|
{
|
||||||
graphData = structuredClone(graphData);
|
graphData = structuredClone(graphData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.workflowManager.setWorkflow(workflow);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
const missingNodeTypes = [];
|
const missingNodeTypes = [];
|
||||||
await this.#invokeExtensionsAsync("beforeConfigureGraph", graphData, missingNodeTypes);
|
await this.#invokeExtensionsAsync("beforeConfigureGraph", graphData, missingNodeTypes);
|
||||||
|
@ -1840,6 +1893,11 @@ export class ComfyApp {
|
||||||
this.canvas.ds.offset = graphData.extra.ds.offset;
|
this.canvas.ds.offset = graphData.extra.ds.offset;
|
||||||
this.canvas.ds.scale = graphData.extra.ds.scale;
|
this.canvas.ds.scale = graphData.extra.ds.scale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.workflowManager.activeWorkflow?.track()
|
||||||
|
} catch (error) {
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
let errorHint = [];
|
let errorHint = [];
|
||||||
// Try extracting filename to see if it was caused by an extension script
|
// Try extracting filename to see if it was caused by an extension script
|
||||||
|
@ -1927,14 +1985,17 @@ export class ComfyApp {
|
||||||
this.showMissingNodesError(missingNodeTypes);
|
this.showMissingNodesError(missingNodeTypes);
|
||||||
}
|
}
|
||||||
await this.#invokeExtensionsAsync("afterConfigureGraph", missingNodeTypes);
|
await this.#invokeExtensionsAsync("afterConfigureGraph", missingNodeTypes);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.graph.setDirtyCanvas(true, true);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts the current graph workflow for sending to the API
|
* Converts the current graph workflow for sending to the API
|
||||||
* @returns The workflow and node links
|
* @returns The workflow and node links
|
||||||
*/
|
*/
|
||||||
async graphToPrompt() {
|
async graphToPrompt(graph = this.graph, clean = true) {
|
||||||
for (const outerNode of this.graph.computeExecutionOrder(false)) {
|
for (const outerNode of graph.computeExecutionOrder(false)) {
|
||||||
if (outerNode.widgets) {
|
if (outerNode.widgets) {
|
||||||
for (const widget of outerNode.widgets) {
|
for (const widget of outerNode.widgets) {
|
||||||
// Allow widgets to run callbacks before a prompt has been queued
|
// Allow widgets to run callbacks before a prompt has been queued
|
||||||
|
@ -1954,10 +2015,10 @@ export class ComfyApp {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const workflow = this.graph.serialize();
|
const workflow = graph.serialize();
|
||||||
const output = {};
|
const output = {};
|
||||||
// Process nodes in order of execution
|
// Process nodes in order of execution
|
||||||
for (const outerNode of this.graph.computeExecutionOrder(false)) {
|
for (const outerNode of graph.computeExecutionOrder(false)) {
|
||||||
const skipNode = outerNode.mode === 2 || outerNode.mode === 4;
|
const skipNode = outerNode.mode === 2 || outerNode.mode === 4;
|
||||||
const innerNodes = (!skipNode && outerNode.getInnerNodes) ? outerNode.getInnerNodes() : [outerNode];
|
const innerNodes = (!skipNode && outerNode.getInnerNodes) ? outerNode.getInnerNodes() : [outerNode];
|
||||||
for (const node of innerNodes) {
|
for (const node of innerNodes) {
|
||||||
|
@ -2049,13 +2110,14 @@ export class ComfyApp {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove inputs connected to removed nodes
|
// Remove inputs connected to removed nodes
|
||||||
|
if(clean) {
|
||||||
for (const o in output) {
|
for (const o in output) {
|
||||||
for (const i in output[o].inputs) {
|
for (const i in output[o].inputs) {
|
||||||
if (Array.isArray(output[o].inputs[i])
|
if (Array.isArray(output[o].inputs[i])
|
||||||
&& output[o].inputs[i].length === 2
|
&& output[o].inputs[i].length === 2
|
||||||
&& !output[output[o].inputs[i][0]]) {
|
&& !output[output[o].inputs[i][0]]) {
|
||||||
delete output[o].inputs[i];
|
delete output[o].inputs[i];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2123,6 +2185,14 @@ export class ComfyApp {
|
||||||
this.lastNodeErrors = res.node_errors;
|
this.lastNodeErrors = res.node_errors;
|
||||||
if (this.lastNodeErrors.length > 0) {
|
if (this.lastNodeErrors.length > 0) {
|
||||||
this.canvas.draw(true, true);
|
this.canvas.draw(true, true);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
this.workflowManager.storePrompt({
|
||||||
|
id: res.prompt_id,
|
||||||
|
nodes: Object.keys(p.output)
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const formattedError = this.#formatPromptError(error)
|
const formattedError = this.#formatPromptError(error)
|
||||||
|
@ -2155,6 +2225,7 @@ export class ComfyApp {
|
||||||
this.#processingQueue = false;
|
this.#processingQueue = false;
|
||||||
}
|
}
|
||||||
api.dispatchEvent(new CustomEvent("promptQueued", { detail: { number, batchCount } }));
|
api.dispatchEvent(new CustomEvent("promptQueued", { detail: { number, batchCount } }));
|
||||||
|
return !this.lastNodeErrors;
|
||||||
}
|
}
|
||||||
|
|
||||||
showErrorOnFileLoad(file) {
|
showErrorOnFileLoad(file) {
|
||||||
|
@ -2170,14 +2241,24 @@ export class ComfyApp {
|
||||||
* @param {File} file
|
* @param {File} file
|
||||||
*/
|
*/
|
||||||
async handleFile(file) {
|
async handleFile(file) {
|
||||||
|
const removeExt = f => {
|
||||||
|
if(!f) return f;
|
||||||
|
const p = f.lastIndexOf(".");
|
||||||
|
if(p === -1) return f;
|
||||||
|
return f.substring(0, p);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fileName = removeExt(file.name);
|
||||||
if (file.type === "image/png") {
|
if (file.type === "image/png") {
|
||||||
const pngInfo = await getPngMetadata(file);
|
const pngInfo = await getPngMetadata(file);
|
||||||
if (pngInfo?.workflow) {
|
if (pngInfo?.workflow) {
|
||||||
await this.loadGraphData(JSON.parse(pngInfo.workflow));
|
await this.loadGraphData(JSON.parse(pngInfo.workflow), true, true, fileName);
|
||||||
} else if (pngInfo?.prompt) {
|
} else if (pngInfo?.prompt) {
|
||||||
this.loadApiJson(JSON.parse(pngInfo.prompt));
|
this.loadApiJson(JSON.parse(pngInfo.prompt), fileName);
|
||||||
} else if (pngInfo?.parameters) {
|
} else if (pngInfo?.parameters) {
|
||||||
importA1111(this.graph, pngInfo.parameters);
|
this.changeWorkflow(() => {
|
||||||
|
importA1111(this.graph, pngInfo.parameters);
|
||||||
|
}, fileName)
|
||||||
} else {
|
} else {
|
||||||
this.showErrorOnFileLoad(file);
|
this.showErrorOnFileLoad(file);
|
||||||
}
|
}
|
||||||
|
@ -2188,9 +2269,9 @@ export class ComfyApp {
|
||||||
const prompt = pngInfo?.prompt || pngInfo?.Prompt;
|
const prompt = pngInfo?.prompt || pngInfo?.Prompt;
|
||||||
|
|
||||||
if (workflow) {
|
if (workflow) {
|
||||||
this.loadGraphData(JSON.parse(workflow));
|
this.loadGraphData(JSON.parse(workflow), true, true, fileName);
|
||||||
} else if (prompt) {
|
} else if (prompt) {
|
||||||
this.loadApiJson(JSON.parse(prompt));
|
this.loadApiJson(JSON.parse(prompt), fileName);
|
||||||
} else {
|
} else {
|
||||||
this.showErrorOnFileLoad(file);
|
this.showErrorOnFileLoad(file);
|
||||||
}
|
}
|
||||||
|
@ -2201,16 +2282,16 @@ export class ComfyApp {
|
||||||
if (jsonContent?.templates) {
|
if (jsonContent?.templates) {
|
||||||
this.loadTemplateData(jsonContent);
|
this.loadTemplateData(jsonContent);
|
||||||
} else if(this.isApiJson(jsonContent)) {
|
} else if(this.isApiJson(jsonContent)) {
|
||||||
this.loadApiJson(jsonContent);
|
this.loadApiJson(jsonContent, fileName);
|
||||||
} else {
|
} else {
|
||||||
await this.loadGraphData(jsonContent);
|
await this.loadGraphData(jsonContent, true, fileName);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
reader.readAsText(file);
|
reader.readAsText(file);
|
||||||
} else if (file.name?.endsWith(".latent") || file.name?.endsWith(".safetensors")) {
|
} else if (file.name?.endsWith(".latent") || file.name?.endsWith(".safetensors")) {
|
||||||
const info = await getLatentMetadata(file);
|
const info = await getLatentMetadata(file);
|
||||||
if (info.workflow) {
|
if (info.workflow) {
|
||||||
await this.loadGraphData(JSON.parse(info.workflow));
|
await this.loadGraphData(JSON.parse(info.workflow), true, fileName);
|
||||||
} else if (info.prompt) {
|
} else if (info.prompt) {
|
||||||
this.loadApiJson(JSON.parse(info.prompt));
|
this.loadApiJson(JSON.parse(info.prompt));
|
||||||
} else {
|
} else {
|
||||||
|
@ -2225,7 +2306,7 @@ export class ComfyApp {
|
||||||
return Object.values(data).every((v) => v.class_type);
|
return Object.values(data).every((v) => v.class_type);
|
||||||
}
|
}
|
||||||
|
|
||||||
loadApiJson(apiData) {
|
loadApiJson(apiData, fileName) {
|
||||||
const missingNodeTypes = Object.values(apiData).filter((n) => !LiteGraph.registered_node_types[n.class_type]);
|
const missingNodeTypes = Object.values(apiData).filter((n) => !LiteGraph.registered_node_types[n.class_type]);
|
||||||
if (missingNodeTypes.length) {
|
if (missingNodeTypes.length) {
|
||||||
this.showMissingNodesError(missingNodeTypes.map(t => t.class_type), false);
|
this.showMissingNodesError(missingNodeTypes.map(t => t.class_type), false);
|
||||||
|
@ -2240,40 +2321,42 @@ export class ComfyApp {
|
||||||
node.id = isNaN(+id) ? id : +id;
|
node.id = isNaN(+id) ? id : +id;
|
||||||
node.title = data._meta?.title ?? node.title
|
node.title = data._meta?.title ?? node.title
|
||||||
app.graph.add(node);
|
app.graph.add(node);
|
||||||
|
graph.add(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const id of ids) {
|
this.changeWorkflow(() => {
|
||||||
const data = apiData[id];
|
for (const id of ids) {
|
||||||
const node = app.graph.getNodeById(id);
|
const data = apiData[id];
|
||||||
for (const input in data.inputs ?? {}) {
|
const node = app.graph.getNodeById(id);
|
||||||
const value = data.inputs[input];
|
for (const input in data.inputs ?? {}) {
|
||||||
if (value instanceof Array) {
|
const value = data.inputs[input];
|
||||||
const [fromId, fromSlot] = value;
|
if (value instanceof Array) {
|
||||||
const fromNode = app.graph.getNodeById(fromId);
|
const [fromId, fromSlot] = value;
|
||||||
let toSlot = node.inputs?.findIndex((inp) => inp.name === input);
|
const fromNode = app.graph.getNodeById(fromId);
|
||||||
if (toSlot == null || toSlot === -1) {
|
let toSlot = node.inputs?.findIndex((inp) => inp.name === input);
|
||||||
try {
|
if (toSlot == null || toSlot === -1) {
|
||||||
// Target has no matching input, most likely a converted widget
|
try {
|
||||||
const widget = node.widgets?.find((w) => w.name === input);
|
// Target has no matching input, most likely a converted widget
|
||||||
if (widget && node.convertWidgetToInput?.(widget)) {
|
const widget = node.widgets?.find((w) => w.name === input);
|
||||||
toSlot = node.inputs?.length - 1;
|
if (widget && node.convertWidgetToInput?.(widget)) {
|
||||||
}
|
toSlot = node.inputs?.length - 1;
|
||||||
} catch (error) {}
|
}
|
||||||
}
|
} catch (error) {}
|
||||||
if (toSlot != null || toSlot !== -1) {
|
}
|
||||||
fromNode.connect(fromSlot, node, toSlot);
|
if (toSlot != null || toSlot !== -1) {
|
||||||
}
|
fromNode.connect(fromSlot, node, toSlot);
|
||||||
} else {
|
}
|
||||||
const widget = node.widgets?.find((w) => w.name === input);
|
} else {
|
||||||
if (widget) {
|
const widget = node.widgets?.find((w) => w.name === input);
|
||||||
widget.value = value;
|
if (widget) {
|
||||||
widget.callback?.(value);
|
widget.value = value;
|
||||||
|
widget.callback?.(value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
app.graph.arrange();
|
||||||
|
}, fileName);
|
||||||
app.graph.arrange();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -0,0 +1,242 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
import { api } from "./api.js";
|
||||||
|
import { clone } from "./utils.js";
|
||||||
|
|
||||||
|
|
||||||
|
export class ChangeTracker {
|
||||||
|
static MAX_HISTORY = 50;
|
||||||
|
#app;
|
||||||
|
undo = [];
|
||||||
|
redo = [];
|
||||||
|
activeState = null;
|
||||||
|
isOurLoad = false;
|
||||||
|
/** @type { import("./workflows").ComfyWorkflow | null } */
|
||||||
|
workflow;
|
||||||
|
|
||||||
|
ds;
|
||||||
|
nodeOutputs;
|
||||||
|
|
||||||
|
get app() {
|
||||||
|
return this.#app ?? this.workflow.manager.app;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(workflow) {
|
||||||
|
this.workflow = workflow;
|
||||||
|
}
|
||||||
|
|
||||||
|
#setApp(app) {
|
||||||
|
this.#app = app;
|
||||||
|
}
|
||||||
|
|
||||||
|
store() {
|
||||||
|
this.ds = { scale: this.app.canvas.ds.scale, offset: [...this.app.canvas.ds.offset] };
|
||||||
|
}
|
||||||
|
|
||||||
|
restore() {
|
||||||
|
if (this.ds) {
|
||||||
|
this.app.canvas.ds.scale = this.ds.scale;
|
||||||
|
this.app.canvas.ds.offset = this.ds.offset;
|
||||||
|
}
|
||||||
|
if (this.nodeOutputs) {
|
||||||
|
this.app.nodeOutputs = this.nodeOutputs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkState() {
|
||||||
|
if (!this.app.graph) return;
|
||||||
|
|
||||||
|
const currentState = this.app.graph.serialize();
|
||||||
|
if (!this.activeState) {
|
||||||
|
this.activeState = clone(currentState);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!ChangeTracker.graphEqual(this.activeState, currentState)) {
|
||||||
|
this.undo.push(this.activeState);
|
||||||
|
if (this.undo.length > ChangeTracker.MAX_HISTORY) {
|
||||||
|
this.undo.shift();
|
||||||
|
}
|
||||||
|
this.activeState = clone(currentState);
|
||||||
|
this.redo.length = 0;
|
||||||
|
this.workflow.unsaved = true;
|
||||||
|
api.dispatchEvent(new CustomEvent("graphChanged", { detail: this.activeState }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateState(source, target) {
|
||||||
|
const prevState = source.pop();
|
||||||
|
if (prevState) {
|
||||||
|
target.push(this.activeState);
|
||||||
|
this.isOurLoad = true;
|
||||||
|
await this.app.loadGraphData(prevState, false, false, this.workflow);
|
||||||
|
this.activeState = prevState;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async undoRedo(e) {
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
if (e.key === "y") {
|
||||||
|
this.updateState(this.redo, this.undo);
|
||||||
|
return true;
|
||||||
|
} else if (e.key === "z") {
|
||||||
|
this.updateState(this.undo, this.redo);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param { import("./app.js").ComfyApp } app */
|
||||||
|
static init(app) {
|
||||||
|
const changeTracker = () => app.workflowManager.activeWorkflow?.changeTracker ?? globalTracker;
|
||||||
|
globalTracker.#setApp(app);
|
||||||
|
|
||||||
|
const loadGraphData = app.loadGraphData;
|
||||||
|
app.loadGraphData = async function () {
|
||||||
|
const v = await loadGraphData.apply(this, arguments);
|
||||||
|
const ct = changeTracker();
|
||||||
|
if (ct.isOurLoad) {
|
||||||
|
ct.isOurLoad = false;
|
||||||
|
} else {
|
||||||
|
ct.checkState();
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
};
|
||||||
|
|
||||||
|
let keyIgnored = false;
|
||||||
|
window.addEventListener(
|
||||||
|
"keydown",
|
||||||
|
(e) => {
|
||||||
|
requestAnimationFrame(async () => {
|
||||||
|
let activeEl;
|
||||||
|
// If we are auto queue in change mode then we do want to trigger on inputs
|
||||||
|
if (!app.ui.autoQueueEnabled || app.ui.autoQueueMode === "instant") {
|
||||||
|
activeEl = document.activeElement;
|
||||||
|
if (activeEl?.tagName === "INPUT" || activeEl?.["type"] === "textarea") {
|
||||||
|
// Ignore events on inputs, they have their native history
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keyIgnored = e.key === "Control" || e.key === "Shift" || e.key === "Alt" || e.key === "Meta";
|
||||||
|
if (keyIgnored) return;
|
||||||
|
|
||||||
|
// Check if this is a ctrl+z ctrl+y
|
||||||
|
if (await changeTracker().undoRedo(e)) return;
|
||||||
|
|
||||||
|
// If our active element is some type of input then handle changes after they're done
|
||||||
|
if (ChangeTracker.bindInput(activeEl)) return;
|
||||||
|
changeTracker().checkState();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
window.addEventListener("keyup", (e) => {
|
||||||
|
if (keyIgnored) {
|
||||||
|
keyIgnored = false;
|
||||||
|
changeTracker().checkState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle clicking DOM elements (e.g. widgets)
|
||||||
|
window.addEventListener("mouseup", () => {
|
||||||
|
changeTracker().checkState();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle prompt queue event for dynamic widget changes
|
||||||
|
api.addEventListener("promptQueued", () => {
|
||||||
|
changeTracker().checkState();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle litegraph clicks
|
||||||
|
const processMouseUp = LGraphCanvas.prototype.processMouseUp;
|
||||||
|
LGraphCanvas.prototype.processMouseUp = function (e) {
|
||||||
|
const v = processMouseUp.apply(this, arguments);
|
||||||
|
changeTracker().checkState();
|
||||||
|
return v;
|
||||||
|
};
|
||||||
|
const processMouseDown = LGraphCanvas.prototype.processMouseDown;
|
||||||
|
LGraphCanvas.prototype.processMouseDown = function (e) {
|
||||||
|
const v = processMouseDown.apply(this, arguments);
|
||||||
|
changeTracker().checkState();
|
||||||
|
return v;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle litegraph context menu for COMBO widgets
|
||||||
|
const close = LiteGraph.ContextMenu.prototype.close;
|
||||||
|
LiteGraph.ContextMenu.prototype.close = function (e) {
|
||||||
|
const v = close.apply(this, arguments);
|
||||||
|
changeTracker().checkState();
|
||||||
|
return v;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store node outputs
|
||||||
|
api.addEventListener("executed", ({ detail }) => {
|
||||||
|
const prompt = app.workflowManager.queuedPrompts[detail.prompt_id];
|
||||||
|
if (!prompt?.workflow) return;
|
||||||
|
const nodeOutputs = (prompt.workflow.changeTracker.nodeOutputs ??= {});
|
||||||
|
const output = nodeOutputs[detail.node];
|
||||||
|
if (detail.merge && output) {
|
||||||
|
for (const k in detail.output ?? {}) {
|
||||||
|
const v = output[k];
|
||||||
|
if (v instanceof Array) {
|
||||||
|
output[k] = v.concat(detail.output[k]);
|
||||||
|
} else {
|
||||||
|
output[k] = detail.output[k];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nodeOutputs[detail.node] = detail.output;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static bindInput(app, activeEl) {
|
||||||
|
if (activeEl && activeEl.tagName !== "CANVAS" && activeEl.tagName !== "BODY") {
|
||||||
|
for (const evt of ["change", "input", "blur"]) {
|
||||||
|
if (`on${evt}` in activeEl) {
|
||||||
|
const listener = () => {
|
||||||
|
app.workflowManager.activeWorkflow.changeTracker.checkState();
|
||||||
|
activeEl.removeEventListener(evt, listener);
|
||||||
|
};
|
||||||
|
activeEl.addEventListener(evt, listener);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static graphEqual(a, b, path = "") {
|
||||||
|
if (a === b) return true;
|
||||||
|
|
||||||
|
if (typeof a == "object" && a && typeof b == "object" && b) {
|
||||||
|
const keys = Object.getOwnPropertyNames(a);
|
||||||
|
|
||||||
|
if (keys.length != Object.getOwnPropertyNames(b).length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
let av = a[key];
|
||||||
|
let bv = b[key];
|
||||||
|
if (!path && key === "nodes") {
|
||||||
|
// Nodes need to be sorted as the order changes when selecting nodes
|
||||||
|
av = [...av].sort((a, b) => a.id - b.id);
|
||||||
|
bv = [...bv].sort((a, b) => a.id - b.id);
|
||||||
|
} else if (path === "extra.ds") {
|
||||||
|
// Ignore view changes
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!ChangeTracker.graphEqual(av, bv, path + (path ? "." : "") + key)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalTracker = new ChangeTracker({});
|
|
@ -34,8 +34,8 @@ function getClipPath(node, element) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const widgetRect = element.getBoundingClientRect();
|
const widgetRect = element.getBoundingClientRect();
|
||||||
const clipX = intersection[0] - widgetRect.x / scale + "px";
|
const clipX = elRect.left + intersection[0] - widgetRect.x / scale + "px";
|
||||||
const clipY = intersection[1] - widgetRect.y / scale + "px";
|
const clipY = elRect.top + intersection[1] - widgetRect.y / scale + "px";
|
||||||
const clipWidth = intersection[2] + "px";
|
const clipWidth = intersection[2] + "px";
|
||||||
const clipHeight = intersection[3] + "px";
|
const clipHeight = intersection[3] + "px";
|
||||||
const path = `polygon(0% 0%, 0% 100%, ${clipX} 100%, ${clipX} ${clipY}, calc(${clipX} + ${clipWidth}) ${clipY}, calc(${clipX} + ${clipWidth}) calc(${clipY} + ${clipHeight}), ${clipX} calc(${clipY} + ${clipHeight}), ${clipX} 100%, 100% 100%, 100% 0%)`;
|
const path = `polygon(0% 0%, 0% 100%, ${clipX} 100%, ${clipX} ${clipY}, calc(${clipX} + ${clipWidth}) ${clipY}, calc(${clipX} + ${clipWidth}) calc(${clipY} + ${clipHeight}), ${clipX} calc(${clipY} + ${clipHeight}), ${clipX} 100%, 100% 100%, 100% 0%)`;
|
||||||
|
@ -210,7 +210,9 @@ LGraphNode.prototype.addDOMWidget = function (name, type, element, options) {
|
||||||
if (!element.parentElement) {
|
if (!element.parentElement) {
|
||||||
document.body.append(element);
|
document.body.append(element);
|
||||||
}
|
}
|
||||||
|
element.hidden = true;
|
||||||
|
element.style.display = "none";
|
||||||
|
|
||||||
let mouseDownHandler;
|
let mouseDownHandler;
|
||||||
if (element.blur) {
|
if (element.blur) {
|
||||||
mouseDownHandler = (event) => {
|
mouseDownHandler = (event) => {
|
||||||
|
@ -254,15 +256,15 @@ LGraphNode.prototype.addDOMWidget = function (name, type, element, options) {
|
||||||
const transform = new DOMMatrix()
|
const transform = new DOMMatrix()
|
||||||
.scaleSelf(elRect.width / ctx.canvas.width, elRect.height / ctx.canvas.height)
|
.scaleSelf(elRect.width / ctx.canvas.width, elRect.height / ctx.canvas.height)
|
||||||
.multiplySelf(ctx.getTransform())
|
.multiplySelf(ctx.getTransform())
|
||||||
.translateSelf(margin, margin + y);
|
.translateSelf(margin, margin + y );
|
||||||
|
|
||||||
const scale = new DOMMatrix().scaleSelf(transform.a, transform.d);
|
const scale = new DOMMatrix().scaleSelf(transform.a, transform.d);
|
||||||
|
|
||||||
Object.assign(element.style, {
|
Object.assign(element.style, {
|
||||||
transformOrigin: "0 0",
|
transformOrigin: "0 0",
|
||||||
transform: scale,
|
transform: scale,
|
||||||
left: `${transform.a + transform.e}px`,
|
left: `${transform.a + transform.e + elRect.left}px`,
|
||||||
top: `${transform.d + transform.f}px`,
|
top: `${transform.d + transform.f + elRect.top}px`,
|
||||||
width: `${widgetWidth - margin * 2}px`,
|
width: `${widgetWidth - margin * 2}px`,
|
||||||
height: `${(widget.computedHeight ?? 50) - margin * 2}px`,
|
height: `${(widget.computedHeight ?? 50) - margin * 2}px`,
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
|
|
|
@ -6,17 +6,22 @@ import { ComfySettingsDialog } from "./ui/settings.js";
|
||||||
export const ComfyDialog = _ComfyDialog;
|
export const ComfyDialog = _ComfyDialog;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
* @template { string | (keyof HTMLElementTagNameMap) } K
|
||||||
* @param { string } tag HTML Element Tag and optional classes e.g. div.class1.class2
|
* @typedef { K extends keyof HTMLElementTagNameMap ? HTMLElementTagNameMap[K] : HTMLElement } ElementType
|
||||||
* @param { string | Element | Element[] | {
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template { string | (keyof HTMLElementTagNameMap) } K
|
||||||
|
* @param { K } tag HTML Element Tag and optional classes e.g. div.class1.class2
|
||||||
|
* @param { string | Element | Element[] | ({
|
||||||
* parent?: Element,
|
* parent?: Element,
|
||||||
* $?: (el: Element) => void,
|
* $?: (el: ElementType<K>) => void,
|
||||||
* dataset?: DOMStringMap,
|
* dataset?: DOMStringMap,
|
||||||
* style?: CSSStyleDeclaration,
|
* style?: Partial<CSSStyleDeclaration>,
|
||||||
* for?: string
|
* for?: string
|
||||||
* } | undefined } propsOrChildren
|
* } & Omit<Partial<ElementType<K>>, "style">) | undefined } [propsOrChildren]
|
||||||
* @param { Element[] | undefined } [children]
|
* @param { string | Element | Element[] | undefined } [children]
|
||||||
* @returns
|
* @returns { ElementType<K> }
|
||||||
*/
|
*/
|
||||||
export function $el(tag, propsOrChildren, children) {
|
export function $el(tag, propsOrChildren, children) {
|
||||||
const split = tag.split(".");
|
const split = tag.split(".");
|
||||||
|
@ -54,7 +59,7 @@ export function $el(tag, propsOrChildren, children) {
|
||||||
|
|
||||||
Object.assign(element, propsOrChildren);
|
Object.assign(element, propsOrChildren);
|
||||||
if (children) {
|
if (children) {
|
||||||
element.append(...(children instanceof Array ? children : [children]));
|
element.append(...(children instanceof Array ? children.filter(Boolean) : [children]));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parent) {
|
if (parent) {
|
||||||
|
@ -102,6 +107,8 @@ function dragElement(dragEl, settings) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function positionElement() {
|
function positionElement() {
|
||||||
|
if(dragEl.style.display === "none") return;
|
||||||
|
|
||||||
const halfWidth = document.body.clientWidth / 2;
|
const halfWidth = document.body.clientWidth / 2;
|
||||||
const anchorRight = newPosX + dragEl.clientWidth / 2 > halfWidth;
|
const anchorRight = newPosX + dragEl.clientWidth / 2 > halfWidth;
|
||||||
|
|
||||||
|
@ -191,6 +198,8 @@ function dragElement(dragEl, settings) {
|
||||||
document.onmouseup = null;
|
document.onmouseup = null;
|
||||||
document.onmousemove = null;
|
document.onmousemove = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return restorePos;
|
||||||
}
|
}
|
||||||
|
|
||||||
class ComfyList {
|
class ComfyList {
|
||||||
|
@ -372,6 +381,8 @@ export class ComfyUI {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.loadFile = () => fileInput.click();
|
||||||
|
|
||||||
const autoQueueModeEl = toggleSwitch(
|
const autoQueueModeEl = toggleSwitch(
|
||||||
"autoQueueMode",
|
"autoQueueMode",
|
||||||
[
|
[
|
||||||
|
@ -592,6 +603,7 @@ export class ComfyUI {
|
||||||
onclick: () => app.refreshComboInNodes()
|
onclick: () => app.refreshComboInNodes()
|
||||||
}),
|
}),
|
||||||
$el("button", {id: "comfy-clipspace-button", textContent: "Clipspace", onclick: () => app.openClipspace()}),
|
$el("button", {id: "comfy-clipspace-button", textContent: "Clipspace", onclick: () => app.openClipspace()}),
|
||||||
|
$el("button", {id: "comfy-reset-view-button", textContent: "Reset View", onclick: () => app.resetView()}),
|
||||||
$el("button", {
|
$el("button", {
|
||||||
id: "comfy-clear-button", textContent: "Clear", onclick: () => {
|
id: "comfy-clear-button", textContent: "Clear", onclick: () => {
|
||||||
if (!confirmClear.value || confirm("Clear workflow?")) {
|
if (!confirmClear.value || confirm("Clear workflow?")) {
|
||||||
|
@ -621,10 +633,10 @@ export class ComfyUI {
|
||||||
name: "Enable Dev mode Options",
|
name: "Enable Dev mode Options",
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
defaultValue: false,
|
defaultValue: false,
|
||||||
onChange: function(value) { document.getElementById("comfy-dev-save-api-button").style.display = value ? "block" : "none"},
|
onChange: function(value) { document.getElementById("comfy-dev-save-api-button").style.display = value ? "flex" : "none"},
|
||||||
});
|
});
|
||||||
|
|
||||||
dragElement(this.menuContainer, this.settings);
|
this.restoreMenuPosition = dragElement(this.menuContainer, this.settings);
|
||||||
|
|
||||||
this.setStatus({exec_info: {queue_remaining: "X"}});
|
this.setStatus({exec_info: {queue_remaining: "X"}});
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
|
@ -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");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,43 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
import { $el } from "../../ui.js";
|
||||||
|
import { ComfyButton } from "./button.js";
|
||||||
|
import { prop } from "../../utils.js";
|
||||||
|
import { ComfyPopup } from "./popup.js";
|
||||||
|
|
||||||
|
export class ComfySplitButton {
|
||||||
|
/**
|
||||||
|
* @param {{
|
||||||
|
* primary: ComfyButton,
|
||||||
|
* mode?: "hover" | "click",
|
||||||
|
* horizontal?: "left" | "right",
|
||||||
|
* position?: "relative" | "absolute"
|
||||||
|
* }} param0
|
||||||
|
* @param {Array<ComfyButton> | Array<HTMLElement>} items
|
||||||
|
*/
|
||||||
|
constructor({ primary, mode, horizontal = "left", position = "relative" }, ...items) {
|
||||||
|
this.arrow = new ComfyButton({
|
||||||
|
icon: "chevron-down",
|
||||||
|
});
|
||||||
|
this.element = $el("div.comfyui-split-button" + (mode === "hover" ? ".hover" : ""), [
|
||||||
|
$el("div.comfyui-split-primary", primary.element),
|
||||||
|
$el("div.comfyui-split-arrow", this.arrow.element),
|
||||||
|
]);
|
||||||
|
this.popup = new ComfyPopup({
|
||||||
|
target: this.element,
|
||||||
|
container: position === "relative" ? this.element : document.body,
|
||||||
|
classList: "comfyui-split-button-popup" + (mode === "hover" ? " hover" : ""),
|
||||||
|
closeOnEscape: mode === "click",
|
||||||
|
position,
|
||||||
|
horizontal,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.arrow.withPopup(this.popup, mode);
|
||||||
|
|
||||||
|
this.items = prop(this, "items", items, () => this.update());
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
this.popup.element.replaceChildren(...this.items.map((b) => b.element ?? b));
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,20 +1,26 @@
|
||||||
import { $el } from "../ui.js";
|
import { $el } from "../ui.js";
|
||||||
|
|
||||||
export class ComfyDialog {
|
export class ComfyDialog extends EventTarget {
|
||||||
constructor() {
|
#buttons;
|
||||||
this.element = $el("div.comfy-modal", { parent: document.body }, [
|
|
||||||
|
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()]),
|
$el("div.comfy-modal-content", [$el("p", { $: (p) => (this.textElement = p) }), ...this.createButtons()]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
createButtons() {
|
createButtons() {
|
||||||
return [
|
return (
|
||||||
$el("button", {
|
this.#buttons ?? [
|
||||||
type: "button",
|
$el("button", {
|
||||||
textContent: "Close",
|
type: "button",
|
||||||
onclick: () => this.close(),
|
textContent: "Close",
|
||||||
}),
|
onclick: () => this.close(),
|
||||||
];
|
}),
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
|
@ -25,7 +31,7 @@ export class ComfyDialog {
|
||||||
if (typeof html === "string") {
|
if (typeof html === "string") {
|
||||||
this.textElement.innerHTML = html;
|
this.textElement.innerHTML = html;
|
||||||
} else {
|
} else {
|
||||||
this.textElement.replaceChildren(html);
|
this.textElement.replaceChildren(...(html instanceof Array ? html : [html]));
|
||||||
}
|
}
|
||||||
this.element.style.display = "flex";
|
this.element.style.display = "flex";
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
|
@ -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) {}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,764 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
import { ComfyButton } from "../components/button.js";
|
||||||
|
import { prop, getStorageValue, setStorageValue } from "../../utils.js";
|
||||||
|
import { $el } from "../../ui.js";
|
||||||
|
import { api } from "../../api.js";
|
||||||
|
import { ComfyPopup } from "../components/popup.js";
|
||||||
|
import { createSpinner } from "../spinner.js";
|
||||||
|
import { ComfyWorkflow, trimJsonExt } from "../../workflows.js";
|
||||||
|
import { ComfyAsyncDialog } from "../components/asyncDialog.js";
|
||||||
|
|
||||||
|
export class ComfyWorkflowsMenu {
|
||||||
|
#first = true;
|
||||||
|
element = $el("div.comfyui-workflows");
|
||||||
|
|
||||||
|
get open() {
|
||||||
|
return this.popup.open;
|
||||||
|
}
|
||||||
|
|
||||||
|
set open(open) {
|
||||||
|
this.popup.open = open;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import("../../app.js").ComfyApp} app
|
||||||
|
*/
|
||||||
|
constructor(app) {
|
||||||
|
this.app = app;
|
||||||
|
this.#bindEvents();
|
||||||
|
|
||||||
|
const classList = {
|
||||||
|
"comfyui-workflows-button": true,
|
||||||
|
"comfyui-button": true,
|
||||||
|
unsaved: getStorageValue("Comfy.PreviousWorkflowUnsaved") === "true",
|
||||||
|
running: false,
|
||||||
|
};
|
||||||
|
this.buttonProgress = $el("div.comfyui-workflows-button-progress");
|
||||||
|
this.workflowLabel = $el("span.comfyui-workflows-label", "");
|
||||||
|
this.button = new ComfyButton({
|
||||||
|
content: $el("div.comfyui-workflows-button-inner", [$el("i.mdi.mdi-graph"), this.workflowLabel, this.buttonProgress]),
|
||||||
|
icon: "chevron-down",
|
||||||
|
classList,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.element.append(this.button.element);
|
||||||
|
|
||||||
|
this.popup = new ComfyPopup({ target: this.element, classList: "comfyui-workflows-popup" });
|
||||||
|
this.content = new ComfyWorkflowsContent(app, this.popup);
|
||||||
|
this.popup.children = [this.content.element];
|
||||||
|
this.popup.addEventListener("change", () => {
|
||||||
|
this.button.icon = "chevron-" + (this.popup.open ? "up" : "down");
|
||||||
|
});
|
||||||
|
this.button.withPopup(this.popup);
|
||||||
|
|
||||||
|
this.unsaved = prop(this, "unsaved", classList.unsaved, (v) => {
|
||||||
|
classList.unsaved = v;
|
||||||
|
this.button.classList = classList;
|
||||||
|
setStorageValue("Comfy.PreviousWorkflowUnsaved", v);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#updateProgress = () => {
|
||||||
|
const prompt = this.app.workflowManager.activePrompt;
|
||||||
|
let percent = 0;
|
||||||
|
if (this.app.workflowManager.activeWorkflow === prompt?.workflow) {
|
||||||
|
const total = Object.values(prompt.nodes);
|
||||||
|
const done = total.filter(Boolean);
|
||||||
|
percent = (done.length / total.length) * 100;
|
||||||
|
}
|
||||||
|
this.buttonProgress.style.width = percent + "%";
|
||||||
|
};
|
||||||
|
|
||||||
|
#updateActive = () => {
|
||||||
|
const active = this.app.workflowManager.activeWorkflow;
|
||||||
|
this.button.tooltip = active.path;
|
||||||
|
this.workflowLabel.textContent = active.name;
|
||||||
|
this.unsaved = active.unsaved;
|
||||||
|
|
||||||
|
if (this.#first) {
|
||||||
|
this.#first = false;
|
||||||
|
this.content.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#updateProgress();
|
||||||
|
};
|
||||||
|
|
||||||
|
#bindEvents() {
|
||||||
|
this.app.workflowManager.addEventListener("changeWorkflow", this.#updateActive);
|
||||||
|
this.app.workflowManager.addEventListener("rename", this.#updateActive);
|
||||||
|
this.app.workflowManager.addEventListener("delete", this.#updateActive);
|
||||||
|
|
||||||
|
this.app.workflowManager.addEventListener("save", () => {
|
||||||
|
this.unsaved = this.app.workflowManager.activeWorkflow.unsaved;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.app.workflowManager.addEventListener("execute", (e) => {
|
||||||
|
this.#updateProgress();
|
||||||
|
});
|
||||||
|
|
||||||
|
api.addEventListener("graphChanged", () => {
|
||||||
|
this.unsaved = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#getMenuOptions(callback) {
|
||||||
|
const menu = [];
|
||||||
|
const directories = new Map();
|
||||||
|
for (const workflow of this.app.workflowManager.workflows || []) {
|
||||||
|
const path = workflow.pathParts;
|
||||||
|
if (!path) continue;
|
||||||
|
let parent = menu;
|
||||||
|
let currentPath = "";
|
||||||
|
for (let i = 0; i < path.length - 1; i++) {
|
||||||
|
currentPath += "/" + path[i];
|
||||||
|
let newParent = directories.get(currentPath);
|
||||||
|
if (!newParent) {
|
||||||
|
newParent = {
|
||||||
|
title: path[i],
|
||||||
|
has_submenu: true,
|
||||||
|
submenu: {
|
||||||
|
options: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
parent.push(newParent);
|
||||||
|
newParent = newParent.submenu.options;
|
||||||
|
directories.set(currentPath, newParent);
|
||||||
|
}
|
||||||
|
parent = newParent;
|
||||||
|
}
|
||||||
|
parent.push({
|
||||||
|
title: trimJsonExt(path[path.length - 1]),
|
||||||
|
callback: () => callback(workflow),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return menu;
|
||||||
|
}
|
||||||
|
|
||||||
|
#getFavoriteMenuOptions(callback) {
|
||||||
|
const menu = [];
|
||||||
|
for (const workflow of this.app.workflowManager.workflows || []) {
|
||||||
|
if (workflow.isFavorite) {
|
||||||
|
menu.push({
|
||||||
|
title: "⭐ " + workflow.name,
|
||||||
|
callback: () => callback(workflow),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return menu;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import("../../app.js").ComfyApp} app
|
||||||
|
*/
|
||||||
|
registerExtension(app) {
|
||||||
|
const self = this;
|
||||||
|
app.registerExtension({
|
||||||
|
name: "Comfy.Workflows",
|
||||||
|
async beforeRegisterNodeDef(nodeType) {
|
||||||
|
function getImageWidget(node) {
|
||||||
|
const inputs = { ...node.constructor?.nodeData?.input?.required, ...node.constructor?.nodeData?.input?.optional };
|
||||||
|
for (const input in inputs) {
|
||||||
|
if (inputs[input][0] === "IMAGEUPLOAD") {
|
||||||
|
const imageWidget = node.widgets.find((w) => w.name === (inputs[input]?.[1]?.widget ?? "image"));
|
||||||
|
if (imageWidget) return imageWidget;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setWidgetImage(node, widget, img) {
|
||||||
|
const url = new URL(img.src);
|
||||||
|
const filename = url.searchParams.get("filename");
|
||||||
|
const subfolder = url.searchParams.get("subfolder");
|
||||||
|
const type = url.searchParams.get("type");
|
||||||
|
const imageId = `${subfolder ? subfolder + "/" : ""}${filename} [${type}]`;
|
||||||
|
widget.value = imageId;
|
||||||
|
node.imgs = [img];
|
||||||
|
app.graph.setDirtyCanvas(true, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {HTMLImageElement} img
|
||||||
|
* @param {ComfyWorkflow} workflow
|
||||||
|
*/
|
||||||
|
async function sendToWorkflow(img, workflow) {
|
||||||
|
await workflow.load();
|
||||||
|
let options = [];
|
||||||
|
const nodes = app.graph.computeExecutionOrder(false);
|
||||||
|
for (const node of nodes) {
|
||||||
|
const widget = getImageWidget(node);
|
||||||
|
if (widget == null) continue;
|
||||||
|
|
||||||
|
if (node.title?.toLowerCase().includes("input")) {
|
||||||
|
options = [{ widget, node }];
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
options.push({ widget, node });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.length) {
|
||||||
|
alert("No image nodes have been found in this workflow!");
|
||||||
|
return;
|
||||||
|
} else if (options.length > 1) {
|
||||||
|
const dialog = new WidgetSelectionDialog(options);
|
||||||
|
const res = await dialog.show(app);
|
||||||
|
if (!res) return;
|
||||||
|
options = [res];
|
||||||
|
}
|
||||||
|
|
||||||
|
setWidgetImage(options[0].node, options[0].widget, img);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getExtraMenuOptions = nodeType.prototype["getExtraMenuOptions"];
|
||||||
|
nodeType.prototype["getExtraMenuOptions"] = function (_, options) {
|
||||||
|
const r = getExtraMenuOptions?.apply?.(this, arguments);
|
||||||
|
|
||||||
|
if (app.ui.settings.getSettingValue("Comfy.UseNewMenu", false) === true) {
|
||||||
|
const t = /** @type { {imageIndex?: number, overIndex?: number, imgs: string[]} } */ /** @type {any} */ (this);
|
||||||
|
let img;
|
||||||
|
if (t.imageIndex != null) {
|
||||||
|
// An image is selected so select that
|
||||||
|
img = t.imgs?.[t.imageIndex];
|
||||||
|
} else if (t.overIndex != null) {
|
||||||
|
// No image is selected but one is hovered
|
||||||
|
img = t.img?.s[t.overIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (img) {
|
||||||
|
let pos = options.findIndex((o) => o.content === "Save Image");
|
||||||
|
if (pos === -1) {
|
||||||
|
pos = 0;
|
||||||
|
} else {
|
||||||
|
pos++;
|
||||||
|
}
|
||||||
|
|
||||||
|
options.splice(pos, 0, {
|
||||||
|
content: "Send to workflow",
|
||||||
|
has_submenu: true,
|
||||||
|
submenu: {
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
callback: () => sendToWorkflow(img, app.workflowManager.activeWorkflow),
|
||||||
|
title: "[Current workflow]",
|
||||||
|
},
|
||||||
|
...self.#getFavoriteMenuOptions(sendToWorkflow.bind(null, img)),
|
||||||
|
null,
|
||||||
|
...self.#getMenuOptions(sendToWorkflow.bind(null, img)),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return r;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ComfyWorkflowsContent {
|
||||||
|
element = $el("div.comfyui-workflows-panel");
|
||||||
|
treeState = {};
|
||||||
|
treeFiles = {};
|
||||||
|
/** @type { Map<ComfyWorkflow, WorkflowElement> } */
|
||||||
|
openFiles = new Map();
|
||||||
|
/** @type {WorkflowElement} */
|
||||||
|
activeElement = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import("../../app.js").ComfyApp} app
|
||||||
|
* @param {ComfyPopup} popup
|
||||||
|
*/
|
||||||
|
constructor(app, popup) {
|
||||||
|
this.app = app;
|
||||||
|
this.popup = popup;
|
||||||
|
this.actions = $el("div.comfyui-workflows-actions", [
|
||||||
|
new ComfyButton({
|
||||||
|
content: "Default",
|
||||||
|
icon: "file-code",
|
||||||
|
iconSize: 18,
|
||||||
|
classList: "comfyui-button primary",
|
||||||
|
tooltip: "Load default workflow",
|
||||||
|
action: () => {
|
||||||
|
popup.open = false;
|
||||||
|
app.loadGraphData();
|
||||||
|
app.resetView();
|
||||||
|
},
|
||||||
|
}).element,
|
||||||
|
new ComfyButton({
|
||||||
|
content: "Browse",
|
||||||
|
icon: "folder",
|
||||||
|
iconSize: 18,
|
||||||
|
tooltip: "Browse for an image or exported workflow",
|
||||||
|
action: () => {
|
||||||
|
popup.open = false;
|
||||||
|
app.ui.loadFile();
|
||||||
|
},
|
||||||
|
}).element,
|
||||||
|
new ComfyButton({
|
||||||
|
content: "Blank",
|
||||||
|
icon: "plus-thick",
|
||||||
|
iconSize: 18,
|
||||||
|
tooltip: "Create a new blank workflow",
|
||||||
|
action: () => {
|
||||||
|
app.workflowManager.setWorkflow(null);
|
||||||
|
app.clean();
|
||||||
|
app.graph.clear();
|
||||||
|
app.workflowManager.activeWorkflow.track();
|
||||||
|
popup.open = false;
|
||||||
|
},
|
||||||
|
}).element,
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.spinner = createSpinner();
|
||||||
|
this.element.replaceChildren(this.actions, this.spinner);
|
||||||
|
|
||||||
|
this.popup.addEventListener("open", () => this.load());
|
||||||
|
this.popup.addEventListener("close", () => this.element.replaceChildren(this.actions, this.spinner));
|
||||||
|
|
||||||
|
this.app.workflowManager.addEventListener("favorite", (e) => {
|
||||||
|
const workflow = e["detail"];
|
||||||
|
const button = this.treeFiles[workflow.path]?.primary;
|
||||||
|
if (!button) return; // Can happen when a workflow is renamed
|
||||||
|
button.icon = this.#getFavoriteIcon(workflow);
|
||||||
|
button.overIcon = this.#getFavoriteOverIcon(workflow);
|
||||||
|
this.updateFavorites();
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const e of ["save", "open", "close", "changeWorkflow"]) {
|
||||||
|
// TODO: dont be lazy and just update the specific element
|
||||||
|
app.workflowManager.addEventListener(e, () => this.updateOpen());
|
||||||
|
}
|
||||||
|
this.app.workflowManager.addEventListener("rename", () => this.load());
|
||||||
|
this.app.workflowManager.addEventListener("execute", (e) => this.#updateActive());
|
||||||
|
}
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
await this.app.workflowManager.loadWorkflows();
|
||||||
|
this.updateTree();
|
||||||
|
this.updateFavorites();
|
||||||
|
this.updateOpen();
|
||||||
|
this.element.replaceChildren(this.actions, this.openElement, this.favoritesElement, this.treeElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateOpen() {
|
||||||
|
const current = this.openElement;
|
||||||
|
this.openFiles.clear();
|
||||||
|
|
||||||
|
this.openElement = $el("div.comfyui-workflows-open", [
|
||||||
|
$el("h3", "Open"),
|
||||||
|
...this.app.workflowManager.openWorkflows.map((w) => {
|
||||||
|
const wrapper = new WorkflowElement(this, w, {
|
||||||
|
primary: { element: $el("i.mdi.mdi-18px.mdi-progress-pencil") },
|
||||||
|
buttons: [
|
||||||
|
this.#getRenameButton(w),
|
||||||
|
new ComfyButton({
|
||||||
|
icon: "close",
|
||||||
|
iconSize: 18,
|
||||||
|
classList: "comfyui-button comfyui-workflows-file-action",
|
||||||
|
tooltip: "Close workflow",
|
||||||
|
action: (e) => {
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
this.app.workflowManager.closeWorkflow(w);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (w.unsaved) {
|
||||||
|
wrapper.element.classList.add("unsaved");
|
||||||
|
}
|
||||||
|
if(w === this.app.workflowManager.activeWorkflow) {
|
||||||
|
wrapper.element.classList.add("active");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.openFiles.set(w, wrapper);
|
||||||
|
return wrapper.element;
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.#updateActive();
|
||||||
|
current?.replaceWith(this.openElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFavorites() {
|
||||||
|
const current = this.favoritesElement;
|
||||||
|
const favorites = [...this.app.workflowManager.workflows.filter((w) => w.isFavorite)];
|
||||||
|
|
||||||
|
this.favoritesElement = $el("div.comfyui-workflows-favorites", [
|
||||||
|
$el("h3", "Favorites"),
|
||||||
|
...favorites
|
||||||
|
.map((w) => {
|
||||||
|
return this.#getWorkflowElement(w).element;
|
||||||
|
})
|
||||||
|
.filter(Boolean),
|
||||||
|
]);
|
||||||
|
|
||||||
|
current?.replaceWith(this.favoritesElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
filterTree() {
|
||||||
|
if (!this.filterText) {
|
||||||
|
this.treeRoot.classList.remove("filtered");
|
||||||
|
// Unfilter whole tree
|
||||||
|
for (const item of Object.values(this.treeFiles)) {
|
||||||
|
item.element.parentElement.style.removeProperty("display");
|
||||||
|
this.showTreeParents(item.element.parentElement);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.treeRoot.classList.add("filtered");
|
||||||
|
const searchTerms = this.filterText.toLocaleLowerCase().split(" ");
|
||||||
|
for (const item of Object.values(this.treeFiles)) {
|
||||||
|
const parts = item.workflow.pathParts;
|
||||||
|
let termIndex = 0;
|
||||||
|
let valid = false;
|
||||||
|
for (const part of parts) {
|
||||||
|
let currentIndex = 0;
|
||||||
|
do {
|
||||||
|
currentIndex = part.indexOf(searchTerms[termIndex], currentIndex);
|
||||||
|
if (currentIndex > -1) currentIndex += searchTerms[termIndex].length;
|
||||||
|
} while (currentIndex !== -1 && ++termIndex < searchTerms.length);
|
||||||
|
|
||||||
|
if (termIndex >= searchTerms.length) {
|
||||||
|
valid = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (valid) {
|
||||||
|
item.element.parentElement.style.removeProperty("display");
|
||||||
|
this.showTreeParents(item.element.parentElement);
|
||||||
|
} else {
|
||||||
|
item.element.parentElement.style.display = "none";
|
||||||
|
this.hideTreeParents(item.element.parentElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hideTreeParents(element) {
|
||||||
|
// Hide all parents if no children are visible
|
||||||
|
if (element.parentElement?.classList.contains("comfyui-workflows-tree") === false) {
|
||||||
|
for (let i = 1; i < element.parentElement.children.length; i++) {
|
||||||
|
const c = element.parentElement.children[i];
|
||||||
|
if (c.style.display !== "none") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
element.parentElement.style.display = "none";
|
||||||
|
this.hideTreeParents(element.parentElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showTreeParents(element) {
|
||||||
|
if (element.parentElement?.classList.contains("comfyui-workflows-tree") === false) {
|
||||||
|
element.parentElement.style.removeProperty("display");
|
||||||
|
this.showTreeParents(element.parentElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTree() {
|
||||||
|
const current = this.treeElement;
|
||||||
|
const nodes = {};
|
||||||
|
let typingTimeout;
|
||||||
|
|
||||||
|
this.treeFiles = {};
|
||||||
|
this.treeRoot = $el("ul.comfyui-workflows-tree");
|
||||||
|
this.treeElement = $el("section", [
|
||||||
|
$el("header", [
|
||||||
|
$el("h3", "Browse"),
|
||||||
|
$el("div.comfy-ui-workflows-search", [
|
||||||
|
$el("i.mdi.mdi-18px.mdi-magnify"),
|
||||||
|
$el("input", {
|
||||||
|
placeholder: "Search",
|
||||||
|
value: this.filterText ?? "",
|
||||||
|
oninput: (e) => {
|
||||||
|
this.filterText = e.target["value"]?.trim();
|
||||||
|
clearTimeout(typingTimeout);
|
||||||
|
typingTimeout = setTimeout(() => this.filterTree(), 250);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
this.treeRoot,
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const workflow of this.app.workflowManager.workflows) {
|
||||||
|
if (!workflow.pathParts) continue;
|
||||||
|
|
||||||
|
let currentPath = "";
|
||||||
|
let currentRoot = this.treeRoot;
|
||||||
|
|
||||||
|
for (let i = 0; i < workflow.pathParts.length; i++) {
|
||||||
|
currentPath += (currentPath ? "\\" : "") + workflow.pathParts[i];
|
||||||
|
const parentNode = nodes[currentPath] ?? this.#createNode(currentPath, workflow, i, currentRoot);
|
||||||
|
|
||||||
|
nodes[currentPath] = parentNode;
|
||||||
|
currentRoot = parentNode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
current?.replaceWith(this.treeElement);
|
||||||
|
this.filterTree();
|
||||||
|
}
|
||||||
|
|
||||||
|
#expandNode(el, workflow, thisPath, i) {
|
||||||
|
const expanded = !el.classList.toggle("closed");
|
||||||
|
if (expanded) {
|
||||||
|
let c = "";
|
||||||
|
for (let j = 0; j <= i; j++) {
|
||||||
|
c += (c ? "\\" : "") + workflow.pathParts[j];
|
||||||
|
this.treeState[c] = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let c = thisPath;
|
||||||
|
for (let j = i + 1; j < workflow.pathParts.length; j++) {
|
||||||
|
c += (c ? "\\" : "") + workflow.pathParts[j];
|
||||||
|
delete this.treeState[c];
|
||||||
|
}
|
||||||
|
delete this.treeState[thisPath];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#updateActive() {
|
||||||
|
this.#removeActive();
|
||||||
|
|
||||||
|
const active = this.app.workflowManager.activePrompt;
|
||||||
|
if (!active?.workflow) return;
|
||||||
|
|
||||||
|
const open = this.openFiles.get(active.workflow);
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
this.activeElement = open;
|
||||||
|
|
||||||
|
const total = Object.values(active.nodes);
|
||||||
|
const done = total.filter(Boolean);
|
||||||
|
const percent = done.length / total.length;
|
||||||
|
open.element.classList.add("running");
|
||||||
|
open.element.style.setProperty("--progress", percent * 100 + "%");
|
||||||
|
open.primary.element.classList.remove("mdi-progress-pencil");
|
||||||
|
open.primary.element.classList.add("mdi-play");
|
||||||
|
}
|
||||||
|
|
||||||
|
#removeActive() {
|
||||||
|
if (!this.activeElement) return;
|
||||||
|
this.activeElement.element.classList.remove("running");
|
||||||
|
this.activeElement.element.style.removeProperty("--progress");
|
||||||
|
this.activeElement.primary.element.classList.add("mdi-progress-pencil");
|
||||||
|
this.activeElement.primary.element.classList.remove("mdi-play");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {ComfyWorkflow} workflow */
|
||||||
|
#getFavoriteIcon(workflow) {
|
||||||
|
return workflow.isFavorite ? "star" : "file-outline";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {ComfyWorkflow} workflow */
|
||||||
|
#getFavoriteOverIcon(workflow) {
|
||||||
|
return workflow.isFavorite ? "star-off" : "star-outline";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {ComfyWorkflow} workflow */
|
||||||
|
#getFavoriteTooltip(workflow) {
|
||||||
|
return workflow.isFavorite ? "Remove this workflow from your favorites" : "Add this workflow to your favorites";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {ComfyWorkflow} workflow */
|
||||||
|
#getFavoriteButton(workflow, primary) {
|
||||||
|
return new ComfyButton({
|
||||||
|
icon: this.#getFavoriteIcon(workflow),
|
||||||
|
overIcon: this.#getFavoriteOverIcon(workflow),
|
||||||
|
iconSize: 18,
|
||||||
|
classList: "comfyui-button comfyui-workflows-file-action-favorite" + (primary ? " comfyui-workflows-file-action-primary" : ""),
|
||||||
|
tooltip: this.#getFavoriteTooltip(workflow),
|
||||||
|
action: (e) => {
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
workflow.favorite(!workflow.isFavorite);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {ComfyWorkflow} workflow */
|
||||||
|
#getDeleteButton(workflow) {
|
||||||
|
const deleteButton = new ComfyButton({
|
||||||
|
icon: "delete",
|
||||||
|
tooltip: "Delete this workflow",
|
||||||
|
classList: "comfyui-button comfyui-workflows-file-action",
|
||||||
|
iconSize: 18,
|
||||||
|
action: async (e, btn) => {
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
|
||||||
|
if (btn.icon === "delete-empty") {
|
||||||
|
btn.enabled = false;
|
||||||
|
await workflow.delete();
|
||||||
|
await this.load();
|
||||||
|
} else {
|
||||||
|
btn.icon = "delete-empty";
|
||||||
|
btn.element.style.background = "red";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
deleteButton.element.addEventListener("mouseleave", () => {
|
||||||
|
deleteButton.icon = "delete";
|
||||||
|
deleteButton.element.style.removeProperty("background");
|
||||||
|
});
|
||||||
|
return deleteButton;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {ComfyWorkflow} workflow */
|
||||||
|
#getInsertButton(workflow) {
|
||||||
|
return new ComfyButton({
|
||||||
|
icon: "file-move-outline",
|
||||||
|
iconSize: 18,
|
||||||
|
tooltip: "Insert this workflow into the current workflow",
|
||||||
|
classList: "comfyui-button comfyui-workflows-file-action",
|
||||||
|
action: (e) => {
|
||||||
|
if (!this.app.shiftDown) {
|
||||||
|
this.popup.open = false;
|
||||||
|
}
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
if (!this.app.shiftDown) {
|
||||||
|
this.popup.open = false;
|
||||||
|
}
|
||||||
|
workflow.insert();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {ComfyWorkflow} workflow */
|
||||||
|
#getRenameButton(workflow) {
|
||||||
|
return new ComfyButton({
|
||||||
|
icon: "pencil",
|
||||||
|
tooltip: workflow.path ? "Rename this workflow" : "This workflow can't be renamed as it hasn't been saved.",
|
||||||
|
classList: "comfyui-button comfyui-workflows-file-action",
|
||||||
|
iconSize: 18,
|
||||||
|
enabled: !!workflow.path,
|
||||||
|
action: async (e) => {
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
const newName = prompt("Enter new name", workflow.path);
|
||||||
|
if (newName) {
|
||||||
|
await workflow.rename(newName);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {ComfyWorkflow} workflow */
|
||||||
|
#getWorkflowElement(workflow) {
|
||||||
|
return new WorkflowElement(this, workflow, {
|
||||||
|
primary: this.#getFavoriteButton(workflow, true),
|
||||||
|
buttons: [this.#getInsertButton(workflow), this.#getRenameButton(workflow), this.#getDeleteButton(workflow)],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {ComfyWorkflow} workflow */
|
||||||
|
#createLeafNode(workflow) {
|
||||||
|
const fileNode = this.#getWorkflowElement(workflow);
|
||||||
|
this.treeFiles[workflow.path] = fileNode;
|
||||||
|
return fileNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
#createNode(currentPath, workflow, i, currentRoot) {
|
||||||
|
const part = workflow.pathParts[i];
|
||||||
|
|
||||||
|
const parentNode = $el("ul" + (this.treeState[currentPath] ? "" : ".closed"), {
|
||||||
|
$: (el) => {
|
||||||
|
el.onclick = (e) => {
|
||||||
|
this.#expandNode(el, workflow, currentPath, i);
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
currentRoot.append(parentNode);
|
||||||
|
|
||||||
|
// Create a node for the current part and an inner UL for its children if it isnt a leaf node
|
||||||
|
const leaf = i === workflow.pathParts.length - 1;
|
||||||
|
let nodeElement;
|
||||||
|
if (leaf) {
|
||||||
|
nodeElement = this.#createLeafNode(workflow).element;
|
||||||
|
} else {
|
||||||
|
nodeElement = $el("li", [$el("i.mdi.mdi-18px.mdi-folder"), $el("span", part)]);
|
||||||
|
}
|
||||||
|
parentNode.append(nodeElement);
|
||||||
|
return parentNode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class WorkflowElement {
|
||||||
|
/**
|
||||||
|
* @param { ComfyWorkflowsContent } parent
|
||||||
|
* @param { ComfyWorkflow } workflow
|
||||||
|
*/
|
||||||
|
constructor(parent, workflow, { tagName = "li", primary, buttons }) {
|
||||||
|
this.parent = parent;
|
||||||
|
this.workflow = workflow;
|
||||||
|
this.primary = primary;
|
||||||
|
this.buttons = buttons;
|
||||||
|
|
||||||
|
this.element = $el(
|
||||||
|
tagName + ".comfyui-workflows-tree-file",
|
||||||
|
{
|
||||||
|
onclick: () => {
|
||||||
|
workflow.load();
|
||||||
|
this.parent.popup.open = false;
|
||||||
|
},
|
||||||
|
title: this.workflow.path,
|
||||||
|
},
|
||||||
|
[this.primary?.element, $el("span", workflow.name), ...buttons.map((b) => b.element)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class WidgetSelectionDialog extends ComfyAsyncDialog {
|
||||||
|
#options;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Array<{widget: {name: string}, node: {pos: [number, number], title: string, id: string, type: string}}>} options
|
||||||
|
*/
|
||||||
|
constructor(options) {
|
||||||
|
super();
|
||||||
|
this.#options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
show(app) {
|
||||||
|
this.element.classList.add("comfy-widget-selection-dialog");
|
||||||
|
return super.show(
|
||||||
|
$el("div", [
|
||||||
|
$el("h2", "Select image target"),
|
||||||
|
$el(
|
||||||
|
"p",
|
||||||
|
"This workflow has multiple image loader nodes, you can rename a node to include 'input' in the title for it to be automatically selected, or select one below."
|
||||||
|
),
|
||||||
|
$el(
|
||||||
|
"section",
|
||||||
|
this.#options.map((opt) => {
|
||||||
|
return $el("div.comfy-widget-selection-item", [
|
||||||
|
$el("span", { dataset: { id: opt.node.id } }, `${opt.node.title ?? opt.node.type} ${opt.widget.name}`),
|
||||||
|
$el(
|
||||||
|
"button.comfyui-button",
|
||||||
|
{
|
||||||
|
onclick: () => {
|
||||||
|
app.canvas.ds.offset[0] = -opt.node.pos[0] + 50;
|
||||||
|
app.canvas.ds.offset[1] = -opt.node.pos[1] + 50;
|
||||||
|
app.canvas.selectNode(opt.node);
|
||||||
|
app.graph.setDirtyCanvas(true, true);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Show"
|
||||||
|
),
|
||||||
|
$el(
|
||||||
|
"button.comfyui-button.primary",
|
||||||
|
{
|
||||||
|
onclick: () => {
|
||||||
|
this.close(opt);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Select"
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -47,6 +47,17 @@ export class ComfySettingsDialog extends ComfyDialog {
|
||||||
return Object.values(this.settingsLookup);
|
return Object.values(this.settingsLookup);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#dispatchChange(id, value, oldValue) {
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent(id + ".change", {
|
||||||
|
detail: {
|
||||||
|
value,
|
||||||
|
oldValue
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async load() {
|
async load() {
|
||||||
if (this.app.storageLocation === "browser") {
|
if (this.app.storageLocation === "browser") {
|
||||||
this.settingsValues = localStorage;
|
this.settingsValues = localStorage;
|
||||||
|
@ -56,7 +67,9 @@ export class ComfySettingsDialog extends ComfyDialog {
|
||||||
|
|
||||||
// Trigger onChange for any settings added before load
|
// Trigger onChange for any settings added before load
|
||||||
for (const id in this.settingsLookup) {
|
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) {
|
if (id in this.settingsLookup) {
|
||||||
this.settingsLookup[id].onChange?.(value, oldValue);
|
this.settingsLookup[id].onChange?.(value, oldValue);
|
||||||
}
|
}
|
||||||
|
this.#dispatchChange(id, value, oldValue);
|
||||||
|
|
||||||
await api.storeSetting(id, value);
|
await api.storeSetting(id, value);
|
||||||
}
|
}
|
||||||
|
@ -136,6 +150,8 @@ export class ComfySettingsDialog extends ComfyDialog {
|
||||||
onChange,
|
onChange,
|
||||||
name,
|
name,
|
||||||
render: () => {
|
render: () => {
|
||||||
|
if (type === "hidden") return;
|
||||||
|
|
||||||
const setter = (v) => {
|
const setter = (v) => {
|
||||||
if (onChange) {
|
if (onChange) {
|
||||||
onChange(v, value);
|
onChange(v, value);
|
||||||
|
@ -310,7 +326,7 @@ export class ComfySettingsDialog extends ComfyDialog {
|
||||||
},
|
},
|
||||||
[$el("th"), $el("th", { style: { width: "33%" } })]
|
[$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();
|
this.element.showModal();
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
|
@ -1,4 +1,5 @@
|
||||||
import { $el } from "./ui.js";
|
import { $el } from "./ui.js";
|
||||||
|
import { api } from "./api.js";
|
||||||
|
|
||||||
// Simple date formatter
|
// Simple date formatter
|
||||||
const parts = {
|
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) {
|
export function applyTextReplacements(app, value) {
|
||||||
return value.replace(/%([^%]+)%/g, function (match, text) {
|
return value.replace(/%([^%]+)%/g, function (match, text) {
|
||||||
const split = text.split(".");
|
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);
|
||||||
|
}
|
|
@ -0,0 +1,450 @@
|
||||||
|
// @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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,3 +1,5 @@
|
||||||
|
@import url("scripts/ui/menu/menu.css");
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--fg-color: #000;
|
--fg-color: #000;
|
||||||
--bg-color: #fff;
|
--bg-color: #fff;
|
||||||
|
@ -10,12 +12,24 @@
|
||||||
--border-color: #4e4e4e;
|
--border-color: #4e4e4e;
|
||||||
--tr-even-bg-color: #222;
|
--tr-even-bg-color: #222;
|
||||||
--tr-odd-bg-color: #353535;
|
--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) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
--fg-color: #fff;
|
--fg-color: #fff;
|
||||||
--bg-color: #202020;
|
--bg-color: #202020;
|
||||||
|
--content-bg: #4e4e4e;
|
||||||
|
--content-fg: #fff;
|
||||||
|
--content-hover-bg: #222;
|
||||||
|
--content-hover-fg: #fff;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -26,11 +40,41 @@ body {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background-color: var(--bg-color);
|
background-color: var(--bg-color);
|
||||||
color: var(--fg-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 {
|
#graph-canvas {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 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 {
|
.comfy-multiline-input {
|
||||||
|
@ -364,6 +408,37 @@ dialog::backdrop {
|
||||||
background: rgba(0, 0, 0, 0.5);
|
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 {
|
#comfy-settings-dialog {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
width: 41rem;
|
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
|
* Allows any initialisation, e.g. loading resources. Called after the canvas is created but before nodes are added
|
||||||
* @param app The ComfyUI app instance
|
* @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
|
* Allows any additonal setup, called after the application is fully set up and running
|
||||||
* @param app The ComfyUI app instance
|
* @param app The ComfyUI app instance
|
||||||
*/
|
*/
|
||||||
setup(app: ComfyApp): Promise<void>;
|
setup?(app: ComfyApp): Promise<void>;
|
||||||
/**
|
/**
|
||||||
* Called before nodes are registered with the graph
|
* Called before nodes are registered with the graph
|
||||||
* @param defs The collection of node definitions, add custom ones or edit existing ones
|
* @param defs The collection of node definitions, add custom ones or edit existing ones
|
||||||
* @param app The ComfyUI app instance
|
* @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
|
* Allows the extension to add custom widgets
|
||||||
* @param app The ComfyUI app instance
|
* @param app The ComfyUI app instance
|
||||||
* @returns An array of {[widget name]: widget data}
|
* @returns An array of {[widget name]: widget data}
|
||||||
*/
|
*/
|
||||||
getCustomWidgets(
|
getCustomWidgets?(
|
||||||
app: ComfyApp
|
app: ComfyApp
|
||||||
): Promise<
|
): Promise<
|
||||||
Record<string, (node, inputName, inputData, app) => { widget?: IWidget; minWidth?: number; minHeight?: number }>
|
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 nodeData The original node object info config object
|
||||||
* @param app The ComfyUI app instance
|
* @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
|
* Allows the extension to register additional nodes with LGraph after standard nodes are added
|
||||||
* @param app The ComfyUI app instance
|
* @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.
|
* 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
|
* 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 node The node that has been loaded
|
||||||
* @param app The ComfyUI app instance
|
* @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
|
* Allows the extension to run code after the constructor of the node
|
||||||
* @param node The node that has been created
|
* @param node The node that has been created
|
||||||
* @param app The ComfyUI app instance
|
* @param app The ComfyUI app instance
|
||||||
*/
|
*/
|
||||||
nodeCreated(node: LGraphNode, app: ComfyApp);
|
nodeCreated?(node: LGraphNode, app: ComfyApp);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ComfyObjectInfo = {
|
export type ComfyObjectInfo = {
|
||||||
|
|
Loading…
Reference in New Issue