ui.js 15 KB
Newer Older
1
2
3
4
5
import { api } from "./api.js";
import { ComfyDialog as _ComfyDialog } from "./ui/dialog.js";
import { ComfySettingsDialog } from "./ui/settings.js";

export const ComfyDialog = _ComfyDialog;
pythongosssss's avatar
pythongosssss committed
6

pythongosssss's avatar
pythongosssss committed
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
 * 
 * @param { string } tag HTML Element Tag and optional classes e.g. div.class1.class2
 * @param { string | Element | Element[] | {
 * 	 parent?: Element,
 *   $?: (el: Element) => void, 
 *   dataset?: DOMStringMap,
 *   style?: CSSStyleDeclaration,
 * 	 for?: string
 * } | undefined } propsOrChildren 
 * @param { Element[] | undefined } [children]
 * @returns 
 */
Jairo Correa's avatar
Jairo Correa committed
20
export function $el(tag, propsOrChildren, children) {
pythongosssss's avatar
pythongosssss committed
21
22
	const split = tag.split(".");
	const element = document.createElement(split.shift());
reaper47's avatar
reaper47 committed
23
24
25
26
	if (split.length > 0) {
		element.classList.add(...split);
	}

pythongosssss's avatar
pythongosssss committed
27
	if (propsOrChildren) {
pythongosssss's avatar
pythongosssss committed
28
29
30
31
32
		if (typeof propsOrChildren === "string") {
			propsOrChildren = { textContent: propsOrChildren };
		} else if (propsOrChildren instanceof Element) {
			propsOrChildren = [propsOrChildren];
		}
pythongosssss's avatar
pythongosssss committed
33
34
35
		if (Array.isArray(propsOrChildren)) {
			element.append(...propsOrChildren);
		} else {
reaper47's avatar
reaper47 committed
36
			const {parent, $: cb, dataset, style} = propsOrChildren;
pythongosssss's avatar
pythongosssss committed
37
38
			delete propsOrChildren.parent;
			delete propsOrChildren.$;
39
40
			delete propsOrChildren.dataset;
			delete propsOrChildren.style;
pythongosssss's avatar
pythongosssss committed
41

reaper47's avatar
reaper47 committed
42
43
44
45
			if (Object.hasOwn(propsOrChildren, "for")) {
				element.setAttribute("for", propsOrChildren.for)
			}

46
47
48
49
50
51
			if (style) {
				Object.assign(element.style, style);
			}

			if (dataset) {
				Object.assign(element.dataset, dataset);
pythongosssss's avatar
pythongosssss committed
52
53
			}

pythongosssss's avatar
pythongosssss committed
54
55
			Object.assign(element, propsOrChildren);
			if (children) {
pythongosssss's avatar
pythongosssss committed
56
				element.append(...(children instanceof Array ? children : [children]));
pythongosssss's avatar
pythongosssss committed
57
58
59
60
61
62
63
64
65
66
67
68
69
70
			}

			if (parent) {
				parent.append(element);
			}

			if (cb) {
				cb(element);
			}
		}
	}
	return element;
}

71
function dragElement(dragEl, settings) {
72
73
74
75
76
77
	var posDiffX = 0,
		posDiffY = 0,
		posStartX = 0,
		posStartY = 0,
		newPosX = 0,
		newPosY = 0;
78
	if (dragEl.getElementsByClassName("drag-handle")[0]) {
Jairo Correa's avatar
Jairo Correa committed
79
		// if present, the handle is where you move the DIV from:
80
		dragEl.getElementsByClassName("drag-handle")[0].onmousedown = dragMouseDown;
Jairo Correa's avatar
Jairo Correa committed
81
82
83
84
85
	} else {
		// otherwise, move the DIV from anywhere inside the DIV:
		dragEl.onmousedown = dragMouseDown;
	}

pythongosssss's avatar
pythongosssss committed
86
87
88
89
90
	// When the element resizes (e.g. view queue) ensure it is still in the windows bounds
	const resizeObserver = new ResizeObserver(() => {
		ensureInBounds();
	}).observe(dragEl);

91
	function ensureInBounds() {
pythongosssss's avatar
pythongosssss committed
92
		if (dragEl.classList.contains("comfy-menu-manual-pos")) {
pythongosssss's avatar
pythongosssss committed
93
94
			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));
95

pythongosssss's avatar
pythongosssss committed
96
97
			positionElement();
		}
