Browse Source

Manage group nodes (#2455)

* wip group manage

* prototyping ui

* tweaks

* wip

* wip

* more wip

* fixes
add deletion

* Fix tests

* fixes

* Remove test code

* typo

* fix crash when link is invalid
pull/2544/head
pythongosssss 10 months ago committed by GitHub
parent
commit
18511dd581
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 161
      web/extensions/core/groupNode.js
  2. 149
      web/extensions/core/groupNodeManage.css
  3. 422
      web/extensions/core/groupNodeManage.js
  4. 3
      web/jsconfig.json
  5. 3
      web/scripts/domWidget.js
  6. 20
      web/scripts/ui.js
  7. 287
      web/scripts/ui/draggableList.js
  8. 11
      web/style.css

161
web/extensions/core/groupNode.js

@ -1,6 +1,7 @@
import { app } from "../../scripts/app.js"; import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js"; import { api } from "../../scripts/api.js";
import { mergeIfValid } from "./widgetInputs.js"; import { mergeIfValid } from "./widgetInputs.js";
import { ManageGroupDialog } from "./groupNodeManage.js";
const GROUP = Symbol(); const GROUP = Symbol();
@ -61,11 +62,7 @@ class GroupNodeBuilder {
); );
return; return;
case Workflow.InUse.Registered: case Workflow.InUse.Registered:
if ( if (!confirm("A group node with this name already exists embedded in this workflow, are you sure you want to overwrite it?")) {
!confirm(
"An group node with this name already exists embedded in this workflow, are you sure you want to overwrite it?"
)
) {
return; return;
} }
break; break;
@ -151,6 +148,8 @@ export class GroupNodeConfig {
this.primitiveDefs = {}; this.primitiveDefs = {};
this.widgetToPrimitive = {}; this.widgetToPrimitive = {};
this.primitiveToWidget = {}; this.primitiveToWidget = {};
this.nodeInputs = {};
this.outputVisibility = [];
} }
async registerType(source = "workflow") { async registerType(source = "workflow") {
@ -158,6 +157,7 @@ export class GroupNodeConfig {
output: [], output: [],
output_name: [], output_name: [],
output_is_list: [], output_is_list: [],
output_is_hidden: [],
name: source + "/" + this.name, name: source + "/" + this.name,
display_name: this.name, display_name: this.name,
category: "group nodes" + ("/" + source), category: "group nodes" + ("/" + source),
@ -277,8 +277,7 @@ export class GroupNodeConfig {
} }
if (input.widget) { if (input.widget) {
const targetDef = globalDefs[node.type]; const targetDef = globalDefs[node.type];
const targetWidget = const targetWidget = targetDef.input.required[input.widget.name] ?? targetDef.input.optional[input.widget.name];
targetDef.input.required[input.widget.name] ?? targetDef.input.optional[input.widget.name];
const widget = [targetWidget[0], config]; const widget = [targetWidget[0], config];
const res = mergeIfValid( const res = mergeIfValid(
@ -330,7 +329,8 @@ export class GroupNodeConfig {
} }
getInputConfig(node, inputName, seenInputs, config, extra) { getInputConfig(node, inputName, seenInputs, config, extra) {
let name = node.inputs?.find((inp) => inp.name === inputName)?.label ?? inputName; const customConfig = this.nodeData.config?.[node.index]?.input?.[inputName];
let name = customConfig?.name ?? node.inputs?.find((inp) => inp.name === inputName)?.label ?? inputName;
let key = name; let key = name;
let prefix = ""; let prefix = "";
// Special handling for primitive to include the title if it is set rather than just "value" // Special handling for primitive to include the title if it is set rather than just "value"
@ -356,7 +356,7 @@ export class GroupNodeConfig {
config = [config[0], { ...config[1], ...extra }]; config = [config[0], { ...config[1], ...extra }];
} }
return { name, config }; return { name, config, customConfig };
} }
processWidgetInputs(inputs, node, inputNames, seenInputs) { processWidgetInputs(inputs, node, inputNames, seenInputs) {
@ -366,9 +366,7 @@ export class GroupNodeConfig {
for (const inputName of inputNames) { for (const inputName of inputNames) {
let widgetType = app.getWidgetType(inputs[inputName], inputName); let widgetType = app.getWidgetType(inputs[inputName], inputName);
if (widgetType) { if (widgetType) {
const convertedIndex = node.inputs?.findIndex( const convertedIndex = node.inputs?.findIndex((inp) => inp.name === inputName && inp.widget?.name === inputName);
(inp) => inp.name === inputName && inp.widget?.name === inputName
);
if (convertedIndex > -1) { if (convertedIndex > -1) {
// This widget has been converted to a widget // This widget has been converted to a widget
// We need to store this in the correct position so link ids line up // We need to store this in the correct position so link ids line up
@ -424,6 +422,7 @@ export class GroupNodeConfig {
} }
processInputSlots(inputs, node, slots, linksTo, inputMap, seenInputs) { processInputSlots(inputs, node, slots, linksTo, inputMap, seenInputs) {
this.nodeInputs[node.index] = {};
for (let i = 0; i < slots.length; i++) { for (let i = 0; i < slots.length; i++) {
const inputName = slots[i]; const inputName = slots[i];
if (linksTo[i]) { if (linksTo[i]) {
@ -432,7 +431,11 @@ export class GroupNodeConfig {
continue; continue;
} }
const { name, config } = this.getInputConfig(node, inputName, seenInputs, inputs[inputName]); const { name, config, customConfig } = this.getInputConfig(node, inputName, seenInputs, inputs[inputName]);
this.nodeInputs[node.index][inputName] = name;
if(customConfig?.visible === false) continue;
this.nodeDef.input.required[name] = config; this.nodeDef.input.required[name] = config;
inputMap[i] = this.inputCount++; inputMap[i] = this.inputCount++;
} }
@ -452,6 +455,7 @@ export class GroupNodeConfig {
const { name, config } = this.getInputConfig(node, inputName, seenInputs, inputs[inputName], { const { name, config } = this.getInputConfig(node, inputName, seenInputs, inputs[inputName], {
defaultInput: true, defaultInput: true,
}); });
this.nodeDef.input.required[name] = config; this.nodeDef.input.required[name] = config;
this.newToOldWidgetMap[name] = { node, inputName }; this.newToOldWidgetMap[name] = { node, inputName };
@ -477,9 +481,7 @@ export class GroupNodeConfig {
this.processInputSlots(inputs, node, slots, linksTo, inputMap, seenInputs); this.processInputSlots(inputs, node, slots, linksTo, inputMap, seenInputs);
// Converted inputs have to be processed after all other nodes as they'll be at the end of the list // Converted inputs have to be processed after all other nodes as they'll be at the end of the list
this.#convertedToProcess.push(() => this.#convertedToProcess.push(() => this.processConvertedWidgets(inputs, node, slots, converted, linksTo, inputMap, seenInputs));
this.processConvertedWidgets(inputs, node, slots, converted, linksTo, inputMap, seenInputs)
);
return inputMapping; return inputMapping;
} }
@ -490,8 +492,12 @@ export class GroupNodeConfig {
// Add outputs // Add outputs
for (let outputId = 0; outputId < def.output.length; outputId++) { for (let outputId = 0; outputId < def.output.length; outputId++) {
const linksFrom = this.linksFrom[node.index]; const linksFrom = this.linksFrom[node.index];
if (linksFrom?.[outputId] && !this.externalFrom[node.index]?.[outputId]) { // If this output is linked internally we flag it to hide
// This output is linked internally so we can skip it const hasLink = linksFrom?.[outputId] && !this.externalFrom[node.index]?.[outputId];
const customConfig = this.nodeData.config?.[node.index]?.output?.[outputId];
const visible = customConfig?.visible ?? !hasLink;
this.outputVisibility.push(visible);
if (!visible) {
continue; continue;
} }
@ -500,11 +506,15 @@ export class GroupNodeConfig {
this.nodeDef.output.push(def.output[outputId]); this.nodeDef.output.push(def.output[outputId]);
this.nodeDef.output_is_list.push(def.output_is_list[outputId]); this.nodeDef.output_is_list.push(def.output_is_list[outputId]);
let label = def.output_name?.[outputId] ?? def.output[outputId]; let label = customConfig?.name;
const output = node.outputs.find((o) => o.name === label); if (!label) {
if (output?.label) { label = def.output_name?.[outputId] ?? def.output[outputId];
label = output.label; const output = node.outputs.find((o) => o.name === label);
if (output?.label) {
label = output.label;
}
} }
let name = label; let name = label;
if (name in seenOutputs) { if (name in seenOutputs) {
const prefix = `${node.title ?? node.type} `; const prefix = `${node.title ?? node.type} `;
@ -677,6 +687,25 @@ export class GroupNodeHandler {
return this.innerNodes; return this.innerNodes;
}; };
this.node.recreate = async () => {
const id = this.node.id;
const sz = this.node.size;
const nodes = this.node.convertToNodes();
const groupNode = LiteGraph.createNode(this.node.type);
groupNode.id = id;
// Reuse the existing nodes for this instance
groupNode.setInnerNodes(nodes);
groupNode[GROUP].populateWidgets();
app.graph.add(groupNode);
groupNode.size = [Math.max(groupNode.size[0], sz[0]), Math.max(groupNode.size[1], sz[1])];
// Remove all converted nodes and relink them
groupNode[GROUP].replaceNodes(nodes);
return groupNode;
};
this.node.convertToNodes = () => { this.node.convertToNodes = () => {
const addInnerNodes = () => { const addInnerNodes = () => {
const backup = localStorage.getItem("litegrapheditor_clipboard"); const backup = localStorage.getItem("litegrapheditor_clipboard");
@ -769,6 +798,7 @@ export class GroupNodeHandler {
const slot = node.inputs[groupSlotId]; const slot = node.inputs[groupSlotId];
if (slot.link == null) continue; if (slot.link == null) continue;
const link = app.graph.links[slot.link]; const link = app.graph.links[slot.link];
if (!link) continue;
// connect this node output to the input of another node // connect this node output to the input of another node
const originNode = app.graph.getNodeById(link.origin_id); const originNode = app.graph.getNodeById(link.origin_id);
originNode.connect(link.origin_slot, newNode, +innerInputId); originNode.connect(link.origin_slot, newNode, +innerInputId);
@ -806,12 +836,23 @@ export class GroupNodeHandler {
let optionIndex = options.findIndex((o) => o.content === "Outputs"); let optionIndex = options.findIndex((o) => o.content === "Outputs");
if (optionIndex === -1) optionIndex = options.length; if (optionIndex === -1) optionIndex = options.length;
else optionIndex++; else optionIndex++;
options.splice(optionIndex, 0, null, { options.splice(
content: "Convert to nodes", optionIndex,
callback: () => { 0,
return this.convertToNodes(); null,
{
content: "Convert to nodes",
callback: () => {
return this.convertToNodes();
},
}, },
}); {
content: "Manage Group Node",
callback: () => {
new ManageGroupDialog(app).show(this.type);
},
}
);
}; };
// Draw custom collapse icon to identity this as a group // Draw custom collapse icon to identity this as a group
@ -865,6 +906,28 @@ export class GroupNodeHandler {
return onExecutionStart?.apply(this, arguments); return onExecutionStart?.apply(this, arguments);
}; };
const self = this;
const onNodeCreated = this.node.onNodeCreated;
this.node.onNodeCreated = function () {
const config = self.groupData.nodeData.config;
if (config) {
for (const n in config) {
const inputs = config[n]?.input;
for (const w in inputs) {
if (inputs[w].visible !== false) continue;
const widgetName = self.groupData.oldToNewWidgetMap[n][w];
const widget = this.widgets.find((w) => w.name === widgetName);
if (widget) {
widget.type = "hidden";
widget.computeSize = () => [0, -4];
}
}
}
}
return onNodeCreated?.apply(this, arguments);
};
function handleEvent(type, getId, getEvent) { function handleEvent(type, getId, getEvent) {
const handler = ({ detail }) => { const handler = ({ detail }) => {
const id = getId(detail); const id = getId(detail);
@ -927,13 +990,15 @@ export class GroupNodeHandler {
continue; continue;
} else if (innerNode.type === "Reroute") { } else if (innerNode.type === "Reroute") {
const rerouteLinks = this.groupData.linksFrom[old.node.index]; const rerouteLinks = this.groupData.linksFrom[old.node.index];
for (const [_, , targetNodeId, targetSlot] of rerouteLinks["0"]) { if (rerouteLinks) {
const node = this.innerNodes[targetNodeId]; for (const [_, , targetNodeId, targetSlot] of rerouteLinks["0"]) {
const input = node.inputs[targetSlot]; const node = this.innerNodes[targetNodeId];
if (input.widget) { const input = node.inputs[targetSlot];
const widget = node.widgets?.find((w) => w.name === input.widget.name); if (input.widget) {
if (widget) { const widget = node.widgets?.find((w) => w.name === input.widget.name);
widget.value = newValue; if (widget) {
widget.value = newValue;
}
} }
} }
} }
@ -975,7 +1040,7 @@ export class GroupNodeHandler {
const [, , targetNodeId, targetNodeSlot] = link; const [, , targetNodeId, targetNodeSlot] = link;
const targetNode = this.groupData.nodeData.nodes[targetNodeId]; const targetNode = this.groupData.nodeData.nodes[targetNodeId];
const inputs = targetNode.inputs; const inputs = targetNode.inputs;
const targetWidget = inputs?.[targetNodeSlot].widget; const targetWidget = inputs?.[targetNodeSlot]?.widget;
if (!targetWidget) return; if (!targetWidget) return;
const offset = inputs.length - (targetNode.widgets_values?.length ?? 0); const offset = inputs.length - (targetNode.widgets_values?.length ?? 0);
@ -983,13 +1048,12 @@ export class GroupNodeHandler {
if (v == null) return; if (v == null) return;
const widgetName = Object.values(map)[0]; const widgetName = Object.values(map)[0];
const widget = this.node.widgets.find(w => w.name === widgetName); const widget = this.node.widgets.find((w) => w.name === widgetName);
if(widget) { if (widget) {
widget.value = v; widget.value = v;
} }
} }
populateWidgets() { populateWidgets() {
if (!this.node.widgets) return; if (!this.node.widgets) return;
@ -1080,7 +1144,7 @@ export class GroupNodeHandler {
} }
static getGroupData(node) { static getGroupData(node) {
return node.constructor?.nodeData?.[GROUP]; return (node.nodeData ?? node.constructor?.nodeData)?.[GROUP];
} }
static isGroupNode(node) { static isGroupNode(node) {
@ -1112,7 +1176,7 @@ export class GroupNodeHandler {
} }
function addConvertToGroupOptions() { function addConvertToGroupOptions() {
function addOption(options, index) { function addConvertOption(options, index) {
const selected = Object.values(app.canvas.selected_nodes ?? {}); const selected = Object.values(app.canvas.selected_nodes ?? {});
const disabled = selected.length < 2 || selected.find((n) => GroupNodeHandler.isGroupNode(n)); const disabled = selected.length < 2 || selected.find((n) => GroupNodeHandler.isGroupNode(n));
options.splice(index + 1, null, { options.splice(index + 1, null, {
@ -1124,12 +1188,25 @@ function addConvertToGroupOptions() {
}); });
} }
function addManageOption(options, index) {
const groups = app.graph.extra?.groupNodes;
const disabled = !groups || !Object.keys(groups).length;
options.splice(index + 1, null, {
content: `Manage Group Nodes`,
disabled,
callback: () => {
new ManageGroupDialog(app).show();
},
});
}
// Add to canvas // Add to canvas
const getCanvasMenuOptions = LGraphCanvas.prototype.getCanvasMenuOptions; const getCanvasMenuOptions = LGraphCanvas.prototype.getCanvasMenuOptions;
LGraphCanvas.prototype.getCanvasMenuOptions = function () { LGraphCanvas.prototype.getCanvasMenuOptions = function () {
const options = getCanvasMenuOptions.apply(this, arguments); const options = getCanvasMenuOptions.apply(this, arguments);
const index = options.findIndex((o) => o?.content === "Add Group") + 1 || options.length; const index = options.findIndex((o) => o?.content === "Add Group") + 1 || options.length;
addOption(options, index); addConvertOption(options, index);
addManageOption(options, index + 1);
return options; return options;
}; };
@ -1139,7 +1216,7 @@ function addConvertToGroupOptions() {
const options = getNodeMenuOptions.apply(this, arguments); const options = getNodeMenuOptions.apply(this, arguments);
if (!GroupNodeHandler.isGroupNode(node)) { if (!GroupNodeHandler.isGroupNode(node)) {
const index = options.findIndex((o) => o?.content === "Outputs") + 1 || options.length - 1; const index = options.findIndex((o) => o?.content === "Outputs") + 1 || options.length - 1;
addOption(options, index); addConvertOption(options, index);
} }
return options; return options;
}; };

149
web/extensions/core/groupNodeManage.css

@ -0,0 +1,149 @@
.comfy-group-manage {
background: var(--bg-color);
color: var(--fg-color);
padding: 0;
font-family: Arial, Helvetica, sans-serif;
border-color: black;
margin: 20vh auto;
max-height: 60vh;
}
.comfy-group-manage-outer {
max-height: 60vh;
min-width: 500px;
display: flex;
flex-direction: column;
}
.comfy-group-manage-outer > header {
display: flex;
align-items: center;
gap: 10px;
justify-content: space-between;
background: var(--comfy-menu-bg);
padding: 15px 20px;
}
.comfy-group-manage-outer > header select {
background: var(--comfy-input-bg);
border: 1px solid var(--border-color);
color: var(--input-text);
padding: 5px 10px;
border-radius: 5px;
}
.comfy-group-manage h2 {
margin: 0;
font-weight: normal;
}
.comfy-group-manage main {
display: flex;
overflow: hidden;
}
.comfy-group-manage .drag-handle {
font-weight: bold;
}
.comfy-group-manage-list {
border-right: 1px solid var(--comfy-menu-bg);
}
.comfy-group-manage-list ul {
margin: 40px 0 0;
padding: 0;
list-style: none;
}
.comfy-group-manage-list-items {
max-height: 70vh;
overflow-y: scroll;
overflow-x: hidden;
}
.comfy-group-manage-list li {
display: flex;
padding: 10px 20px 10px 10px;
cursor: pointer;
align-items: center;
gap: 5px;
}
.comfy-group-manage-list div {
display: flex;
flex-direction: column;
}
.comfy-group-manage-list li:not(.selected):hover div {
text-decoration: underline;
}
.comfy-group-manage-list li.selected {
background: var(--border-color);
}
.comfy-group-manage-list li span {
opacity: 0.7;
font-size: smaller;
}
.comfy-group-manage-node {
flex: auto;
background: var(--border-color);
display: flex;
flex-direction: column;
}
.comfy-group-manage-node > div {
overflow: auto;
}
.comfy-group-manage-node header {
display: flex;
background: var(--bg-color);
height: 40px;
}
.comfy-group-manage-node header a {
text-align: center;
flex: auto;
border-right: 1px solid var(--comfy-menu-bg);
border-bottom: 1px solid var(--comfy-menu-bg);
padding: 10px;
cursor: pointer;
font-size: 15px;
}
.comfy-group-manage-node header a:last-child {
border-right: none;
}
.comfy-group-manage-node header a:not(.active):hover {
text-decoration: underline;
}
.comfy-group-manage-node header a.active {
background: var(--border-color);
border-bottom: none;
}
.comfy-group-manage-node-page {
display: none;
overflow: auto;
}
.comfy-group-manage-node-page.active {
display: block;
}
.comfy-group-manage-node-page div {
padding: 10px;
display: flex;
align-items: center;
gap: 10px;
}
.comfy-group-manage-node-page input {
border: none;
color: var(--input-text);
background: var(--comfy-input-bg);
padding: 5px 10px;
}
.comfy-group-manage-node-page input[type="text"] {
flex: auto;
}
.comfy-group-manage-node-page label {
display: flex;
gap: 5px;
align-items: center;
}
.comfy-group-manage footer {
border-top: 1px solid var(--comfy-menu-bg);
padding: 10px;
display: flex;
gap: 10px;
}
.comfy-group-manage footer button {
font-size: 14px;
padding: 5px 10px;
border-radius: 0;
}
.comfy-group-manage footer button:first-child {
margin-right: auto;
}

422
web/extensions/core/groupNodeManage.js

@ -0,0 +1,422 @@
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<string, Record<string, Record<string, { name?: string | undefined, visible?: boolean | undefined }>>> } */
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();
});
}
}

3
web/jsconfig.json

@ -3,7 +3,8 @@
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"/*": ["./*"] "/*": ["./*"]
} },
"lib": ["DOM", "ES2022"]
}, },
"include": ["."] "include": ["."]
} }

3
web/scripts/domWidget.js

@ -239,7 +239,8 @@ LGraphNode.prototype.addDOMWidget = function (name, type, element, options) {
node.flags?.collapsed || node.flags?.collapsed ||
(!!options.hideOnZoom && app.canvas.ds.scale < 0.5) || (!!options.hideOnZoom && app.canvas.ds.scale < 0.5) ||
widget.computedHeight <= 0 || widget.computedHeight <= 0 ||
widget.type === "converted-widget"; widget.type === "converted-widget"||
widget.type === "hidden";
element.hidden = hidden; element.hidden = hidden;
element.style.display = hidden ? "none" : null; element.style.display = hidden ? "none" : null;
if (hidden) { if (hidden) {

20
web/scripts/ui.js

@ -4,6 +4,19 @@ import { ComfySettingsDialog } from "./ui/settings.js";
export const ComfyDialog = _ComfyDialog; export const ComfyDialog = _ComfyDialog;
/**
*
* @param { string } tag HTML Element Tag and optional classes e.g. div.class1.class2
* @param { string | Element | Element[] | {
* parent?: Element,
* $?: (el: Element) => void,
* dataset?: DOMStringMap,
* style?: CSSStyleDeclaration,
* for?: string
* } | undefined } propsOrChildren
* @param { Element[] | undefined } [children]
* @returns
*/
export function $el(tag, propsOrChildren, children) { export function $el(tag, propsOrChildren, children) {
const split = tag.split("."); const split = tag.split(".");
const element = document.createElement(split.shift()); const element = document.createElement(split.shift());
@ -12,6 +25,11 @@ export function $el(tag, propsOrChildren, children) {
} }
if (propsOrChildren) { if (propsOrChildren) {
if (typeof propsOrChildren === "string") {
propsOrChildren = { textContent: propsOrChildren };
} else if (propsOrChildren instanceof Element) {
propsOrChildren = [propsOrChildren];
}
if (Array.isArray(propsOrChildren)) { if (Array.isArray(propsOrChildren)) {
element.append(...propsOrChildren); element.append(...propsOrChildren);
} else { } else {
@ -35,7 +53,7 @@ export function $el(tag, propsOrChildren, children) {
Object.assign(element, propsOrChildren); Object.assign(element, propsOrChildren);
if (children) { if (children) {
element.append(...children); element.append(...(children instanceof Array ? children : [children]));
} }
if (parent) { if (parent) {

287
web/scripts/ui/draggableList.js

@ -0,0 +1,287 @@
// @ts-check
/*
Original implementation:
https://github.com/TahaSh/drag-to-reorder
MIT License
Copyright (c) 2023 Taha Shashtari
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
import { $el } from "../ui.js";
$el("style", {
parent: document.head,
textContent: `
.draggable-item {
position: relative;
will-change: transform;
user-select: none;
}
.draggable-item.is-idle {
transition: 0.25s ease transform;
}
.draggable-item.is-draggable {
z-index: 10;
}
`
});
export class DraggableList extends EventTarget {
listContainer;
draggableItem;
pointerStartX;
pointerStartY;
scrollYMax;
itemsGap = 0;
items = [];
itemSelector;
handleClass = "drag-handle";
off = [];
offDrag = [];
constructor(element, itemSelector) {
super();
this.listContainer = element;
this.itemSelector = itemSelector;
if (!this.listContainer) return;
this.off.push(this.on(this.listContainer, "mousedown", this.dragStart));
this.off.push(this.on(this.listContainer, "touchstart", this.dragStart));
this.off.push(this.on(document, "mouseup", this.dragEnd));
this.off.push(this.on(document, "touchend", this.dragEnd));
}
getAllItems() {
if (!this.items?.length) {
this.items = Array.from(this.listContainer.querySelectorAll(this.itemSelector));
this.items.forEach((element) => {
element.classList.add("is-idle");
});
}
return this.items;
}
getIdleItems() {
return this.getAllItems().filter((item) => item.classList.contains("is-idle"));
}
isItemAbove(item) {
return item.hasAttribute("data-is-above");
}
isItemToggled(item) {
return item.hasAttribute("data-is-toggled");
}
on(source, event, listener, options) {
listener = listener.bind(this);
source.addEventListener(event, listener, options);
return () => source.removeEventListener(event, listener);
}
dragStart(e) {
if (e.target.classList.contains(this.handleClass)) {
this.draggableItem = e.target.closest(this.itemSelector);
}
if (!this.draggableItem) return;
this.pointerStartX = e.clientX || e.touches[0].clientX;
this.pointerStartY = e.clientY || e.touches[0].clientY;
this.scrollYMax = this.listContainer.scrollHeight - this.listContainer.clientHeight;
this.setItemsGap();
this.initDraggableItem();
this.initItemsState();
this.offDrag.push(this.on(document, "mousemove", this.drag));
this.offDrag.push(this.on(document, "touchmove", this.drag, { passive: false }));
this.dispatchEvent(
new CustomEvent("dragstart", {
detail: { element: this.draggableItem, position: this.getAllItems().indexOf(this.draggableItem) },
})
);
}
setItemsGap() {
if (this.getIdleItems().length <= 1) {
this.itemsGap = 0;
return;
}
const item1 = this.getIdleItems()[0];
const item2 = this.getIdleItems()[1];
const item1Rect = item1.getBoundingClientRect();
const item2Rect = item2.getBoundingClientRect();
this.itemsGap = Math.abs(item1Rect.bottom - item2Rect.top);
}
initItemsState() {
this.getIdleItems().forEach((item, i) => {
if (this.getAllItems().indexOf(this.draggableItem) > i) {
item.dataset.isAbove = "";
}
});
}
initDraggableItem() {
this.draggableItem.classList.remove("is-idle");
this.draggableItem.classList.add("is-draggable");
}
drag(e) {
if (!this.draggableItem) return;
e.preventDefault();
const clientX = e.clientX || e.touches[0].clientX;
const clientY = e.clientY || e.touches[0].clientY;
const listRect = this.listContainer.getBoundingClientRect();
if (clientY > listRect.bottom) {
if (this.listContainer.scrollTop < this.scrollYMax) {
this.listContainer.scrollBy(0, 10);
this.pointerStartY -= 10;
}
} else if (clientY < listRect.top && this.listContainer.scrollTop > 0) {
this.pointerStartY += 10;
this.listContainer.scrollBy(0, -10);
}
const pointerOffsetX = clientX - this.pointerStartX;
const pointerOffsetY = clientY - this.pointerStartY;
this.updateIdleItemsStateAndPosition();
this.draggableItem.style.transform = `translate(${pointerOffsetX}px, ${pointerOffsetY}px)`;
}
updateIdleItemsStateAndPosition() {
const draggableItemRect = this.draggableItem.getBoundingClientRect();
const draggableItemY = draggableItemRect.top + draggableItemRect.height / 2;
// Update state
this.getIdleItems().forEach((item) => {
const itemRect = item.getBoundingClientRect();
const itemY = itemRect.top + itemRect.height / 2;
if (this.isItemAbove(item)) {
if (draggableItemY <= itemY) {
item.dataset.isToggled = "";
} else {
delete item.dataset.isToggled;
}
} else {
if (draggableItemY >= itemY) {
item.dataset.isToggled = "";
} else {
delete item.dataset.isToggled;
}
}
});
// Update position
this.getIdleItems().forEach((item) => {
if (this.isItemToggled(item)) {
const direction = this.isItemAbove(item) ? 1 : -1;
item.style.transform = `translateY(${direction * (draggableItemRect.height + this.itemsGap)}px)`;
} else {
item.style.transform = "";
}
});
}
dragEnd() {
if (!this.draggableItem) return;
this.applyNewItemsOrder();
this.cleanup();
}
applyNewItemsOrder() {
const reorderedItems = [];
let oldPosition = -1;
this.getAllItems().forEach((item, index) => {
if (item === this.draggableItem) {
oldPosition = index;
return;
}
if (!this.isItemToggled(item)) {
reorderedItems[index] = item;
return;
}
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1;
reorderedItems[newIndex] = item;
});
for (let index = 0; index < this.getAllItems().length; index++) {
const item = reorderedItems[index];
if (typeof item === "undefined") {
reorderedItems[index] = this.draggableItem;
}
}
reorderedItems.forEach((item) => {
this.listContainer.appendChild(item);
});
this.items = reorderedItems;
this.dispatchEvent(
new CustomEvent("dragend", {
detail: { element: this.draggableItem, oldPosition, newPosition: reorderedItems.indexOf(this.draggableItem) },
})
);
}
cleanup() {
this.itemsGap = 0;
this.items = [];
this.unsetDraggableItem();
this.unsetItemState();
this.offDrag.forEach((f) => f());
this.offDrag = [];
}
unsetDraggableItem() {
this.draggableItem.style = null;
this.draggableItem.classList.remove("is-draggable");
this.draggableItem.classList.add("is-idle");
this.draggableItem = null;
}
unsetItemState() {
this.getIdleItems().forEach((item, i) => {
delete item.dataset.isAbove;
delete item.dataset.isToggled;
item.style.transform = "";
});
}
dispose() {
this.off.forEach((f) => f());
}
}

11
web/style.css

@ -145,6 +145,12 @@ body {
} }
.comfy-menu span.drag-handle { .comfy-menu span.drag-handle {
position: absolute;
top: 0;
left: 0;
}
span.drag-handle {
width: 10px; width: 10px;
height: 20px; height: 20px;
display: inline-block; display: inline-block;
@ -160,12 +166,9 @@ body {
letter-spacing: 2px; letter-spacing: 2px;
color: var(--drag-text); color: var(--drag-text);
text-shadow: 1px 0 1px black; text-shadow: 1px 0 1px black;
position: absolute;
top: 0;
left: 0;
} }
.comfy-menu span.drag-handle::after { span.drag-handle::after {
content: '.. .. ..'; content: '.. .. ..';
} }

Loading…
Cancel
Save