diff --git a/web/extensions/core/nodeTemplates.js b/web/extensions/core/nodeTemplates.js new file mode 100644 index 00000000..69d09cde --- /dev/null +++ b/web/extensions/core/nodeTemplates.js @@ -0,0 +1,184 @@ +import { app } from "/scripts/app.js"; +import { ComfyDialog, $el } from "/scripts/ui.js"; + +// Adds the ability to save and add multiple nodes as a template +// To save: +// Select multiple nodes (ctrl + drag to select a region or ctrl+click individual nodes) +// Right click the canvas +// Save Node Template -> give it a name +// +// To add: +// Right click the canvas +// Node templates -> click the one to add +// +// To delete/rename: +// Right click the canvas +// Node templates -> Manage + +const id = "Comfy.NodeTemplates"; + +class ManageTemplates extends ComfyDialog { + constructor() { + super(); + this.element.classList.add("comfy-manage-templates"); + this.templates = this.load(); + } + + createButtons() { + const btns = super.createButtons(); + btns[0].textContent = "Cancel"; + btns.unshift( + $el("button", { + type: "button", + textContent: "Save", + onclick: () => this.save(), + }) + ); + return btns; + } + + load() { + const templates = localStorage.getItem(id); + if (templates) { + return JSON.parse(templates); + } else { + return []; + } + } + + save() { + // Find all visible inputs and save them as our new list + const inputs = this.element.querySelectorAll("input"); + const updated = []; + + for (let i = 0; i < inputs.length; i++) { + const input = inputs[i]; + if (input.parentElement.style.display !== "none") { + const t = this.templates[i]; + t.name = input.value.trim() || input.getAttribute("data-name"); + updated.push(t); + } + } + + this.templates = updated; + this.store(); + this.close(); + } + + store() { + localStorage.setItem(id, JSON.stringify(this.templates)); + } + + show() { + // Show list of template names + delete button + super.show( + $el( + "div", + { + style: { + display: "grid", + gridTemplateColumns: "1fr auto", + gap: "5px", + }, + }, + this.templates.flatMap((t) => { + let nameInput; + return [ + $el( + "label", + { + textContent: "Name: ", + }, + [ + $el("input", { + value: t.name, + dataset: { name: t.name }, + $: (el) => (nameInput = el), + }), + ] + ), + $el("button", { + textContent: "Delete", + style: { + fontSize: "12px", + color: "red", + fontWeight: "normal", + }, + onclick: (e) => { + nameInput.value = ""; + e.target.style.display = "none"; + e.target.previousElementSibling.style.display = "none"; + }, + }), + ]; + }) + ) + ); + } +} + +app.registerExtension({ + name: id, + setup() { + const manage = new ManageTemplates(); + + const clipboardAction = (cb) => { + // We use the clipboard functions but dont want to overwrite the current user clipboard + // Restore it after we've run our callback + const old = localStorage.getItem("litegrapheditor_clipboard"); + cb(); + localStorage.setItem("litegrapheditor_clipboard", old); + }; + + const orig = LGraphCanvas.prototype.getCanvasMenuOptions; + LGraphCanvas.prototype.getCanvasMenuOptions = function () { + const options = orig.apply(this, arguments); + + options.push(null); + options.push({ + content: `Save Selected as Template`, + disabled: !Object.keys(app.canvas.selected_nodes || {}).length, + callback: () => { + const name = prompt("Enter name"); + if (!name || !name.trim()) return; + + clipboardAction(() => { + app.canvas.copyToClipboard(); + manage.templates.push({ + name, + data: localStorage.getItem("litegrapheditor_clipboard"), + }); + manage.store(); + }); + }, + }); + + // Map each template to a menu item + const subItems = manage.templates.map((t) => ({ + content: t.name, + callback: () => { + clipboardAction(() => { + localStorage.setItem("litegrapheditor_clipboard", t.data); + app.canvas.pasteFromClipboard(); + }); + }, + })); + + if (subItems.length) { + subItems.push(null, { + content: "Manage", + callback: () => manage.show(), + }); + + options.push({ + content: "Node Templates", + submenu: { + options: subItems, + }, + }); + } + + return options; + }; + }, +}); diff --git a/web/scripts/ui.js b/web/scripts/ui.js index 6999c0a7..09861c44 100644 --- a/web/scripts/ui.js +++ b/web/scripts/ui.js @@ -8,14 +8,18 @@ export function $el(tag, propsOrChildren, children) { if (Array.isArray(propsOrChildren)) { element.append(...propsOrChildren); } else { - const parent = propsOrChildren.parent; + const { parent, $: cb, dataset, style } = propsOrChildren; delete propsOrChildren.parent; - const cb = propsOrChildren.$; delete propsOrChildren.$; + delete propsOrChildren.dataset; + delete propsOrChildren.style; - if (propsOrChildren.style) { - Object.assign(element.style, propsOrChildren.style); - delete propsOrChildren.style; + if (style) { + Object.assign(element.style, style); + } + + if (dataset) { + Object.assign(element.dataset, dataset); } Object.assign(element, propsOrChildren); @@ -76,7 +80,7 @@ function dragElement(dragEl, settings) { dragEl.style.left = newPosX + "px"; dragEl.style.right = "unset"; } - + dragEl.style.top = newPosY + "px"; dragEl.style.bottom = "unset"; @@ -145,7 +149,7 @@ function dragElement(dragEl, settings) { } window.addEventListener("resize", () => { - ensureInBounds(); + ensureInBounds(); }); function closeDragElement() { @@ -155,26 +159,33 @@ function dragElement(dragEl, settings) { } } -class ComfyDialog { +export class ComfyDialog { constructor() { this.element = $el("div.comfy-modal", { parent: document.body }, [ - $el("div.comfy-modal-content", [ - $el("p", { $: (p) => (this.textElement = p) }), - $el("button", { - type: "button", - textContent: "Close", - onclick: () => this.close(), - }), - ]), + $el("div.comfy-modal-content", [$el("p", { $: (p) => (this.textElement = p) }), ...this.createButtons()]), ]); } + createButtons() { + return [ + $el("button", { + type: "button", + textContent: "Close", + onclick: () => this.close(), + }), + ]; + } + close() { this.element.style.display = "none"; } show(html) { - this.textElement.innerHTML = html; + if (typeof html === "string") { + this.textElement.innerHTML = html; + } else { + this.textElement.replaceChildren(html); + } this.element.style.display = "flex"; } } @@ -419,7 +430,7 @@ export class ComfyUI { type: "boolean", defaultValue: true, }); - + const fileInput = $el("input", { type: "file", accept: ".json,image/png", diff --git a/web/style.css b/web/style.css index 27bb83bb..afea3a8b 100644 --- a/web/style.css +++ b/web/style.css @@ -202,7 +202,8 @@ button.comfy-queue-btn { margin: 6px 0 !important; } -.comfy-modal.comfy-settings { +.comfy-modal.comfy-settings, +.comfy-modal.comfy-manage-templates { text-align: center; font-family: sans-serif; color: #999;