pythongosssss's avatar
pythongosssss committed
98
	}
99
100
101
102
103
104
105
106
107
108
109
110
111

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

pythongosssss's avatar
pythongosssss committed
113
114
		dragEl.style.top = newPosY + "px";
		dragEl.style.bottom = "unset";
115
116
117
118
119

		if (savePos) {
			localStorage.setItem(
				"Comfy.MenuPosition",
				JSON.stringify({
pythongosssss's avatar
pythongosssss committed
120
121
					x: dragEl.offsetLeft,
					y: dragEl.offsetTop,
122
123
124
125
126
127
128
129
130
				})
			);
		}
	}

	function restorePos() {
		let pos = localStorage.getItem("Comfy.MenuPosition");
		if (pos) {
			pos = JSON.parse(pos);
pythongosssss's avatar
pythongosssss committed
131
132
133
			newPosX = pos.x;
			newPosY = pos.y;
			positionElement();
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
			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;
		},
	});
reaper47's avatar
reaper47 committed
151

Jairo Correa's avatar
Jairo Correa committed
152
153
154
155
	function dragMouseDown(e) {
		e = e || window.event;
		e.preventDefault();
		// get the mouse cursor position at startup:
156
157
		posStartX = e.clientX;
		posStartY = e.clientY;
Jairo Correa's avatar
Jairo Correa committed
158
159
160
161
162
163
164
165
		document.onmouseup = closeDragElement;
		// call a function whenever the cursor moves:
		document.onmousemove = elementDrag;
	}

	function elementDrag(e) {
		e = e || window.event;
		e.preventDefault();
166
167
168

		dragEl.classList.add("comfy-menu-manual-pos");

Jairo Correa's avatar
Jairo Correa committed
169
		// calculate the new cursor position:
170
171
172
173
		posDiffX = e.clientX - posStartX;
		posDiffY = e.clientY - posStartY;
		posStartX = e.clientX;
		posStartY = e.clientY;
174
175
176
177
178

		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();
Jairo Correa's avatar
Jairo Correa committed
179
180
	}

181
	window.addEventListener("resize", () => {
182
		ensureInBounds();
183
184
	});

Jairo Correa's avatar
Jairo Correa committed
185
186
187
188
189
190
191
	function closeDragElement() {
		// stop moving when mouse button is released:
		document.onmouseup = null;
		document.onmousemove = null;
	}
}

pythongosssss's avatar
pythongosssss committed
192
class ComfyList {
pythongosssss's avatar
pythongosssss committed
193
194
	#type;
	#text;
195
	#reverse;
pythongosssss's avatar
pythongosssss committed
196

197
	constructor(text, type, reverse) {
pythongosssss's avatar
pythongosssss committed
198
199
		this.#text = text;
		this.#type = type || text.toLowerCase();
200
		this.#reverse = reverse || false;
pythongosssss's avatar
pythongosssss committed
201
		this.element = $el("div.comfy-list");
pythongosssss's avatar
pythongosssss committed
202
203
204
205
206
207
208
209
		this.element.style.display = "none";
	}

	get visible() {
		return this.element.style.display !== "none";
	}

