diff --git a/README.md b/README.md index d38506a..2ba1ec7 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Extension for ComyUI that enables saving and loading node presets using the node Right click the node and choose "Presets" from the context menu to access the node's presets. # Installation -Clone this repository into your `ComfyUI/web/extensions` folder using `git clone https://github.com/BlakeOne/ComfyUI-NodePresets` and reload ComfyUI. +Clone this repository into your `custom_nodes` folder using `git clone https://github.com/BlakeOne/ComfyUI-NodePresets` and reload ComfyUI. # Props This extension is based on ComfyUI's [NodeTemplates](https://github.com/comfyanonymous/ComfyUI/blob/master/web/extensions/core/nodeTemplates.js) extension. :) diff --git a/js/extension.js b/js/extension.js index 8b13789..e18beeb 100644 --- a/js/extension.js +++ b/js/extension.js @@ -1 +1,387 @@ +import { app } from "../../scripts/app.js"; +import { api } from "../../scripts/api.js"; +import { ComfyDialog, $el } from "../../scripts/ui.js"; +// Adds the ability to save and load node presets using the node's context menu +// To save: +// Right click on the node +// Presets -> Save -> give it a name +// +// To load: +// Right click on the node +// Presets -> click the one to add +// +// To delete/rename: +// Right click the node +// Presets -> Manage +// +// To rearrange: +// Open the manage dialog and Drag and drop elements using the "Name:" label as handle + +const id = "Comfy.NodePresets"; +const file = "comfy.presets.json"; + +class PresetManager extends ComfyDialog { + constructor() { + super(); + this.load().then((v) => { + this.presets = v; + }); + + this.element.classList.add("comfy-manage-presets"); + 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 presets = {}; + if (app.storageLocation === "server") { + try { + if (app.isNewUserSession) { + // New user so migrate existing presets + const json = localStorage.getItem(id); + if (json) { + presets = JSON.parse(json); + api.storeUserData(file, json, { stringify: false }); + } + } else { + const res = await api.getUserData(file); + if (res.status === 200) { + presets = await res.json(); + } else if (res.status !== 404) { + console.error(res.status + " " + res.statusText); + } + } + } catch (error) { + console.error(error); + } + } else { + const json = localStorage.getItem(id); + if (json) { + presets = JSON.parse(json); + } + } + + return presets ?? {}; + } + + async store() { + if(app.storageLocation === "server") { + try { + const presets = JSON.stringify(this.presets, undefined, 4); + await api.storeUserData(file, presets, { stringify: false }); + } catch (error) { + console.error(error); + } + } else { + localStorage.setItem(id, JSON.stringify(this.presets)); + } + } + + 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?.presets) { + for (const nodeType in importFile.presets) { + if (this.presets[nodeType]) { + this.presets[nodeType] = this.presets[nodeType].concat(importFile.presets[nodeType]); + } else { + this.presets[nodeType] = importFile.presets[nodeType]; + } + } + } + }; + await reader.readAsText(file); + } + } + + this.importInput.value = null; + this.store(); + this.close(); + } + + exportAll() { + if (Object.keys(this.presets).length === 0) { + alert("No presets to export."); + return; + } + + const json = JSON.stringify({ presets: this.presets }, 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_presets.json", + style: { display: "none" }, + parent: document.body, + }); + a.click(); + setTimeout(function () { + a.remove(); + window.URL.revokeObjectURL(url); + }, 0); + } + + show(nodeType) { + const nodePresets = this.presets[nodeType]; + super.show( + $el( + "div", + {}, + nodePresets.flatMap((p, i) => { + let nameInput; + return [ + $el( + "div", + { + dataset: { id: i }, + className: "presetManagerRow", + style: { + display: "grid", + gridPresetColumns: "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('.presetManagerRow').forEach((el,i) => { + var prev_i = el.dataset.id; + + if ( el == this.draggedEl && prev_i != i ) { + nodePresets.splice(i, 0, nodePresets.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: p.name, + dataset: { name: p.name }, + style: { + transitionProperty: 'background-color', + transitionDuration: '0s', + }, + onchange: (e) => { + clearTimeout(this.saveVisualCue); + var el = e.target; + var row = el.parentNode.parentNode; + nodePresets[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 presets = {}; + presets[nodeType] = [p]; + const json = JSON.stringify({presets: presets}, 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 || p.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); + nodePresets.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('.presetManagerRow').forEach((el,i) => { + el.dataset.id = i; + }); + }, 0); + }, + }), + ] + ), + ] + ) + ]; + }) + ) + ); + } +} + +app.registerExtension({ + name: id, + setup() { + const presetManager = new PresetManager(); + + const orig = LGraphCanvas.prototype.getNodeMenuOptions; + LGraphCanvas.prototype.getNodeMenuOptions = function(node) { + const options = orig.call(this, node); + + const loadOptions = presetManager.presets[node.type]?.map((preset) => ({ + content: preset.name, + callback: () => { + node.widgets.forEach((widget) => { + if (widget.name in preset.values) { + widget.value = preset.values[widget.name]; + } + }); + node.setDirtyCanvas(true, false); + } + })); + + const saveCallback = () => { + const name = prompt("Enter name"); + if (!name?.trim()) return; + + const presetValues = {}; + node.widgets.forEach(widget => presetValues[widget.name] = widget.value); + + const preset = {name: name, values: presetValues}; + if (presetManager.presets[node.type]) { + presetManager.presets[node.type].push(preset); + } else { + presetManager.presets[node.type] = [preset]; + } + presetManager.store(); + }; + + const presetOptions = [{ + content: `Save`, + disabled: !node.widgets.length, + callback: saveCallback + }, { + content: "Load", + disabled: !presetManager.presets[node.type], + submenu: {options: loadOptions} + }, { + content: "Manage", + callback: () => presetManager.show(node.type) + }]; + + const presetMenu = { + content: "Presets", + submenu: {options: presetOptions} + }; + + options.push(null, presetMenu); + return options; + }; + }, +}); diff --git a/nodePresets.js b/nodePresets.js deleted file mode 100644 index e18beeb..0000000 --- a/nodePresets.js +++ /dev/null @@ -1,387 +0,0 @@ -import { app } from "../../scripts/app.js"; -import { api } from "../../scripts/api.js"; -import { ComfyDialog, $el } from "../../scripts/ui.js"; - -// Adds the ability to save and load node presets using the node's context menu -// To save: -// Right click on the node -// Presets -> Save -> give it a name -// -// To load: -// Right click on the node -// Presets -> click the one to add -// -// To delete/rename: -// Right click the node -// Presets -> Manage -// -// To rearrange: -// Open the manage dialog and Drag and drop elements using the "Name:" label as handle - -const id = "Comfy.NodePresets"; -const file = "comfy.presets.json"; - -class PresetManager extends ComfyDialog { - constructor() { - super(); - this.load().then((v) => { - this.presets = v; - }); - - this.element.classList.add("comfy-manage-presets"); - 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 presets = {}; - if (app.storageLocation === "server") { - try { - if (app.isNewUserSession) { - // New user so migrate existing presets - const json = localStorage.getItem(id); - if (json) { - presets = JSON.parse(json); - api.storeUserData(file, json, { stringify: false }); - } - } else { - const res = await api.getUserData(file); - if (res.status === 200) { - presets = await res.json(); - } else if (res.status !== 404) { - console.error(res.status + " " + res.statusText); - } - } - } catch (error) { - console.error(error); - } - } else { - const json = localStorage.getItem(id); - if (json) { - presets = JSON.parse(json); - } - } - - return presets ?? {}; - } - - async store() { - if(app.storageLocation === "server") { - try { - const presets = JSON.stringify(this.presets, undefined, 4); - await api.storeUserData(file, presets, { stringify: false }); - } catch (error) { - console.error(error); - } - } else { - localStorage.setItem(id, JSON.stringify(this.presets)); - } - } - - 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?.presets) { - for (const nodeType in importFile.presets) { - if (this.presets[nodeType]) { - this.presets[nodeType] = this.presets[nodeType].concat(importFile.presets[nodeType]); - } else { - this.presets[nodeType] = importFile.presets[nodeType]; - } - } - } - }; - await reader.readAsText(file); - } - } - - this.importInput.value = null; - this.store(); - this.close(); - } - - exportAll() { - if (Object.keys(this.presets).length === 0) { - alert("No presets to export."); - return; - } - - const json = JSON.stringify({ presets: this.presets }, 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_presets.json", - style: { display: "none" }, - parent: document.body, - }); - a.click(); - setTimeout(function () { - a.remove(); - window.URL.revokeObjectURL(url); - }, 0); - } - - show(nodeType) { - const nodePresets = this.presets[nodeType]; - super.show( - $el( - "div", - {}, - nodePresets.flatMap((p, i) => { - let nameInput; - return [ - $el( - "div", - { - dataset: { id: i }, - className: "presetManagerRow", - style: { - display: "grid", - gridPresetColumns: "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('.presetManagerRow').forEach((el,i) => { - var prev_i = el.dataset.id; - - if ( el == this.draggedEl && prev_i != i ) { - nodePresets.splice(i, 0, nodePresets.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: p.name, - dataset: { name: p.name }, - style: { - transitionProperty: 'background-color', - transitionDuration: '0s', - }, - onchange: (e) => { - clearTimeout(this.saveVisualCue); - var el = e.target; - var row = el.parentNode.parentNode; - nodePresets[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 presets = {}; - presets[nodeType] = [p]; - const json = JSON.stringify({presets: presets}, 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 || p.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); - nodePresets.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('.presetManagerRow').forEach((el,i) => { - el.dataset.id = i; - }); - }, 0); - }, - }), - ] - ), - ] - ) - ]; - }) - ) - ); - } -} - -app.registerExtension({ - name: id, - setup() { - const presetManager = new PresetManager(); - - const orig = LGraphCanvas.prototype.getNodeMenuOptions; - LGraphCanvas.prototype.getNodeMenuOptions = function(node) { - const options = orig.call(this, node); - - const loadOptions = presetManager.presets[node.type]?.map((preset) => ({ - content: preset.name, - callback: () => { - node.widgets.forEach((widget) => { - if (widget.name in preset.values) { - widget.value = preset.values[widget.name]; - } - }); - node.setDirtyCanvas(true, false); - } - })); - - const saveCallback = () => { - const name = prompt("Enter name"); - if (!name?.trim()) return; - - const presetValues = {}; - node.widgets.forEach(widget => presetValues[widget.name] = widget.value); - - const preset = {name: name, values: presetValues}; - if (presetManager.presets[node.type]) { - presetManager.presets[node.type].push(preset); - } else { - presetManager.presets[node.type] = [preset]; - } - presetManager.store(); - }; - - const presetOptions = [{ - content: `Save`, - disabled: !node.widgets.length, - callback: saveCallback - }, { - content: "Load", - disabled: !presetManager.presets[node.type], - submenu: {options: loadOptions} - }, { - content: "Manage", - callback: () => presetManager.show(node.type) - }]; - - const presetMenu = { - content: "Presets", - submenu: {options: presetOptions} - }; - - options.push(null, presetMenu); - return options; - }; - }, -});