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. 133
      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

133
web/extensions/core/groupNode.js

@ -1,6 +1,7 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { mergeIfValid } from "./widgetInputs.js";
import { ManageGroupDialog } from "./groupNodeManage.js";
const GROUP = Symbol();
@ -61,11 +62,7 @@ class GroupNodeBuilder {
);
return;
case Workflow.InUse.Registered:
if (
!confirm(
"An group node with this name already exists embedded in this workflow, are you sure you want to overwrite it?"
)
) {
if (!confirm("A group node with this name already exists embedded in this workflow, are you sure you want to overwrite it?")) {
return;
}
break;
@ -151,6 +148,8 @@ export class GroupNodeConfig {
this.primitiveDefs = {};
this.widgetToPrimitive = {};
this.primitiveToWidget = {};
this.nodeInputs = {};
this.outputVisibility = [];
}
async registerType(source = "workflow") {
@ -158,6 +157,7 @@ export class GroupNodeConfig {
output: [],
output_name: [],
output_is_list: [],
output_is_hidden: [],
name: source + "/" + this.name,
display_name: this.name,
category: "group nodes" + ("/" + source),
@ -277,8 +277,7 @@ export class GroupNodeConfig {
}
if (input.widget) {
const targetDef = globalDefs[node.type];
const targetWidget =
targetDef.input.required[input.widget.name] ?? targetDef.input.optional[input.widget.name];
const targetWidget = targetDef.input.required[input.widget.name] ?? targetDef.input.optional[input.widget.name];
const widget = [targetWidget[0], config];
const res = mergeIfValid(
@ -330,7 +329,8 @@ export class GroupNodeConfig {
}
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 prefix = "";
// 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 }];
}
return { name, config };
return { name, config, customConfig };
}
processWidgetInputs(inputs, node, inputNames, seenInputs) {
@ -366,9 +366,7 @@ export class GroupNodeConfig {
for (const inputName of inputNames) {
let widgetType = app.getWidgetType(inputs[inputName], inputName);
if (widgetType) {
const convertedIndex = node.inputs?.findIndex(
(inp) => inp.name === inputName && inp.widget?.name === inputName
);
const convertedIndex = node.inputs?.findIndex((inp) => inp.name === inputName && inp.widget?.name === inputName);
if (convertedIndex > -1) {
// This widget has been converted to a widget
// 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) {
this.nodeInputs[node.index] = {};
for (let i = 0; i < slots.length; i++) {
const inputName = slots[i];
if (linksTo[i]) {
@ -432,7 +431,11 @@ export class GroupNodeConfig {
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;
inputMap[i] = this.inputCount++;
}
@ -452,6 +455,7 @@ export class GroupNodeConfig {
const { name, config } = this.getInputConfig(node, inputName, seenInputs, inputs[inputName], {
defaultInput: true,
});
this.nodeDef.input.required[name] = config;
this.newToOldWidgetMap[name] = { node, inputName };
@ -477,9 +481,7 @@ export class GroupNodeConfig {
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
this.#convertedToProcess.push(() =>
this.processConvertedWidgets(inputs, node, slots, converted, linksTo, inputMap, seenInputs)
);
this.#convertedToProcess.push(() => this.processConvertedWidgets(inputs, node, slots, converted, linksTo, inputMap, seenInputs));
return inputMapping;
}
@ -490,8 +492,12 @@ export class GroupNodeConfig {
// Add outputs
for (let outputId = 0; outputId < def.output.length; outputId++) {
const linksFrom = this.linksFrom[node.index];
if (linksFrom?.[outputId] && !this.externalFrom[node.index]?.[outputId]) {
// This output is linked internally so we can skip it
// If this output is linked internally we flag it to hide
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;
}
@ -500,11 +506,15 @@ export class GroupNodeConfig {
this.nodeDef.output.push(def.output[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;
if (!label) {
label = def.output_name?.[outputId] ?? def.output[outputId];
const output = node.outputs.find((o) => o.name === label);
if (output?.label) {
label = output.label;
}
}
let name = label;
if (name in seenOutputs) {
const prefix = `${node.title ?? node.type} `;
@ -677,6 +687,25 @@ export class GroupNodeHandler {
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 = () => {
const addInnerNodes = () => {
const backup = localStorage.getItem("litegrapheditor_clipboard");
@ -769,6 +798,7 @@ export class GroupNodeHandler {
const slot = node.inputs[groupSlotId];
if (slot.link == null) continue;
const link = app.graph.links[slot.link];
if (!link) continue;
// connect this node output to the input of another node
const originNode = app.graph.getNodeById(link.origin_id);
originNode.connect(link.origin_slot, newNode, +innerInputId);
@ -806,12 +836,23 @@ export class GroupNodeHandler {
let optionIndex = options.findIndex((o) => o.content === "Outputs");
if (optionIndex === -1) optionIndex = options.length;
else optionIndex++;
options.splice(optionIndex, 0, null, {
options.splice(
optionIndex,
0,
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
@ -865,6 +906,28 @@ export class GroupNodeHandler {
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) {
const handler = ({ detail }) => {
const id = getId(detail);
@ -927,6 +990,7 @@ export class GroupNodeHandler {
continue;
} else if (innerNode.type === "Reroute") {
const rerouteLinks = this.groupData.linksFrom[old.node.index];
if (rerouteLinks) {
for (const [_, , targetNodeId, targetSlot] of rerouteLinks["0"]) {
const node = this.innerNodes[targetNodeId];
const input = node.inputs[targetSlot];
@ -938,6 +1002,7 @@ export class GroupNodeHandler {
}
}
}
}
const widget = innerNode.widgets?.find((w) => w.name === old.inputName);
if (widget) {
@ -975,7 +1040,7 @@ export class GroupNodeHandler {
const [, , targetNodeId, targetNodeSlot] = link;
const targetNode = this.groupData.nodeData.nodes[targetNodeId];
const inputs = targetNode.inputs;
const targetWidget = inputs?.[targetNodeSlot].widget;
const targetWidget = inputs?.[targetNodeSlot]?.widget;
if (!targetWidget) return;
const offset = inputs.length - (targetNode.widgets_values?.length ?? 0);
@ -983,13 +1048,12 @@ export class GroupNodeHandler {
if (v == null) return;
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) {
widget.value = v;
}
}
populateWidgets() {
if (!this.node.widgets) return;
@ -1080,7 +1144,7 @@ export class GroupNodeHandler {
}
static getGroupData(node) {
return node.constructor?.nodeData?.[GROUP];
return (node.nodeData ?? node.constructor?.nodeData)?.[GROUP];
}
static isGroupNode(node) {
@ -1112,7 +1176,7 @@ export class GroupNodeHandler {
}
function addConvertToGroupOptions() {
function addOption(options, index) {
function addConvertOption(options, index) {
const selected = Object.values(app.canvas.selected_nodes ?? {});
const disabled = selected.length < 2 || selected.find((n) => GroupNodeHandler.isGroupNode(n));
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
const getCanvasMenuOptions = LGraphCanvas.prototype.getCanvasMenuOptions;
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
const options = getCanvasMenuOptions.apply(this, arguments);
const index = options.findIndex((o) => o?.content === "Add Group") + 1 || options.length;
addOption(options, index);
addConvertOption(options, index);
addManageOption(options, index + 1);
return options;
};
@ -1139,7 +1216,7 @@ function addConvertToGroupOptions() {
const options = getNodeMenuOptions.apply(this, arguments);
if (!GroupNodeHandler.isGroupNode(node)) {
const index = options.findIndex((o) => o?.content === "Outputs") + 1 || options.length - 1;
addOption(options, index);
addConvertOption(options, index);
}
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": ".",
"paths": {
"/*": ["./*"]
}
},
"lib": ["DOM", "ES2022"]
},
"include": ["."]
}

3
web/scripts/domWidget.js

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

20
web/scripts/ui.js

@ -4,6 +4,19 @@ import { ComfySettingsDialog } from "./ui/settings.js";
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) {
const split = tag.split(".");
const element = document.createElement(split.shift());
@ -12,6 +25,11 @@ export function $el(tag, propsOrChildren, children) {
}
if (propsOrChildren) {
if (typeof propsOrChildren === "string") {
propsOrChildren = { textContent: propsOrChildren };
} else if (propsOrChildren instanceof Element) {
propsOrChildren = [propsOrChildren];
}
if (Array.isArray(propsOrChildren)) {
element.append(...propsOrChildren);
} else {
@ -35,7 +53,7 @@ export function $el(tag, propsOrChildren, children) {
Object.assign(element, propsOrChildren);
if (children) {
element.append(...children);
element.append(...(children instanceof Array ? children : [children]));
}
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 {
position: absolute;
top: 0;
left: 0;
}
span.drag-handle {
width: 10px;
height: 20px;
display: inline-block;
@ -160,12 +166,9 @@ body {
letter-spacing: 2px;
color: var(--drag-text);
text-shadow: 1px 0 1px black;
position: absolute;
top: 0;
left: 0;
}
.comfy-menu span.drag-handle::after {
span.drag-handle::after {
content: '.. .. ..';
}

Loading…
Cancel
Save