	async load() {
pythongosssss's avatar
pythongosssss committed
210
211
212
213
214
215
216
		const items = await api.getItems(this.#type);
		this.element.replaceChildren(
			...Object.keys(items).flatMap((section) => [
				$el("h4", {
					textContent: section,
				}),
				$el("div.comfy-list-items", [
217
					...(this.#reverse ? items[section].reverse() : items[section]).map((item) => {
pythongosssss's avatar
pythongosssss committed
218
219
220
221
222
						// 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]),
						};
reaper47's avatar
reaper47 committed
223
						return $el("div", {textContent: item.prompt[0] + ": "}, [
pythongosssss's avatar
pythongosssss committed
224
225
							$el("button", {
								textContent: "Load",
pythongosssss's avatar
pythongosssss committed
226
227
								onclick: async () => {
									await app.loadGraphData(item.prompt[3].extra_pnginfo.workflow);
pythongosssss's avatar
pythongosssss committed
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
									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();
					},
				}),
reaper47's avatar
reaper47 committed
252
				$el("button", {textContent: "Refresh", onclick: () => this.load()}),
pythongosssss's avatar
pythongosssss committed
253
254
			])
		);
pythongosssss's avatar
pythongosssss committed
255
256
257
258
259
260
261
262
263
264
	}

	async update() {
		if (this.visible) {
			await this.load();
		}
	}

	async show() {
		this.element.style.display = "block";
pythongosssss's avatar
pythongosssss committed
265
266
		this.button.textContent = "Close";

pythongosssss's avatar
pythongosssss committed
267
268
269
270
271
		await this.load();
	}

	hide() {
		this.element.style.display = "none";
comfyanonymous's avatar
comfyanonymous committed
272
		this.button.textContent = "View " + this.#text;
pythongosssss's avatar
pythongosssss committed
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
	}

	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();
290
		this.settings = new ComfySettingsDialog(app);
pythongosssss's avatar
pythongosssss committed
291

m957ymj75urz's avatar
m957ymj75urz committed
292
		this.batchCount = 1;
comfyanonymous's avatar
comfyanonymous committed
293
		this.lastQueueSize = 0;
pythongosssss's avatar
pythongosssss committed
294
		this.queue = new ComfyList("Queue");
295
		this.history = new ComfyList("History", "history", true);
pythongosssss's avatar
pythongosssss committed
296

pythongosssss's avatar
pythongosssss committed
297
298
299
300
		api.addEventListener("status", () => {
			this.queue.update();
			this.history.update();
		});
pythongosssss's avatar
pythongosssss committed
301

302
303
304
305
306
307
		const confirmClear = this.settings.addSetting({
			id: "Comfy.ConfirmClear",
			name: "Require confirmation when clearing workflow",
			type: "boolean",
			defaultValue: true,
		});
308

309
310
311
312
313
314
315
		const promptFilename = this.settings.addSetting({
			id: "Comfy.PromptFilename",
			name: "Prompt for filename when saving workflow",
			type: "boolean",
			defaultValue: true,
		});

316
317
318
		/**
		 * file format for preview
		 *
319
		 * format;quality
320
321
		 *
		 * ex)
322
		 * webp;50 -> webp, quality 50
323
324
325
326
327
328
		 * jpeg;80 -> rgb, jpeg, quality 80
		 *
		 * @type {string}
		 */
		const previewImage = this.settings.addSetting({
			id: "Comfy.PreviewFormat",
reaper47's avatar
reaper47 committed
329
330
			name: "When displaying a preview in the image widget, convert it to a lightweight image, e.g. webp, jpeg, webp;50, etc.",
			type: "text",
331
332
333
			defaultValue: "",
		});

334
335
336
337
338
339
340
		this.settings.addSetting({
			id: "Comfy.DisableSliders",
			name: "Disable sliders.",
			type: "boolean",
			defaultValue: false,
		});

341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
		this.settings.addSetting({
			id: "Comfy.DisableFloatRounding",
			name: "Disable rounding floats (requires page reload).",
			type: "boolean",
			defaultValue: false,
		});

		this.settings.addSetting({
			id: "Comfy.FloatRoundingPrecision",
			name: "Decimal places [0 = auto] (requires page reload).",
			type: "slider",
			attrs: {
				min: 0,
				max: 6,
				step: 1,
			},
			defaultValue: 0,
		});

pythongosssss's avatar
pythongosssss committed
360
		const fileInput = $el("input", {
361
			id: "comfy-file-input",
pythongosssss's avatar
pythongosssss committed
362
			type: "file",
363
			accept: ".json,image/png,.latent,.safetensors,image/webp",
reaper47's avatar
reaper47 committed
364
			style: {display: "none"},
pythongosssss's avatar
pythongosssss committed
365
366
367
368
			parent: document.body,
			onchange: () => {
				app.handleFile(fileInput.files[0]);
			},
pythongosssss's avatar
pythongosssss committed
369
		});
pythongosssss's avatar
pythongosssss committed
370

reaper47's avatar
reaper47 committed
371
372
373
374
375
376
377
378
379
		this.menuContainer = $el("div.comfy-menu", {parent: document.body}, [
			$el("div.drag-handle", {
				style: {
					overflow: "hidden",
					position: "relative",
					width: "100%",
					cursor: "default"
				}
			}, [
Jairo Correa's avatar
Jairo Correa committed
380
				$el("span.drag-handle"),
reaper47's avatar
reaper47 committed
381
382
				$el("span", {$: (q) => (this.queueSize = q)}),
				$el("button.comfy-settings-btn", {textContent: "⚙️", onclick: () => this.settings.show()}),
pythongosssss's avatar
pythongosssss committed
383
			]),
384
			$el("button.comfy-queue-btn", {
385
				id: "queue-button",
386
387
388
				textContent: "Queue Prompt",
				onclick: () => app.queuePrompt(0, this.batchCount),
			}),
m957ymj75urz's avatar
m957ymj75urz committed
389
			$el("div", {}, [
reaper47's avatar
reaper47 committed
390
				$el("label", {innerHTML: "Extra options"}, [
391
392
393
394
395
396
397
398
399
					$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;
						},
					}),
				]),
m957ymj75urz's avatar
m957ymj75urz committed
400
			]),
reaper47's avatar
reaper47 committed
401
			$el("div", {id: "extraOptions", style: {width: "100%", display: "none"}}, [
402
403
404
				$el("div",[

					$el("label", {innerHTML: "Batch count"}),
405
406
407
408
409
					$el("input", {
						id: "batchCountInputNumber",
						type: "number",
						value: this.batchCount,
						min: "1",
reaper47's avatar
reaper47 committed
410
						style: {width: "35%", "margin-left": "0.4em"},
411
						oninput: (i) => {
m957ymj75urz's avatar
m957ymj75urz committed
412
							this.batchCount = i.target.value;
413
414
							document.getElementById("batchCountInputRange").value = this.batchCount;
						},
m957ymj75urz's avatar
m957ymj75urz committed
415
					}),
416
417
418
419
420
421
					$el("input", {
						id: "batchCountInputRange",
						type: "range",
						min: "1",
						max: "100",
						value: this.batchCount,
m957ymj75urz's avatar
m957ymj75urz committed
422
423
						oninput: (i) => {
							this.batchCount = i.srcElement.value;
424
425
							document.getElementById("batchCountInputNumber").value = i.srcElement.value;
						},
426
427
428
429
430
431
432
433
					}),		
				]),

				$el("div",[
					$el("label",{
						for:"autoQueueCheckbox",
						innerHTML: "Auto Queue"
						// textContent: "Auto Queue"
434
435
436
437
438
					}),
					$el("input", {
						id: "autoQueueCheckbox",
						type: "checkbox",
						checked: false,
439
440
						title: "Automatically queue prompt when the queue size hits 0",
						
m957ymj75urz's avatar
m957ymj75urz committed
441
					}),
442
				])
m957ymj75urz's avatar
m957ymj75urz committed
443
			]),
pythongosssss's avatar
pythongosssss committed
444
			$el("div.comfy-menu-btns", [
reaper47's avatar
reaper47 committed
445
446
447
448
449
				$el("button", {
					id: "queue-front-button",
					textContent: "Queue Front",
					onclick: () => app.queuePrompt(-1, this.batchCount)
				}),
pythongosssss's avatar
pythongosssss committed
450
451
				$el("button", {
					$: (b) => (this.queue.button = b),
452
					id: "comfy-view-queue-button",
pythongosssss's avatar
pythongosssss committed
453
454
455
456
457
458
459
460
					textContent: "View Queue",
					onclick: () => {
						this.history.hide();
						this.queue.toggle();
					},
				}),
				$el("button", {
					$: (b) => (this.history.button = b),
461
					id: "comfy-view-history-button",
pythongosssss's avatar
pythongosssss committed
462
463
464
465
466
467
468
469
470
471
					textContent: "View History",
					onclick: () => {
						this.queue.hide();
						this.history.toggle();
					},
				}),
			]),
			this.queue.element,
			this.history.element,
			$el("button", {
472
				id: "comfy-save-button",
pythongosssss's avatar
pythongosssss committed
473
474
				textContent: "Save",
				onclick: () => {
475
476
477
478
479
480
481
482
					let filename = "workflow.json";
					if (promptFilename.value) {
						filename = prompt("Save workflow as:", filename);
						if (!filename) return;
						if (!filename.toLowerCase().endsWith(".json")) {
							filename += ".json";
						}
					}
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
					app.graphToPrompt().then(p=>{
						const json = JSON.stringify(p.workflow, null, 2); // convert the data to a JSON string
						const blob = new Blob([json], {type: "application/json"});
						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);
pythongosssss's avatar
pythongosssss committed
498
499
500
					});
				},
			}),
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
			$el("button", {
				id: "comfy-dev-save-api-button",
				textContent: "Save (API Format)",
				style: {width: "100%", display: "none"},
				onclick: () => {
					let filename = "workflow_api.json";
					if (promptFilename.value) {
						filename = prompt("Save workflow (API) as:", filename);
						if (!filename) return;
						if (!filename.toLowerCase().endsWith(".json")) {
							filename += ".json";
						}
					}
					app.graphToPrompt().then(p=>{
						const json = JSON.stringify(p.output, null, 2); // convert the data to a JSON string
						const blob = new Blob([json], {type: "application/json"});
						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);
					});
				},
			}),
reaper47's avatar
reaper47 committed
532
533
534
535
536
537
538
539
540
541
542
543
544
			$el("button", {id: "comfy-load-button", textContent: "Load", onclick: () => fileInput.click()}),
			$el("button", {
				id: "comfy-refresh-button",
				textContent: "Refresh",
				onclick: () => app.refreshComboInNodes()
			}),
			$el("button", {id: "comfy-clipspace-button", textContent: "Clipspace", onclick: () => app.openClipspace()}),
			$el("button", {
				id: "comfy-clear-button", textContent: "Clear", onclick: () => {
					if (!confirmClear.value || confirm("Clear workflow?")) {
						app.clean();
						app.graph.clear();
					}
545
				}
reaper47's avatar
reaper47 committed
546
547
			}),
			$el("button", {
pythongosssss's avatar
pythongosssss committed
548
				id: "comfy-load-default-button", textContent: "Load Default", onclick: async () => {
reaper47's avatar
reaper47 committed
549
					if (!confirmClear.value || confirm("Load default workflow?")) {
pythongosssss's avatar
pythongosssss committed
550
						await app.loadGraphData()
reaper47's avatar
reaper47 committed
551
					}
552
				}
reaper47's avatar
reaper47 committed
553
			}),
pythongosssss's avatar
pythongosssss committed
554
		]);
pythongosssss's avatar
pythongosssss committed
555

556
557
558
559
560
561
562
563
		const devMode = this.settings.addSetting({
			id: "Comfy.DevMode",
			name: "Enable Dev mode Options",
			type: "boolean",
			defaultValue: false,
			onChange: function(value) { document.getElementById("comfy-dev-save-api-button").style.display = value ? "block" : "none"},
		});

564
		dragElement(this.menuContainer, this.settings);
Jairo Correa's avatar
Jairo Correa committed
565

reaper47's avatar
reaper47 committed
566
		this.setStatus({exec_info: {queue_remaining: "X"}});
pythongosssss's avatar
pythongosssss committed
567
568
569
570
	}

	setStatus(status) {
		this.queueSize.textContent = "Queue size: " + (status ? status.exec_info.queue_remaining : "ERR");
comfyanonymous's avatar
comfyanonymous committed
571
		if (status) {
572
573
574
			if (
				this.lastQueueSize != 0 &&
				status.exec_info.queue_remaining == 0 &&
Jairo Correa's avatar
Jairo Correa committed
575
576
				document.getElementById("autoQueueCheckbox").checked &&
				! app.lastExecutionError
577
			) {
comfyanonymous's avatar
comfyanonymous committed
578
579
				app.queuePrompt(0, this.batchCount);
			}
580
			this.lastQueueSize = status.exec_info.queue_remaining;
comfyanonymous's avatar
comfyanonymous committed
581
		}
pythongosssss's avatar
pythongosssss committed
582
583
	}
}