import { $el, ComfyDialog } from "../../scripts/ui.js"; import { DraggableList } from "../../scripts/ui/draggableList.js"; import { addStylesheet } from "../../scripts/utils.js"; import { GroupNodeConfig, GroupNodeHandler } from "./groupNode.js"; addStylesheet(import.meta.url); const ORDER = Symbol(); function merge(target, source) { if (typeof target === "object" && typeof source === "object") { for (const key in source) { const sv = source[key]; if (typeof sv === "object") { let tv = target[key]; if (!tv) tv = target[key] = {}; merge(tv, source[key]); } else { target[key] = sv; } } } return target; } export class ManageGroupDialog extends ComfyDialog { /** @type { Record<"Inputs" | "Outputs" | "Widgets", {tab: HTMLAnchorElement, page: HTMLElement}> } */ tabs = {}; /** @type { number | null | undefined } */ selectedNodeIndex; /** @type { keyof ManageGroupDialog["tabs"] } */ selectedTab = "Inputs"; /** @type { string | undefined } */ selectedGroup; /** @type { Record>> } */ modifications = {}; get selectedNodeInnerIndex() { return +this.nodeItems[this.selectedNodeIndex].dataset.nodeindex; } constructor(app) { super(); this.app = app; this.element = $el("dialog.comfy-group-manage", { parent: document.body, }); } changeTab(tab) { this.tabs[this.selectedTab].tab.classList.remove("active"); this.tabs[this.selectedTab].page.classList.remove("active"); this.tabs[tab].tab.classList.add("active"); this.tabs[tab].page.classList.add("active"); this.selectedTab = tab; } changeNode(index, force) { if (!force && this.selectedNodeIndex === index) return; if (this.selectedNodeIndex != null) { this.nodeItems[this.selectedNodeIndex].classList.remove("selected"); } this.nodeItems[index].classList.add("selected"); this.selectedNodeIndex = index; if (!this.buildInputsPage() && this.selectedTab === "Inputs") { this.changeTab("Widgets"); } if (!this.buildWidgetsPage() && this.selectedTab === "Widgets") { this.changeTab("Outputs"); } if (!this.buildOutputsPage() && this.selectedTab === "Outputs") { this.changeTab("Inputs"); } this.changeTab(this.selectedTab); } getGroupData() { this.groupNodeType = LiteGraph.registered_node_types["workflow/" + this.selectedGroup]; this.groupNodeDef = this.groupNodeType.nodeData; this.groupData = GroupNodeHandler.getGroupData(this.groupNodeType); } changeGroup(group, reset = true) { this.selectedGroup = group; this.getGroupData(); const nodes = this.groupData.nodeData.nodes; this.nodeItems = nodes.map((n, i) => $el( "li.draggable-item", { dataset: { nodeindex: n.index + "", }, onclick: () => { this.changeNode(i); }, }, [ $el("span.drag-handle"), $el( "div", { textContent: n.title ?? n.type, }, n.title ? $el("span", { textContent: n.type, }) : [] ), ] ) ); this.innerNodesList.replaceChildren(...this.nodeItems); if (reset) { this.selectedNodeIndex = null; this.changeNode(0); } else { const items = this.draggable.getAllItems(); let index = items.findIndex(item => item.classList.contains("selected")); if(index === -1) index = this.selectedNodeIndex; this.changeNode(index, true); } const ordered = [...nodes]; this.draggable?.dispose(); this.draggable = new DraggableList(this.innerNodesList, "li"); this.draggable.addEventListener("dragend", ({ detail: { oldPosition, newPosition } }) => { if (oldPosition === newPosition) return; ordered.splice(newPosition, 0, ordered.splice(oldPosition, 1)[0]); for (let i = 0; i < ordered.length; i++) { this.storeModification({ nodeIndex: ordered[i].index, section: ORDER, prop: "order", value: i }); } }); } storeModification({ nodeIndex, section, prop, value }) { const groupMod = (this.modifications[this.selectedGroup] ??= {}); const nodesMod = (groupMod.nodes ??= {}); const nodeMod = (nodesMod[nodeIndex ?? this.selectedNodeInnerIndex] ??= {}); const typeMod = (nodeMod[section] ??= {}); if (typeof value === "object") { const objMod = (typeMod[prop] ??= {}); Object.assign(objMod, value); } else { typeMod[prop] = value; } } getEditElement(section, prop, value, placeholder, checked, checkable = true) { if (value === placeholder) value = ""; const mods = this.modifications[this.selectedGroup]?.nodes?.[this.selectedNodeInnerIndex]?.[section]?.[prop]; if (mods) { if (mods.name != null) { value = mods.name; } if (mods.visible != null) { checked = mods.visible; } } return $el("div", [ $el("input", { value, placeholder, type: "text", onchange: (e) => { this.storeModification({ section, prop, value: { name: e.target.value } }); }, }), $el("label", { textContent: "Visible" }, [ $el("input", { type: "checkbox", checked, disabled: !checkable, onchange: (e) => { this.storeModification({ section, prop, value: { visible: !!e.target.checked } }); }, }), ]), ]); } buildWidgetsPage() { const widgets = this.groupData.oldToNewWidgetMap[this.selectedNodeInnerIndex]; const items = Object.keys(widgets ?? {}); const type = app.graph.extra.groupNodes[this.selectedGroup]; const config = type.config?.[this.selectedNodeInnerIndex]?.input; this.widgetsPage.replaceChildren( ...items.map((oldName) => { return this.getEditElement("input", oldName, widgets[oldName], oldName, config?.[oldName]?.visible !== false); }) ); return !!items.length; } buildInputsPage() { const inputs = this.groupData.nodeInputs[this.selectedNodeInnerIndex]; const items = Object.keys(inputs ?? {}); const type = app.graph.extra.groupNodes[this.selectedGroup]; const config = type.config?.[this.selectedNodeInnerIndex]?.input; this.inputsPage.replaceChildren( ...items .map((oldName) => { let value = inputs[oldName]; if (!value) { return; } return this.getEditElement("input", oldName, value, oldName, config?.[oldName]?.visible !== false); }) .filter(Boolean) ); return !!items.length; } buildOutputsPage() { const nodes = this.groupData.nodeData.nodes; const innerNodeDef = this.groupData.getNodeDef(nodes[this.selectedNodeInnerIndex]); const outputs = innerNodeDef?.output ?? []; const groupOutputs = this.groupData.oldToNewOutputMap[this.selectedNodeInnerIndex]; const type = app.graph.extra.groupNodes[this.selectedGroup]; const config = type.config?.[this.selectedNodeInnerIndex]?.output; const node = this.groupData.nodeData.nodes[this.selectedNodeInnerIndex]; const checkable = node.type !== "PrimitiveNode"; this.outputsPage.replaceChildren( ...outputs .map((type, slot) => { const groupOutputIndex = groupOutputs?.[slot]; const oldName = innerNodeDef.output_name?.[slot] ?? type; let value = config?.[slot]?.name; const visible = config?.[slot]?.visible || groupOutputIndex != null; if (!value || value === oldName) { value = ""; } return this.getEditElement("output", slot, value, oldName, visible, checkable); }) .filter(Boolean) ); return !!outputs.length; } show(type) { const groupNodes = Object.keys(app.graph.extra?.groupNodes ?? {}).sort((a, b) => a.localeCompare(b)); this.innerNodesList = $el("ul.comfy-group-manage-list-items"); this.widgetsPage = $el("section.comfy-group-manage-node-page"); this.inputsPage = $el("section.comfy-group-manage-node-page"); this.outputsPage = $el("section.comfy-group-manage-node-page"); const pages = $el("div", [this.widgetsPage, this.inputsPage, this.outputsPage]); this.tabs = [ ["Inputs", this.inputsPage], ["Widgets", this.widgetsPage], ["Outputs", this.outputsPage], ].reduce((p, [name, page]) => { p[name] = { tab: $el("a", { onclick: () => { this.changeTab(name); }, textContent: name, }), page, }; return p; }, {}); const outer = $el("div.comfy-group-manage-outer", [ $el("header", [ $el("h2", "Group Nodes"), $el( "select", { onchange: (e) => { this.changeGroup(e.target.value); }, }, groupNodes.map((g) => $el("option", { textContent: g, selected: "workflow/" + g === type, value: g, }) ) ), ]), $el("main", [ $el("section.comfy-group-manage-list", this.innerNodesList), $el("section.comfy-group-manage-node", [ $el( "header", Object.values(this.tabs).map((t) => t.tab) ), pages, ]), ]), $el("footer", [ $el( "button.comfy-btn", { onclick: (e) => { const node = app.graph._nodes.find((n) => n.type === "workflow/" + this.selectedGroup); if (node) { alert("This group node is in use in the current workflow, please first remove these."); return; } if (confirm(`Are you sure you want to remove the node: "${this.selectedGroup}"`)) { delete app.graph.extra.groupNodes[this.selectedGroup]; LiteGraph.unregisterNodeType("workflow/" + this.selectedGroup); } this.show(); }, }, "Delete Group Node" ), $el( "button.comfy-btn", { onclick: async () => { let nodesByType; let recreateNodes = []; const types = {}; for (const g in this.modifications) { const type = app.graph.extra.groupNodes[g]; let config = (type.config ??= {}); let nodeMods = this.modifications[g]?.nodes; if (nodeMods) { const keys = Object.keys(nodeMods); if (nodeMods[keys[0]][ORDER]) { // If any node is reordered, they will all need sequencing const orderedNodes = []; const orderedMods = {}; const orderedConfig = {}; for (const n of keys) { const order = nodeMods[n][ORDER].order; orderedNodes[order] = type.nodes[+n]; orderedMods[order] = nodeMods[n]; orderedNodes[order].index = order; } // Rewrite links for (const l of type.links) { if (l[0] != null) l[0] = type.nodes[l[0]].index; if (l[2] != null) l[2] = type.nodes[l[2]].index; } // Rewrite externals if (type.external) { for (const ext of type.external) { ext[0] = type.nodes[ext[0]]; } } // Rewrite modifications for (const id of keys) { if (config[id]) { orderedConfig[type.nodes[id].index] = config[id]; } delete config[id]; } type.nodes = orderedNodes; nodeMods = orderedMods; type.config = config = orderedConfig; } merge(config, nodeMods); } types[g] = type; if (!nodesByType) { nodesByType = app.graph._nodes.reduce((p, n) => { p[n.type] ??= []; p[n.type].push(n); return p; }, {}); } const nodes = nodesByType["workflow/" + g]; if (nodes) recreateNodes.push(...nodes); } await GroupNodeConfig.registerFromWorkflow(types, {}); for (const node of recreateNodes) { node.recreate(); } this.modifications = {}; this.app.graph.setDirtyCanvas(true, true); this.changeGroup(this.selectedGroup, false); }, }, "Save" ), $el("button.comfy-btn", { onclick: () => this.element.close() }, "Close"), ]), ]); this.element.replaceChildren(outer); this.changeGroup(type ? groupNodes.find((g) => "workflow/" + g === type) : groupNodes[0]); this.element.showModal(); this.element.addEventListener("close", () => { this.draggable?.dispose(); }); } }