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

New Menu & Workflow Management (#3112)

* menu

* wip

* wip

* wip

* wip

* wip

* workflow saving/loading

* Support inserting workflows
Move buttosn to top of lists

* fix session storage
implement renaming

* temp

* refactor, better workflow instance management

* wip

* progress on progress

* added send to workflow
various fixes

* Support multiple image loaders

* Support dynamic size breakpoints based on content

* various fixes
add close unsaved warning

* Add filtering tree

* prevent renaming unsaved

* fix zindex on hover

* fix top offset

* use filename as workflow name

* resize on setting change

* hide element until it is drawn

* remove glow

* Fix export name

* Fix test, revert accidental changes to groupNode

* Fix colors on all themes

* show hover items on smaller screen (mobile)

* remove debugging code

* dialog fix

* Dont reorder open workflows
Allow elements around canvas

* Toggle body display on setting change

* Fix menu disappearing on chrome

* Increase delay when typing, remove margin on Safari, fix dialog location

* Fix overflow issue on iOS

* Add reset view button
Prevent view changes causing history entries

* Bottom menu wip

* Various fixes

* Fix merge

* Fix breaking old menu position

* Fix merge adding restore view to loadGraphData
parent eab211bb
......@@ -2,6 +2,8 @@ import json
import os
import re
import uuid
import glob
import shutil
from aiohttp import web
from comfy.cli_args import args
from folder_paths import user_directory
......@@ -56,16 +58,16 @@ class UserManager():
if os.path.commonpath((root_dir, user_root)) != root_dir:
return None
parent = user_root
if file is not None:
# prevent leaving /{type}/{user}
path = os.path.abspath(os.path.join(user_root, file))
if os.path.commonpath((user_root, path)) != user_root:
return None
parent = os.path.split(path)[0]
if create_dir and not os.path.exists(parent):
os.mkdir(parent)
os.makedirs(parent, exist_ok=True)
return path
......@@ -108,24 +110,32 @@ class UserManager():
user_id = self.add_user(username)
return web.json_response(user_id)
@routes.get("/userdata/{file}")
async def getuserdata(request):
file = request.match_info.get("file", None)
if not file:
@routes.get("/userdata")
async def listuserdata(request):
directory = request.rel_url.query.get('dir', '')
if not directory:
return web.Response(status=400)
path = self.get_request_user_filepath(request, file)
path = self.get_request_user_filepath(request, directory)
if not path:
return web.Response(status=403)
if not os.path.exists(path):
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)]
@routes.post("/userdata/{file}")
async def post_userdata(request):
file = request.match_info.get("file", None)
split_path = request.rel_url.query.get('split', '').lower() == "true"
if split_path:
results = [[x] + x.split(os.sep) for x in results]
return web.json_response(results)
def get_user_data_path(request, check_exists = False, param = "file"):
file = request.match_info.get(param, None)
if not file:
return web.Response(status=400)
......@@ -133,8 +143,63 @@ class UserManager():
if not path:
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()
with open(path, "wb") as f:
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) => {
userData[file] = data;
}),
listUserData: jest.fn(() => [])
};
jest.mock("../../web/scripts/api", () => ({
get api() {
......
......@@ -63,6 +63,10 @@ const colorPalettes = {
"border-color": "#4e4e4e",
"tr-even-bg-color": "#222",
"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",
"tr-even-bg-color": "#f9f9f9",
"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
"tr-even-bg-color": "#002b36",
"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",
"border-color": "#6e7581",
"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",
"border-color": "#545d70",
"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",
"border-color": "#30363d",
"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"
}
},
}
......
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;
}
\ No newline at end of file
......@@ -5,6 +5,7 @@
<title>ComfyUI</title>
<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/materialdesignicons.min.css" />
<link rel="stylesheet" type="text/css" href="./style.css" />
<link rel="stylesheet" type="text/css" href="./user.css" />
<script type="text/javascript" src="./lib/litegraph.core.js"></script>
......
......@@ -4,7 +4,9 @@
"paths": {
"/*": ["./*"]
},
"lib": ["DOM", "ES2022"]
"lib": ["DOM", "ES2022", "DOM.Iterable"],
"target": "ES2015",
"module": "es2020"
},
"include": ["."]
}
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -394,7 +394,7 @@ class ComfyApi extends EventTarget {
* Gets a user data file for the current user
* @param { string } file The name of the userdata file to load
* @param { RequestInit } [options]
* @returns { Promise<unknown> } The fetch response object
* @returns { Promise<Response> } The fetch response object
*/
async getUserData(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
* @param { string } file The name of the userdata file to save
* @param { unknown } data The data to save to the file
* @param { RequestInit & { stringify?: boolean, throwOnError?: boolean } } [options]
* @returns { Promise<void> }
* @param { RequestInit & { overwrite?: boolean, stringify?: boolean, throwOnError?: boolean } } [options]
* @returns { Promise<Response> }
*/
async storeUserData(file, data, options = { stringify: true, throwOnError: true }) {
const resp = await this.fetchApi(`/userdata/${encodeURIComponent(file)}`, {
async storeUserData(file, data, options = { overwrite: true, stringify: true, throwOnError: true }) {
const resp = await this.fetchApi(`/userdata/${encodeURIComponent(file)}?overwrite=${options?.overwrite}`, {
method: "POST",
body: options?.stringify ? JSON.stringify(data) : data,
...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}`);
}
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 { getPngMetadata, getWebpMetadata, importA1111, getLatentMetadata } from "./pnginfo.js";
import { addDomClippingSetting } from "./domWidget.js";
import { createImageHost, calculateImageGrid } from "./ui/imagePreview.js"
export const ANIM_PREVIEW_WIDGET = "$$comfy_animation_preview"
import { createImageHost, calculateImageGrid } from "./ui/imagePreview.js";
import { ComfyAppMenu } from "./ui/menu/index.js";
import { getStorageValue, setStorageValue } from "./utils.js";
import { ComfyWorkflowManager } from "./workflows.js";
export const ANIM_PREVIEW_WIDGET = "$$comfy_animation_preview";
function sanitizeNodeName(string) {
let entityMap = {
......@@ -52,6 +54,12 @@ export class ComfyApp {
constructor() {
this.ui = new ComfyUI(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
......@@ -1313,11 +1321,15 @@ export class ComfyApp {
});
api.addEventListener("progress", ({ detail }) => {
if (this.workflowManager.activePrompt?.workflow
&& this.workflowManager.activePrompt.workflow !== this.workflowManager.activeWorkflow) return;
this.progress = detail;
this.graph.setDirtyCanvas(true, false);
});
api.addEventListener("executing", ({ detail }) => {
if (this.workflowManager.activePrompt ?.workflow
&& this.workflowManager.activePrompt.workflow !== this.workflowManager.activeWorkflow) return;
this.progress = null;
this.runningNodeId = detail;
this.graph.setDirtyCanvas(true, false);
......@@ -1325,6 +1337,8 @@ export class ComfyApp {
});
api.addEventListener("executed", ({ detail }) => {
if (this.workflowManager.activePrompt ?.workflow
&& this.workflowManager.activePrompt.workflow !== this.workflowManager.activeWorkflow) return;
const output = this.nodeOutputs[detail.node];
if (detail.merge && output) {
for (const k in detail.output ?? {}) {
......@@ -1433,6 +1447,11 @@ export class ComfyApp {
});
await Promise.all(extensionPromises);
try {
this.menu.workflows.registerExtension(this);
} catch (error) {
console.error(error);
}
}
async #migrateSettings() {
......@@ -1520,15 +1539,17 @@ export class ComfyApp {
*/
async setup() {
await this.#setUser();
await this.ui.settings.load();
await this.#loadExtensions();
// Create and mount the LiteGraph in the DOM
const mainCanvas = document.createElement("canvas")
mainCanvas.style.touchAction = "none"
const canvasEl = (this.canvasEl = Object.assign(mainCanvas, { id: "graph-canvas" }));
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();
this.#addProcessMouseHandler();
......@@ -1541,7 +1562,7 @@ export class ComfyApp {
this.#addAfterConfigureHandler();
const canvas = (this.canvas = new LGraphCanvas(canvasEl, this.graph));
this.canvas = new LGraphCanvas(canvasEl, this.graph);
this.ctx = canvasEl.getContext("2d");
LiteGraph.release_link_on_empty_shows_menu = true;
......@@ -1549,19 +1570,14 @@ export class ComfyApp {
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
resizeCanvas();
window.addEventListener("resize", resizeCanvas);
this.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.registerNodes();
......@@ -1573,7 +1589,8 @@ export class ComfyApp {
const loadWorkflow = async (json) => {
if (json) {
const workflow = JSON.parse(json);
await this.loadGraphData(workflow);
const workflowName = getStorageValue("Comfy.PreviousWorkflow");
await this.loadGraphData(workflow, true, workflowName);
return true;
}
};
......@@ -1609,6 +1626,19 @@ export class ComfyApp {
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
*/
......@@ -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
* @param {*} graphData A serialized graph object
* @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) {
this.clean();
}
......@@ -1819,6 +1866,12 @@ export class ComfyApp {
graphData = structuredClone(graphData);
}
try {
this.workflowManager.setWorkflow(workflow);
} catch (error) {
console.error(error);
}
const missingNodeTypes = [];
await this.#invokeExtensionsAsync("beforeConfigureGraph", graphData, missingNodeTypes);
for (let n of graphData.nodes) {
......@@ -1840,6 +1893,11 @@ export class ComfyApp {
this.canvas.ds.offset = graphData.extra.ds.offset;
this.canvas.ds.scale = graphData.extra.ds.scale;
}
try {
this.workflowManager.activeWorkflow?.track()
} catch (error) {
}
} catch (error) {
let errorHint = [];
// Try extracting filename to see if it was caused by an extension script
......@@ -1927,14 +1985,17 @@ export class ComfyApp {
this.showMissingNodesError(missingNodeTypes);
}
await this.#invokeExtensionsAsync("afterConfigureGraph", missingNodeTypes);
requestAnimationFrame(() => {
this.graph.setDirtyCanvas(true, true);
});
}
/**
* Converts the current graph workflow for sending to the API
* @returns The workflow and node links
*/
async graphToPrompt() {
for (const outerNode of this.graph.computeExecutionOrder(false)) {
async graphToPrompt(graph = this.graph, clean = true) {
for (const outerNode of graph.computeExecutionOrder(false)) {
if (outerNode.widgets) {
for (const widget of outerNode.widgets) {
// 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 = {};
// 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 innerNodes = (!skipNode && outerNode.getInnerNodes) ? outerNode.getInnerNodes() : [outerNode];
for (const node of innerNodes) {
......@@ -2049,7 +2110,7 @@ export class ComfyApp {
}
// Remove inputs connected to removed nodes
if(clean) {
for (const o in output) {
for (const i in output[o].inputs) {
if (Array.isArray(output[o].inputs[i])
......@@ -2059,6 +2120,7 @@ export class ComfyApp {
}
}
}
}
return { workflow, output };
}
......@@ -2123,6 +2185,14 @@ export class ComfyApp {
this.lastNodeErrors = res.node_errors;
if (this.lastNodeErrors.length > 0) {
this.canvas.draw(true, true);
} else {
try {
this.workflowManager.storePrompt({
id: res.prompt_id,
nodes: Object.keys(p.output)
});
} catch (error) {
}
}
} catch (error) {
const formattedError = this.#formatPromptError(error)
......@@ -2155,6 +2225,7 @@ export class ComfyApp {
this.#processingQueue = false;
}
api.dispatchEvent(new CustomEvent("promptQueued", { detail: { number, batchCount } }));
return !this.lastNodeErrors;
}
showErrorOnFileLoad(file) {
......@@ -2170,14 +2241,24 @@ export class ComfyApp {
* @param {File} 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") {
const pngInfo = await getPngMetadata(file);
if (pngInfo?.workflow) {
await this.loadGraphData(JSON.parse(pngInfo.workflow));
await this.loadGraphData(JSON.parse(pngInfo.workflow), true, true, fileName);
} else if (pngInfo?.prompt) {
this.loadApiJson(JSON.parse(pngInfo.prompt));
this.loadApiJson(JSON.parse(pngInfo.prompt), fileName);
} else if (pngInfo?.parameters) {
this.changeWorkflow(() => {
importA1111(this.graph, pngInfo.parameters);
}, fileName)
} else {
this.showErrorOnFileLoad(file);
}
......@@ -2188,9 +2269,9 @@ export class ComfyApp {
const prompt = pngInfo?.prompt || pngInfo?.Prompt;
if (workflow) {
this.loadGraphData(JSON.parse(workflow));
this.loadGraphData(JSON.parse(workflow), true, true, fileName);
} else if (prompt) {
this.loadApiJson(JSON.parse(prompt));
this.loadApiJson(JSON.parse(prompt), fileName);
} else {
this.showErrorOnFileLoad(file);
}
......@@ -2201,16 +2282,16 @@ export class ComfyApp {
if (jsonContent?.templates) {
this.loadTemplateData(jsonContent);
} else if(this.isApiJson(jsonContent)) {
this.loadApiJson(jsonContent);
this.loadApiJson(jsonContent, fileName);
} else {
await this.loadGraphData(jsonContent);
await this.loadGraphData(jsonContent, true, fileName);
}
};
reader.readAsText(file);
} else if (file.name?.endsWith(".latent") || file.name?.endsWith(".safetensors")) {
const info = await getLatentMetadata(file);
if (info.workflow) {
await this.loadGraphData(JSON.parse(info.workflow));
await this.loadGraphData(JSON.parse(info.workflow), true, fileName);
} else if (info.prompt) {
this.loadApiJson(JSON.parse(info.prompt));
} else {
......@@ -2225,7 +2306,7 @@ export class ComfyApp {
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]);
if (missingNodeTypes.length) {
this.showMissingNodesError(missingNodeTypes.map(t => t.class_type), false);
......@@ -2240,8 +2321,10 @@ export class ComfyApp {
node.id = isNaN(+id) ? id : +id;
node.title = data._meta?.title ?? node.title
app.graph.add(node);
graph.add(node);
}
this.changeWorkflow(() => {
for (const id of ids) {
const data = apiData[id];
const node = app.graph.getNodeById(id);
......@@ -2272,8 +2355,8 @@ export class ComfyApp {
}
}
}
app.graph.arrange();
}, fileName);
}
/**
......
// @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({});
\ No newline at end of file
......@@ -34,8 +34,8 @@ function getClipPath(node, element) {
}
const widgetRect = element.getBoundingClientRect();
const clipX = intersection[0] - widgetRect.x / scale + "px";
const clipY = intersection[1] - widgetRect.y / scale + "px";
const clipX = elRect.left + intersection[0] - widgetRect.x / scale + "px";
const clipY = elRect.top + intersection[1] - widgetRect.y / scale + "px";
const clipWidth = intersection[2] + "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%)`;
......@@ -210,6 +210,8 @@ LGraphNode.prototype.addDOMWidget = function (name, type, element, options) {
if (!element.parentElement) {
document.body.append(element);
}
element.hidden = true;
element.style.display = "none";
let mouseDownHandler;
if (element.blur) {
......@@ -254,15 +256,15 @@ LGraphNode.prototype.addDOMWidget = function (name, type, element, options) {
const transform = new DOMMatrix()
.scaleSelf(elRect.width / ctx.canvas.width, elRect.height / ctx.canvas.height)
.multiplySelf(ctx.getTransform())
.translateSelf(margin, margin + y);
.translateSelf(margin, margin + y );
const scale = new DOMMatrix().scaleSelf(transform.a, transform.d);
Object.assign(element.style, {
transformOrigin: "0 0",
transform: scale,
left: `${transform.a + transform.e}px`,
top: `${transform.d + transform.f}px`,
left: `${transform.a + transform.e + elRect.left}px`,
top: `${transform.d + transform.f + elRect.top}px`,
width: `${widgetWidth - margin * 2}px`,
height: `${(widget.computedHeight ?? 50) - margin * 2}px`,
position: "absolute",
......
......@@ -6,17 +6,22 @@ import { ComfySettingsDialog } from "./ui/settings.js";
export const ComfyDialog = _ComfyDialog;
/**
*
* @param { string } tag HTML Element Tag and optional classes e.g. div.class1.class2
* @param { string | Element | Element[] | {
* @template { string | (keyof HTMLElementTagNameMap) } K
* @typedef { K extends keyof HTMLElementTagNameMap ? HTMLElementTagNameMap[K] : HTMLElement } ElementType
*/
/**
* @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,
* $?: (el: Element) => void,
* $?: (el: ElementType<K>) => void,
* dataset?: DOMStringMap,
* style?: CSSStyleDeclaration,
* style?: Partial<CSSStyleDeclaration>,
* for?: string
* } | undefined } propsOrChildren
* @param { Element[] | undefined } [children]
* @returns
* } & Omit<Partial<ElementType<K>>, "style">) | undefined } [propsOrChildren]
* @param { string | Element | Element[] | undefined } [children]
* @returns { ElementType<K> }
*/
export function $el(tag, propsOrChildren, children) {
const split = tag.split(".");
......@@ -54,7 +59,7 @@ export function $el(tag, propsOrChildren, children) {
Object.assign(element, propsOrChildren);
if (children) {
element.append(...(children instanceof Array ? children : [children]));
element.append(...(children instanceof Array ? children.filter(Boolean) : [children]));
}
if (parent) {
......@@ -102,6 +107,8 @@ function dragElement(dragEl, settings) {
}
function positionElement() {
if(dragEl.style.display === "none") return;
const halfWidth = document.body.clientWidth / 2;
const anchorRight = newPosX + dragEl.clientWidth / 2 > halfWidth;
......@@ -191,6 +198,8 @@ function dragElement(dragEl, settings) {
document.onmouseup = null;
document.onmousemove = null;
}
return restorePos;
}
class ComfyList {
......@@ -372,6 +381,8 @@ export class ComfyUI {
},
});
this.loadFile = () => fileInput.click();
const autoQueueModeEl = toggleSwitch(
"autoQueueMode",
[
......@@ -592,6 +603,7 @@ export class ComfyUI {
onclick: () => app.refreshComboInNodes()
}),
$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", {
id: "comfy-clear-button", textContent: "Clear", onclick: () => {
if (!confirmClear.value || confirm("Clear workflow?")) {
......@@ -621,10 +633,10 @@ export class ComfyUI {
name: "Enable Dev mode Options",
type: "boolean",
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"}});
}
......
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;
}
}
// @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;
}
}
// @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));
}
}
// @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");
}
};
}
// @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));
}
}
import { $el } from "../ui.js";
export class ComfyDialog {
constructor() {
this.element = $el("div.comfy-modal", { parent: document.body }, [
export class ComfyDialog extends EventTarget {
#buttons;
constructor(type = "div", buttons = null) {
super();
this.#buttons = buttons;
this.element = $el(type + ".comfy-modal", { parent: document.body }, [
$el("div.comfy-modal-content", [$el("p", { $: (p) => (this.textElement = p) }), ...this.createButtons()]),
]);
}
createButtons() {
return [
return (
this.#buttons ?? [
$el("button", {
type: "button",
textContent: "Close",
onclick: () => this.close(),
}),
];
]
);
}
close() {
......@@ -25,7 +31,7 @@ export class ComfyDialog {
if (typeof html === "string") {
this.textElement.innerHTML = html;
} else {
this.textElement.replaceChildren(html);
this.textElement.replaceChildren(...(html instanceof Array ? html : [html]));
}
this.element.style.display = "flex";
}
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment