import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; import { ComfyDialog, $el } from "../../scripts/ui.js"; import { GroupNodeConfig, GroupNodeHandler } from "./groupNode.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 // // To rearrange: // Open the manage dialog and Drag and drop elements using the "Name:" label as handle const id = "Comfy.NodeTemplates"; const file = "comfy.templates.json"; class ManageTemplates extends ComfyDialog { constructor() { super(); this.load().then((v) => { this.templates = v; }); this.element.classList.add("comfy-manage-templates"); this.draggedEl = null; this.saveVisualCue = null; this.emptyImg = new Image(); this.emptyImg.src = "data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs="; this.importInput = $el("input", { type: "file", accept: ".json", multiple: true, style: { display: "none" }, parent: document.body, onchange: () => this.importAll(), }); } createButtons() { const btns = super.createButtons(); btns[0].textContent = "Close"; btns[0].onclick = (e) => { clearTimeout(this.saveVisualCue); this.close(); }; btns.unshift( $el("button", { type: "button", textContent: "Export", onclick: () => this.exportAll(), }) ); btns.unshift( $el("button", { type: "button", textContent: "Import", onclick: () => { this.importInput.click(); }, }) ); return btns; } async load() { let templates = []; if (app.storageLocation === "server") { if (app.isNewUserSession) { // New user so migrate existing templates const json = localStorage.getItem(id); if (json) { templates = JSON.parse(json); } await api.storeUserData(file, json, { stringify: false }); } else { const res = await api.getUserData(file); if (res.status === 200) { try { templates = await res.json(); } catch (error) { } } else if (res.status !== 404) { console.error(res.status + " " + res.statusText); } } } else { const json = localStorage.getItem(id); if (json) { templates = JSON.parse(json); } } return templates ?? []; } async store() { if(app.storageLocation === "server") { const templates = JSON.stringify(this.templates, undefined, 4); localStorage.setItem(id, templates); // Backwards compatibility try { await api.storeUserData(file, templates, { stringify: false }); } catch (error) { console.error(error); alert(error.message); } } else { localStorage.setItem(id, JSON.stringify(this.templates)); } } async importAll() { for (const file of this.importInput.files) { if (file.type === "application/json" || file.name.endsWith(".json")) { const reader = new FileReader(); reader.onload = async () => { const importFile = JSON.parse(reader.result); if (importFile?.templates) { for (const template of importFile.templates) { if (template?.name && template?.data) { this.templates.push(template); } } await this.store(); } }; await reader.readAsText(file); } } this.importInput.value = null; this.close(); } exportAll() { if (this.templates.length == 0) { alert("No templates to export."); return; } const json = JSON.stringify({ templates: this.templates }, 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: "node_templates.json", style: { display: "none" }, parent: document.body, }); a.click(); setTimeout(function () { a.remove(); window.URL.revokeObjectURL(url); }, 0); } show() { // Show list of template names + delete button super.show( $el( "div", {}, this.templates.flatMap((t,i) => { let nameInput; return [ $el( "div", { dataset: { id: i }, className: "tempateManagerRow", style: { display: "grid", gridTemplateColumns: "1fr auto", border: "1px dashed transparent", gap: "5px", backgroundColor: "var(--comfy-menu-bg)" }, ondragstart: (e) => { this.draggedEl = e.currentTarget; e.currentTarget.style.opacity = "0.6"; e.currentTarget.style.border = "1px dashed yellow"; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setDragImage(this.emptyImg, 0, 0); }, ondragend: (e) => { e.target.style.opacity = "1"; e.currentTarget.style.border = "1px dashed transparent"; e.currentTarget.removeAttribute("draggable"); // rearrange the elements this.element.querySelectorAll('.tempateManagerRow').forEach((el,i) => { var prev_i = el.dataset.id; if ( el == this.draggedEl && prev_i != i ) { this.templates.splice(i, 0, this.templates.splice(prev_i, 1)[0]); } el.dataset.id = i; }); this.store(); }, ondragover: (e) => { e.preventDefault(); if ( e.currentTarget == this.draggedEl ) return; let rect = e.currentTarget.getBoundingClientRect(); if (e.clientY > rect.top + rect.height / 2) { e.currentTarget.parentNode.insertBefore(this.draggedEl, e.currentTarget.nextSibling); } else { e.currentTarget.parentNode.insertBefore(this.draggedEl, e.currentTarget); } } }, [ $el( "label", { textContent: "Name: ", style: { cursor: "grab", }, onmousedown: (e) => { // enable dragging only from the label if (e.target.localName == 'label') e.currentTarget.parentNode.draggable = 'true'; } }, [ $el("input", { value: t.name, dataset: { name: t.name }, style: { transitionProperty: 'background-color', transitionDuration: '0s', }, onchange: (e) => { clearTimeout(this.saveVisualCue); var el = e.target; var row = el.parentNode.parentNode; this.templates[row.dataset.id].name = el.value.trim() || 'untitled'; this.store(); el.style.backgroundColor = 'rgb(40, 95, 40)'; el.style.transitionDuration = '0s'; this.saveVisualCue = setTimeout(function () { el.style.transitionDuration = '.7s'; el.style.backgroundColor = 'var(--comfy-input-bg)'; }, 15); }, onkeypress: (e) => { var el = e.target; clearTimeout(this.saveVisualCue); el.style.transitionDuration = '0s'; el.style.backgroundColor = 'var(--comfy-input-bg)'; }, $: (el) => (nameInput = el), }) ] ), $el( "div", {}, [ $el("button", { textContent: "Export", style: { fontSize: "12px", fontWeight: "normal", }, onclick: (e) => { const json = JSON.stringify({templates: [t]}, 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: (nameInput.value || t.name) + ".json", style: {display: "none"}, parent: document.body, }); a.click(); setTimeout(function () { a.remove(); window.URL.revokeObjectURL(url); }, 0); }, }), $el("button", { textContent: "Delete", style: { fontSize: "12px", color: "red", fontWeight: "normal", }, onclick: (e) => { const item = e.target.parentNode.parentNode; item.parentNode.removeChild(item); this.templates.splice(item.dataset.id*1, 1); this.store(); // update the rows index, setTimeout ensures that the list is updated var that = this; setTimeout(function (){ that.element.querySelectorAll('.tempateManagerRow').forEach((el,i) => { el.dataset.id = i; }); }, 0); }, }), ] ), ] ) ]; }) ) ); } } app.registerExtension({ name: id, setup() { const manage = new ManageTemplates(); const clipboardAction = async (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"); await 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?.trim()) return; clipboardAction(() => { app.canvas.copyToClipboard(); let data = localStorage.getItem("litegrapheditor_clipboard"); data = JSON.parse(data); const nodeIds = Object.keys(app.canvas.selected_nodes); for (let i = 0; i < nodeIds.length; i++) { const node = app.graph.getNodeById(nodeIds[i]); const nodeData = node?.constructor.nodeData; let groupData = GroupNodeHandler.getGroupData(node); if (groupData) { groupData = groupData.nodeData; if (!data.groupNodes) { data.groupNodes = {}; } data.groupNodes[nodeData.name] = groupData; data.nodes[i].type = nodeData.name; } } manage.templates.push({ name, data: JSON.stringify(data), }); manage.store(); }); }, }); // Map each template to a menu item const subItems = manage.templates.map((t) => { return { content: t.name, callback: () => { clipboardAction(async () => { const data = JSON.parse(t.data); await GroupNodeConfig.registerFromWorkflow(data.groupNodes, {}); localStorage.setItem("litegrapheditor_clipboard", t.data); app.canvas.pasteFromClipboard(); }); }, }; }); subItems.push(null, { content: "Manage", callback: () => manage.show(), }); options.push({ content: "Node Templates", submenu: { options: subItems, }, }); return options; }; }, });