ComfyUI/web/scripts/ui.js

635 lines
16 KiB
JavaScript
Raw Normal View History

import { api } from "./api.js";
2023-03-28 02:36:53 +00:00
export function $el(tag, propsOrChildren, children) {
2023-03-03 15:20:49 +00:00
const split = tag.split(".");
const element = document.createElement(split.shift());
element.classList.add(...split);
if (propsOrChildren) {
if (Array.isArray(propsOrChildren)) {
element.append(...propsOrChildren);
} else {
const { parent, $: cb, dataset, style } = propsOrChildren;
2023-03-03 15:20:49 +00:00
delete propsOrChildren.parent;
delete propsOrChildren.$;
delete propsOrChildren.dataset;
delete propsOrChildren.style;
2023-03-03 15:20:49 +00:00
if (style) {
Object.assign(element.style, style);
}
if (dataset) {
Object.assign(element.dataset, dataset);
}
2023-03-03 15:20:49 +00:00
Object.assign(element, propsOrChildren);
if (children) {
element.append(...children);
}
if (parent) {
parent.append(element);
}
if (cb) {
cb(element);
}
}
}
return element;
}
function dragElement(dragEl, settings) {
var posDiffX = 0,
posDiffY = 0,
posStartX = 0,
posStartY = 0,
newPosX = 0,
newPosY = 0;
if (dragEl.getElementsByClassName("drag-handle")[0]) {
2023-03-25 23:23:46 +00:00
// if present, the handle is where you move the DIV from:
dragEl.getElementsByClassName("drag-handle")[0].onmousedown = dragMouseDown;
2023-03-25 23:23:46 +00:00
} else {
// otherwise, move the DIV from anywhere inside the DIV:
dragEl.onmousedown = dragMouseDown;
}
2023-03-30 19:14:01 +00:00
// When the element resizes (e.g. view queue) ensure it is still in the windows bounds
const resizeObserver = new ResizeObserver(() => {
ensureInBounds();
}).observe(dragEl);
function ensureInBounds() {
2023-03-30 19:14:01 +00:00
if (dragEl.classList.contains("comfy-menu-manual-pos")) {
2023-03-30 19:15:12 +00:00
newPosX = Math.min(document.body.clientWidth - dragEl.clientWidth, Math.max(0, dragEl.offsetLeft));
newPosY = Math.min(document.body.clientHeight - dragEl.clientHeight, Math.max(0, dragEl.offsetTop));
2023-03-30 19:15:12 +00:00
positionElement();
}
2023-03-30 19:14:01 +00:00
}
function positionElement() {
const halfWidth = document.body.clientWidth / 2;
const anchorRight = newPosX + dragEl.clientWidth / 2 > halfWidth;
// set the element's new position:
if (anchorRight) {
dragEl.style.left = "unset";
dragEl.style.right = document.body.clientWidth - newPosX - dragEl.clientWidth + "px";
} else {
dragEl.style.left = newPosX + "px";
dragEl.style.right = "unset";
}
2023-03-30 19:15:48 +00:00
dragEl.style.top = newPosY + "px";
dragEl.style.bottom = "unset";
if (savePos) {
localStorage.setItem(
"Comfy.MenuPosition",
JSON.stringify({
2023-03-30 19:14:01 +00:00
x: dragEl.offsetLeft,
y: dragEl.offsetTop,
})
);
}
}
function restorePos() {
let pos = localStorage.getItem("Comfy.MenuPosition");
if (pos) {
pos = JSON.parse(pos);
2023-03-30 19:14:01 +00:00
newPosX = pos.x;
newPosY = pos.y;
positionElement();
ensureInBounds();
}
}
let savePos = undefined;
settings.addSetting({
id: "Comfy.MenuPosition",
name: "Save menu position",
type: "boolean",
defaultValue: savePos,
onChange(value) {
if (savePos === undefined && value) {
restorePos();
}
savePos = value;
},
});
2023-03-25 23:23:46 +00:00
function dragMouseDown(e) {
e = e || window.event;
e.preventDefault();
// get the mouse cursor position at startup:
posStartX = e.clientX;
posStartY = e.clientY;
2023-03-25 23:23:46 +00:00
document.onmouseup = closeDragElement;
// call a function whenever the cursor moves:
document.onmousemove = elementDrag;
}
function elementDrag(e) {
e = e || window.event;
e.preventDefault();
dragEl.classList.add("comfy-menu-manual-pos");
2023-03-25 23:23:46 +00:00
// calculate the new cursor position:
posDiffX = e.clientX - posStartX;
posDiffY = e.clientY - posStartY;
posStartX = e.clientX;
posStartY = e.clientY;
newPosX = Math.min(document.body.clientWidth - dragEl.clientWidth, Math.max(0, dragEl.offsetLeft + posDiffX));
newPosY = Math.min(document.body.clientHeight - dragEl.clientHeight, Math.max(0, dragEl.offsetTop + posDiffY));
positionElement();
2023-03-25 23:23:46 +00:00
}
window.addEventListener("resize", () => {
ensureInBounds();
});
2023-03-25 23:23:46 +00:00
function closeDragElement() {
// stop moving when mouse button is released:
document.onmouseup = null;
document.onmousemove = null;
}
}
export class ComfyDialog {
constructor() {
2023-03-03 15:20:49 +00:00
this.element = $el("div.comfy-modal", { parent: document.body }, [
$el("div.comfy-modal-content", [$el("p", { $: (p) => (this.textElement = p) }), ...this.createButtons()]),
2023-03-03 15:20:49 +00:00
]);
}
createButtons() {
return [
$el("button", {
type: "button",
textContent: "Close",
onclick: () => this.close(),
}),
];
}
close() {
this.element.style.display = "none";
}
show(html) {
if (typeof html === "string") {
this.textElement.innerHTML = html;
} else {
this.textElement.replaceChildren(html);
}
this.element.style.display = "flex";
}
}
class ComfySettingsDialog extends ComfyDialog {
constructor() {
super();
this.element.classList.add("comfy-settings");
this.settings = [];
}
2023-03-28 02:36:53 +00:00
getSettingValue(id, defaultValue) {
const settingId = "Comfy.Settings." + id;
const v = localStorage[settingId];
return v == null ? defaultValue : JSON.parse(v);
}
setSettingValue(id, value) {
const settingId = "Comfy.Settings." + id;
localStorage[settingId] = JSON.stringify(value);
}
addSetting({ id, name, type, defaultValue, onChange, attrs = {}, tooltip = "", }) {
if (!id) {
throw new Error("Settings must have an ID");
}
if (this.settings.find((s) => s.id === id)) {
throw new Error("Setting IDs must be unique");
}
const settingId = "Comfy.Settings." + id;
const v = localStorage[settingId];
let value = v == null ? defaultValue : JSON.parse(v);
// Trigger initial setting of value
if (onChange) {
onChange(value, undefined);
}
this.settings.push({
render: () => {
const setter = (v) => {
if (onChange) {
onChange(v, value);
}
localStorage[settingId] = JSON.stringify(v);
value = v;
};
let element;
2023-04-05 21:56:41 +00:00
value = this.getSettingValue(id, defaultValue);
if (typeof type === "function") {
element = type(name, setter, value, attrs);
} else {
switch (type) {
case "boolean":
element = $el("div", [
$el("label", { textContent: name || id }, [
$el("input", {
type: "checkbox",
checked: !!value,
oninput: (e) => {
setter(e.target.checked);
},
...attrs
}),
]),
]);
break;
case "number":
element = $el("div", [
$el("label", { textContent: name || id }, [
$el("input", {
type,
value,
oninput: (e) => {
setter(e.target.value);
},
...attrs
}),
]),
]);
break;
2023-04-13 03:15:21 +00:00
case "slider":
2023-04-13 01:36:35 +00:00
element = $el("div", [
$el("label", { textContent: name }, [
$el("input", {
type: "range",
value,
oninput: (e) => {
setter(e.target.value);
2023-04-13 03:15:21 +00:00
e.target.nextElementSibling.value = e.target.value;
2023-04-13 01:36:35 +00:00
},
...attrs
}),
2023-04-13 03:15:21 +00:00
$el("input", {
2023-04-13 01:36:35 +00:00
type: "number",
value,
oninput: (e) => {
setter(e.target.value);
2023-04-13 03:15:21 +00:00
e.target.previousElementSibling.value = e.target.value;
2023-04-13 01:36:35 +00:00
},
...attrs
}),
]),
]);
break;
default:
console.warn("Unsupported setting type, defaulting to text");
element = $el("div", [
$el("label", { textContent: name || id }, [
$el("input", {
value,
oninput: (e) => {
setter(e.target.value);
},
...attrs
}),
]),
]);
break;
}
}
if(tooltip) {
element.title = tooltip;
}
return element;
},
});
2023-04-05 21:56:41 +00:00
const self = this;
return {
get value() {
return self.getSettingValue(id, defaultValue);
},
set value(v) {
self.setSettingValue(id, v);
},
};
}
show() {
super.show();
Object.assign(this.textElement.style, {
display: "flex",
flexDirection: "column",
gap: "10px"
});
this.textElement.replaceChildren(...this.settings.map((s) => s.render()));
}
}
class ComfyList {
2023-03-03 15:20:49 +00:00
#type;
#text;
constructor(text, type) {
this.#text = text;
this.#type = type || text.toLowerCase();
this.element = $el("div.comfy-list");
this.element.style.display = "none";
}
get visible() {
return this.element.style.display !== "none";
}
async load() {
2023-03-03 15:20:49 +00:00
const items = await api.getItems(this.#type);
this.element.replaceChildren(
...Object.keys(items).flatMap((section) => [
$el("h4", {
textContent: section,
}),
$el("div.comfy-list-items", [
...items[section].map((item) => {
// Allow items to specify a custom remove action (e.g. for interrupt current prompt)
const removeAction = item.remove || {
name: "Delete",
cb: () => api.deleteItem(this.#type, item.prompt[1]),
};
return $el("div", { textContent: item.prompt[0] + ": " }, [
$el("button", {
textContent: "Load",
onclick: () => {
2023-03-31 17:04:53 +00:00
app.loadGraphData(item.prompt[3].extra_pnginfo.workflow);
2023-03-03 15:20:49 +00:00
if (item.outputs) {
app.nodeOutputs = item.outputs;
}
},
}),
$el("button", {
textContent: removeAction.name,
onclick: async () => {
await removeAction.cb();
await this.update();
},
}),
]);
}),
]),
]),
$el("div.comfy-list-actions", [
$el("button", {
textContent: "Clear " + this.#text,
onclick: async () => {
await api.clearItems(this.#type);
await this.load();
},
}),
$el("button", { textContent: "Refresh", onclick: () => this.load() }),
])
);
}
async update() {
if (this.visible) {
await this.load();
}
}
async show() {
this.element.style.display = "block";
2023-03-03 15:20:49 +00:00
this.button.textContent = "Close";
await this.load();
}
hide() {
this.element.style.display = "none";
2023-03-03 15:20:49 +00:00
this.button.textContent = "See " + this.#text;
}
toggle() {
if (this.visible) {
this.hide();
return false;
} else {
this.show();
return true;
}
}
}
export class ComfyUI {
constructor(app) {
this.app = app;
this.dialog = new ComfyDialog();
this.settings = new ComfySettingsDialog();
2023-03-10 09:38:35 +00:00
this.batchCount = 1;
this.lastQueueSize = 0;
2023-03-03 15:20:49 +00:00
this.queue = new ComfyList("Queue");
this.history = new ComfyList("History");
2023-03-03 15:20:49 +00:00
api.addEventListener("status", () => {
this.queue.update();
this.history.update();
});
2023-04-05 21:56:41 +00:00
const confirmClear = this.settings.addSetting({
id: "Comfy.ConfirmClear",
name: "Require confirmation when clearing workflow",
type: "boolean",
defaultValue: true,
});
const promptFilename = this.settings.addSetting({
id: "Comfy.PromptFilename",
name: "Prompt for filename when saving workflow",
type: "boolean",
defaultValue: true,
});
/**
* file format for preview
*
2023-06-05 05:38:32 +00:00
* format;quality
*
* ex)
2023-06-05 05:38:32 +00:00
* webp;50 -> webp, quality 50
* jpeg;80 -> rgb, jpeg, quality 80
*
* @type {string}
*/
const previewImage = this.settings.addSetting({
id: "Comfy.PreviewFormat",
name: "When displaying a preview in the image widget, convert it to a lightweight image. (webp, jpeg, webp;50, ...)",
type: "string",
defaultValue: "",
});
2023-03-03 15:27:08 +00:00
const fileInput = $el("input", {
id: "comfy-file-input",
2023-03-03 15:27:08 +00:00
type: "file",
2023-05-18 06:41:21 +00:00
accept: ".json,image/png,.latent",
style: { display: "none" },
2023-03-03 15:27:08 +00:00
parent: document.body,
onchange: () => {
app.handleFile(fileInput.files[0]);
},
2023-03-03 15:20:49 +00:00
});
2023-03-03 15:20:49 +00:00
this.menuContainer = $el("div.comfy-menu", { parent: document.body }, [
2023-04-05 04:03:22 +00:00
$el("div.drag-handle", { style: { overflow: "hidden", position: "relative", width: "100%", cursor: "default" } }, [
2023-03-25 23:23:46 +00:00
$el("span.drag-handle"),
$el("span", { $: (q) => (this.queueSize = q) }),
$el("button.comfy-settings-btn", { textContent: "⚙️", onclick: () => this.settings.show() }),
]),
$el("button.comfy-queue-btn", {
id: "queue-button",
textContent: "Queue Prompt",
onclick: () => app.queuePrompt(0, this.batchCount),
}),
2023-03-14 07:16:48 +00:00
$el("div", {}, [
$el("label", { innerHTML: "Extra options" }, [
$el("input", {
type: "checkbox",
onchange: (i) => {
document.getElementById("extraOptions").style.display = i.srcElement.checked ? "block" : "none";
this.batchCount = i.srcElement.checked ? document.getElementById("batchCountInputRange").value : 1;
document.getElementById("autoQueueCheckbox").checked = false;
},
}),
]),
2023-03-14 07:16:48 +00:00
]),
$el("div", { id: "extraOptions", style: { width: "100%", display: "none" } }, [
2023-03-09 17:02:03 +00:00
$el("label", { innerHTML: "Batch count" }, [
$el("input", {
id: "batchCountInputNumber",
type: "number",
value: this.batchCount,
min: "1",
style: { width: "35%", "margin-left": "0.4em" },
oninput: (i) => {
2023-03-10 09:38:35 +00:00
this.batchCount = i.target.value;
document.getElementById("batchCountInputRange").value = this.batchCount;
},
2023-03-10 09:38:35 +00:00
}),
$el("input", {
id: "batchCountInputRange",
type: "range",
min: "1",
max: "100",
value: this.batchCount,
2023-03-10 09:38:35 +00:00
oninput: (i) => {
this.batchCount = i.srcElement.value;
document.getElementById("batchCountInputNumber").value = i.srcElement.value;
},
}),
$el("input", {
id: "autoQueueCheckbox",
type: "checkbox",
checked: false,
title: "automatically queue prompt when the queue size hits 0",
2023-03-10 09:38:35 +00:00
}),
2023-03-09 17:02:03 +00:00
]),
]),
2023-03-03 15:20:49 +00:00
$el("div.comfy-menu-btns", [
$el("button", { id: "queue-front-button", textContent: "Queue Front", onclick: () => app.queuePrompt(-1, this.batchCount) }),
2023-03-03 15:20:49 +00:00
$el("button", {
$: (b) => (this.queue.button = b),
id: "comfy-view-queue-button",
2023-03-03 15:20:49 +00:00
textContent: "View Queue",
onclick: () => {
this.history.hide();
this.queue.toggle();
},
}),
$el("button", {
$: (b) => (this.history.button = b),
id: "comfy-view-history-button",
2023-03-03 15:20:49 +00:00
textContent: "View History",
onclick: () => {
this.queue.hide();
this.history.toggle();
},
}),
]),
this.queue.element,
this.history.element,
$el("button", {
id: "comfy-save-button",
2023-03-03 15:20:49 +00:00
textContent: "Save",
onclick: () => {
let filename = "workflow.json";
if (promptFilename.value) {
filename = prompt("Save workflow as:", filename);
if (!filename) return;
if (!filename.toLowerCase().endsWith(".json")) {
filename += ".json";
}
}
2023-03-20 06:23:47 +00:00
const json = JSON.stringify(app.graph.serialize(), null, 2); // convert the data to a JSON string
2023-03-03 15:20:49 +00:00
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = $el("a", {
href: url,
download: filename,
style: { display: "none" },
2023-03-03 15:20:49 +00:00
parent: document.body,
});
a.click();
setTimeout(function () {
a.remove();
window.URL.revokeObjectURL(url);
}, 0);
},
}),
$el("button", { id: "comfy-load-button", textContent: "Load", onclick: () => fileInput.click() }),
$el("button", { id: "comfy-refresh-button", textContent: "Refresh", onclick: () => app.refreshComboInNodes() }),
Clipspace Menu and MaskEditor application. (#548) * Add clipspace feature. * feat: copy content to clipspace * feat: paste content from clipspace Extend validation to allow for validating annotated_path in addition to other parameters. Add support for annotated_filepath in folder_paths function. Generalize the '/upload/image' API to allow for uploading images to the 'input', 'temp', or 'output' directories. * rename contentClipboard -> clipspace * Do deep copy for imgs on copy to clipspace. * mask painting on clipspace * add original_imgs into clipspace * Preserve the original image when 'imgs' are modified * robust patch & refactoring folder_paths about annotated_filepath * wip * Only show the Paste menu if the ComfyApp.clipspace is not empty * clipspace feature added maskeditor feature added * instant refresh on paste force triggering 'changed' on paste action * enhance mask painting smooth drawing add brush_size +/- button * robust patch use mouseup event * robust patch again... * subfolder fix on paste logic attach subfolder if subfolder isn't empty * event listener patch add ], [ key event for brush size remove listener on close * Fix button positioning issue related to window height. Change brush size from button to slider. * clean commit * clean code * various bug fixes * paste action - prevent opening upload popup - ensure rendering after widget_value update * view api update - support annotated_filepath * maskeditor layout - prevent covering button by hidden div * remove dbg message * Add cursor functionality to display brush size * refactor: Replace brush preview feature with missionfloyd implementation * missionfloyd implementation * hiding brush preview off the canvas * change brush size on wheel event * keyup -> keydown event * Update web/extensions/core/maskeditor.js Co-authored-by: missionfloyd <missionfloyd@users.noreply.github.com> * Add support for channel-specific image data retrieval in /view API to fix alpha mask loading issue When loading an image with an alpha mask in JavaScript canvas, there is an issue where the alpha and RGB channels are premultiplied. To avoid reliance on JavaScript canvas, I added support for channel-specific image data retrieval in the "/view" API. This allows us to retrieve data for each channel separately and fix the alpha mask loading issue. The changes have been committed to the repository. * Enable brush preview for key and slider events * optimize * preview fix * robust patch * fix copy (clipspace) action imgs[0] copy -> whole imgs copy * support batch images on clipspace, maskeditor * copy/paste bug fixes for batch images enhance selector preview on clipspace menu add img_paste_mode option into clipspace menu * crash fix * print message if clipspace content cannot editable * Update web/extensions/core/maskeditor.js Co-authored-by: missionfloyd <missionfloyd@users.noreply.github.com> * make default img_paste_mode to 'selected' refactor space -> tab * save clipspace files to input/clipspace instead of temp * show "clipspace/filename.png" instead of 'filename.png [clipspace]' in LoadImage/LoadImageMask * refresh fix related to FILE_COMBO * Update web/extensions/core/maskeditor.js Co-authored-by: missionfloyd <missionfloyd@users.noreply.github.com> * Update web/extensions/core/maskeditor.js Co-authored-by: missionfloyd <missionfloyd@users.noreply.github.com> * adjust margin based on missionfloyd impelements * mouse event -> pointer event * pen, touch, mouse drawing patched and tested * Update web/extensions/core/maskeditor.js Co-authored-by: missionfloyd <missionfloyd@users.noreply.github.com> * add comment about touch event. --------- Co-authored-by: Lt.Dr.Data <lt.dr.data@gmail.com> Co-authored-by: missionfloyd <missionfloyd@users.noreply.github.com>
2023-05-08 18:37:36 +00:00
$el("button", { id: "comfy-clipspace-button", textContent: "Clipspace", onclick: () => app.openClipspace() }),
$el("button", { id: "comfy-clear-button", textContent: "Clear", onclick: () => {
2023-04-05 21:56:41 +00:00
if (!confirmClear.value || confirm("Clear workflow?")) {
2023-04-05 03:20:49 +00:00
app.clean();
app.graph.clear();
}
}}),
$el("button", { id: "comfy-load-default-button", textContent: "Load Default", onclick: () => {
2023-04-05 21:56:41 +00:00
if (!confirmClear.value || confirm("Load default workflow?")) {
2023-04-05 03:20:49 +00:00
app.loadGraphData()
}
}}),
2023-03-03 15:20:49 +00:00
]);
dragElement(this.menuContainer, this.settings);
2023-03-25 23:23:46 +00:00
this.setStatus({ exec_info: { queue_remaining: "X" } });
}
setStatus(status) {
this.queueSize.textContent = "Queue size: " + (status ? status.exec_info.queue_remaining : "ERR");
if (status) {
if (
this.lastQueueSize != 0 &&
status.exec_info.queue_remaining == 0 &&
document.getElementById("autoQueueCheckbox").checked
) {
app.queuePrompt(0, this.batchCount);
}
this.lastQueueSize = status.exec_info.queue_remaining;
}
}
}