diff --git a/extension.js b/extension.js new file mode 100644 index 0000000..e18beeb --- /dev/null +++ b/extension.js @@ -0,0 +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; + }; + }, +});