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 // // To rearrange: // Open the manage dialog and Drag and drop elements using the "Name:" label as handle const id = "Comfy.NodeTemplates"; class ManageTemplates extends ComfyDialog { constructor() { super(); this.element.classList.add("comfy-manage-templates"); this.templates = this.load(); 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; } load() { const templates = localStorage.getItem(id); if (templates) { return JSON.parse(templates); } else { return []; } } store() { 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 () => { var importFile = JSON.parse(reader.result); if (importFile && importFile?.templates) { for (const template of importFile.templates) { if (template?.name && template?.data) { this.templates.push(template); } } 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 in the localStorage this.element.querySelectorAll('.tempateManagerRow').forEach((el,i) => { var prev_i = el.dataset.id; if ( el == this.draggedEl && prev_i != i ) { [this.templates[i], this.templates[prev_i]] = [this.templates[prev_i], this.templates[i]]; } 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 = (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(); }); }, })); subItems.push(null, { content: "Manage", callback: () => manage.show(), }); options.push({ content: "Node Templates", submenu: { options: subItems, }, }); return options; }; }, });