v1.1.0: Add Dynamic Text Concatenate node and fix width persistence

This commit is contained in:
jonstreeter
2025-12-20 15:51:23 -06:00
parent d5101102a0
commit 794dcadf8c
4 changed files with 325 additions and 35 deletions
+66 -3
View File
@@ -5,9 +5,72 @@ Control any nodes in your workflow by title/color matching.
Works across subgraphs with radio-button style restrictions.
"""
# Purely frontend node - no Python backend required
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
class DynamicTextConcatenate:
"""
Concatenate multiple text inputs with a configurable delimiter.
Dynamically adds input slots as needed (handled by frontend JS).
"""
CATEGORY = "utils"
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("text",)
FUNCTION = "concatenate"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {},
"optional": {
"text_1": ("STRING", {"forceInput": True}),
"text_2": ("STRING", {"forceInput": True}),
"text_3": ("STRING", {"forceInput": True}),
"text_4": ("STRING", {"forceInput": True}),
"text_5": ("STRING", {"forceInput": True}),
"text_6": ("STRING", {"forceInput": True}),
"text_7": ("STRING", {"forceInput": True}),
"text_8": ("STRING", {"forceInput": True}),
"text_9": ("STRING", {"forceInput": True}),
"text_10": ("STRING", {"forceInput": True}),
"delimiter": (["space", "none", "comma", "newline", "pipe", "custom"], {"default": "space"}),
"custom_delimiter": ("STRING", {"default": ""}),
}
}
DELIMITER_MAP = {
"space": " ",
"none": "",
"comma": ", ",
"newline": "\n",
"pipe": " | ",
}
def concatenate(self, delimiter="space", custom_delimiter="", **kwargs):
# Get the actual delimiter string
if delimiter == "custom":
delim = custom_delimiter
else:
delim = self.DELIMITER_MAP.get(delimiter, " ")
# Collect all text inputs in order
values = []
for i in range(1, 11): # text_1 through text_10
key = f"text_{i}"
if key in kwargs and kwargs[key] is not None and kwargs[key] != "":
values.append(str(kwargs[key]))
# Concatenate with delimiter
result = delim.join(values)
return (result,)
NODE_CLASS_MAPPINGS = {
"DynamicTextConcatenate": DynamicTextConcatenate,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"DynamicTextConcatenate": "Dynamic Text Concatenate",
}
WEB_DIRECTORY = "./js"
+164
View File
@@ -0,0 +1,164 @@
import { app } from "../../scripts/app.js";
/**
* Dynamic Text Concatenate - Dynamically add text inputs and concatenate with delimiter.
*
* Features:
* - Starts with 2 text input sockets
* - Dynamically adds more inputs when existing ones are connected
* - Configurable delimiter for joining text
* - Single text output with concatenated result
*/
const MIN_INPUTS = 2;
const MAX_INPUTS = 10;
app.registerExtension({
name: "togglemaster.DynamicTextConcatenate",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name !== "DynamicTextConcatenate") return;
// Store original onNodeCreated
const origOnNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
if (origOnNodeCreated) {
origOnNodeCreated.apply(this, arguments);
}
// Defer cleanup to ensure ComfyUI has finished setting up the node
const self = this;
setTimeout(() => {
self._cleanupInitialInputs();
}, 0);
};
// Also use onAdded as backup - called when node is added to graph
const origOnAdded = nodeType.prototype.onAdded;
nodeType.prototype.onAdded = function () {
if (origOnAdded) {
origOnAdded.apply(this, arguments);
}
this._cleanupInitialInputs();
};
nodeType.prototype._cleanupInitialInputs = function () {
if (!this.inputs || this._inputsCleaned) return;
// Get all text inputs and sort by number
const textInputs = this.inputs
.filter(inp => inp.name.startsWith("text_"))
.sort((a, b) => {
const numA = parseInt(a.name.replace("text_", ""));
const numB = parseInt(b.name.replace("text_", ""));
return numA - numB;
});
// Remove inputs beyond MIN_INPUTS (from end to avoid index issues)
for (let i = textInputs.length - 1; i >= MIN_INPUTS; i--) {
const inputToRemove = textInputs[i];
// Only remove if not connected
if (!inputToRemove.link) {
const indexToRemove = this.inputs.findIndex(inp => inp.name === inputToRemove.name);
if (indexToRemove >= 0) {
this.removeInput(indexToRemove);
}
}
}
this._inputsCleaned = true;
this.setSize(this.computeSize());
this.setDirtyCanvas(true, true);
};
// Store original onConnectionsChange
const origOnConnectionsChange = nodeType.prototype.onConnectionsChange;
nodeType.prototype.onConnectionsChange = function (type, slotIndex, isConnected, link, ioSlot) {
if (origOnConnectionsChange) {
origOnConnectionsChange.apply(this, arguments);
}
// Only handle input connections
if (type !== LiteGraph.INPUT) return;
this._updateDynamicInputs();
};
nodeType.prototype._updateDynamicInputs = function () {
if (!this.inputs) return;
// Count text inputs only
const textInputs = this.inputs.filter(inp => inp.name.startsWith("text_"));
const textInputCount = textInputs.length;
// Check if all text inputs are connected
let allTextConnected = textInputCount > 0;
for (const inp of textInputs) {
if (!inp.link) {
allTextConnected = false;
break;
}
}
// If all text inputs are connected and we haven't reached max, add a new one
if (allTextConnected && textInputCount < MAX_INPUTS) {
const newIndex = textInputCount + 1;
this.addInput(`text_${newIndex}`, "STRING");
}
// Remove trailing unconnected text inputs (keep minimum)
while (true) {
const currentTextInputs = this.inputs.filter(inp => inp.name.startsWith("text_"));
if (currentTextInputs.length <= MIN_INPUTS) break;
// Sort to get proper order
currentTextInputs.sort((a, b) => {
const numA = parseInt(a.name.replace("text_", ""));
const numB = parseInt(b.name.replace("text_", ""));
return numA - numB;
});
const lastTextInput = currentTextInputs[currentTextInputs.length - 1];
const secondLastTextInput = currentTextInputs[currentTextInputs.length - 2];
// Only remove if both last and second-last are unconnected
if (!lastTextInput.link && !secondLastTextInput.link) {
const indexToRemove = this.inputs.findIndex(inp => inp.name === lastTextInput.name);
if (indexToRemove >= 0) {
this.removeInput(indexToRemove);
}
} else {
break;
}
}
// Update size to fit new inputs
const minSize = this.computeSize();
this.setSize([
Math.max(this.size[0], minSize[0]),
Math.max(this.size[1], minSize[1])
]);
};
// Handle loading saved workflows - restore dynamic inputs
const origOnConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function (info) {
if (origOnConfigure) {
origOnConfigure.apply(this, arguments);
}
// Reset cleanup flag when loading
this._inputsCleaned = false;
// After loading, clean up inputs based on connections
// Defer to ensure links are set up
const self = this;
setTimeout(() => {
self._updateDynamicInputs();
}, 100);
};
}
});
+94 -31
View File
@@ -9,6 +9,8 @@ const PROPERTY_SHOW_ALL_GRAPHS = "showAllGraphs";
const PROPERTY_SORT = "sort";
const PROPERTY_SORT_CUSTOM_ALPHA = "customSortAlphabet";
const PROPERTY_RESTRICTION = "toggleRestriction";
const PROPERTY_AUTO_REFRESH = "autoRefresh";
const PROPERTY_USER_WIDTH = "userWidth";
/**
* Recursively find all nodes in a graph and its subgraphs.
@@ -42,29 +44,31 @@ class WirelessMasterToggle extends LiteGraph.LGraphNode {
static type = "Wireless Master Toggle";
// Static property definitions for Properties Panel
static ["@matchColors"] = {type: "string"};
static ["@matchTitle"] = {type: "string"};
static ["@showNav"] = {type: "boolean"};
static ["@showAllGraphs"] = {type: "boolean"};
static ["@matchColors"] = { type: "string" };
static ["@matchTitle"] = { type: "string" };
static ["@showNav"] = { type: "boolean" };
static ["@showAllGraphs"] = { type: "boolean" };
static ["@sort"] = {
type: "combo",
values: ["position", "alphanumeric"]
};
static ["@customSortAlphabet"] = {type: "string"};
static ["@customSortAlphabet"] = { type: "string" };
static ["@toggleRestriction"] = {
type: "combo",
values: ["default", "max one", "always one"]
};
static ["@autoRefresh"] = { type: "boolean" };
static ["@userWidth"] = { type: "number" };
constructor(title = WirelessMasterToggle.title) {
super(title);
this.serialize_widgets = false;
this.isVirtualNode = true;
// Initialize properties
this.widgets = this.widgets || [];
this.properties = this.properties || {};
// Set default values
this.properties[PROPERTY_MATCH_COLORS] = "";
this.properties[PROPERTY_MATCH_TITLE] = "";
@@ -73,11 +77,13 @@ class WirelessMasterToggle extends LiteGraph.LGraphNode {
this.properties[PROPERTY_SORT] = "position";
this.properties[PROPERTY_SORT_CUSTOM_ALPHA] = "";
this.properties[PROPERTY_RESTRICTION] = "default";
this.properties[PROPERTY_AUTO_REFRESH] = true;
this.properties[PROPERTY_USER_WIDTH] = 0; // 0 means auto
}
onNodeCreated() {
// Auto-refresh on creation
this.refreshWidgets();
// Auto-refresh on creation
this.refreshWidgets();
}
onPropertyChanged(name, value) {
@@ -87,14 +93,21 @@ class WirelessMasterToggle extends LiteGraph.LGraphNode {
}
refreshWidgets() {
// Use saved user width if available, otherwise current size
const savedWidth = this.properties[PROPERTY_USER_WIDTH] || 0;
const currentSize = [savedWidth || this.size[0], this.size[1]];
// Clear all existing widgets
if (this.widgets) {
this.widgets.length = 0;
this.widgets.length = 0;
}
// Add "Refresh" button always
this.addWidget("button", "Refresh", null, () => this.refreshWidgets());
const matchTitleStr = this.properties[PROPERTY_MATCH_TITLE];
const matchColorsStr = this.properties[PROPERTY_MATCH_COLORS];
let regex = null;
if (matchTitleStr) {
try {
@@ -103,12 +116,12 @@ class WirelessMasterToggle extends LiteGraph.LGraphNode {
console.error("Invalid Regex", e);
}
}
let colors = [];
if (matchColorsStr) {
colors = matchColorsStr.split(",").map(c => c.trim().toLowerCase()).filter(c => c);
}
// If no filters specified, don't show anything
if (!regex && colors.length === 0) {
this.setSize(this.computeSize());
@@ -117,56 +130,72 @@ class WirelessMasterToggle extends LiteGraph.LGraphNode {
const rootGraph = app.graph;
const allNodes = findAllNodesRecursive(rootGraph, (n) => true);
const matchedNodes = allNodes.filter(n => {
if (n.id === this.id) return false;
let titleMatch = true;
if (regex) {
titleMatch = regex.test(n.title);
}
let colorMatch = true;
if (colors.length > 0) {
const nodeColor = (n.color || "").toLowerCase();
const nodeBgColor = (n.bgcolor || "").toLowerCase();
colorMatch = colors.some(c => nodeColor.includes(c) || nodeBgColor.includes(c));
}
return titleMatch && colorMatch;
});
if (this.properties[PROPERTY_SORT] === "alphanumeric") {
matchedNodes.sort((a, b) => a.title.localeCompare(b.title));
matchedNodes.sort((a, b) => a.title.localeCompare(b.title));
} else {
matchedNodes.sort((a, b) => a.id - b.id);
matchedNodes.sort((a, b) => a.id - b.id);
}
for (const node of matchedNodes) {
const w = this.addWidget("toggle", node.title, node.mode !== 2, (value) => {
const w = this.addWidget("toggle", node.title, node.mode !== 2, (value) => {
this.toggleNode(node, value);
});
// Store direct node reference - IDs are NOT unique across subgraphs
w.targetNode = node;
}
this.setSize(this.computeSize());
// Compute new minimum size based on content
const minSize = this.computeSize();
// Resize logic: Keep existing size unless minSize is larger
// This satifies: "keep its size dimensions unless... addition... requires increasing"
this.setSize([
Math.max(currentSize[0], minSize[0]),
Math.max(currentSize[1], minSize[1])
]);
this.setDirtyCanvas(true);
}
onResize(size) {
// Save user's width preference when they manually resize
if (size && size[0] > 0) {
this.properties[PROPERTY_USER_WIDTH] = size[0];
}
}
toggleNode(targetNode, enabled) {
const restriction = this.properties[PROPERTY_RESTRICTION];
if (enabled && (restriction === "max one" || restriction === "always one")) {
for (const w of this.widgets) {
if (w.type === "toggle" && w.targetNode !== targetNode) {
w.value = false;
this.applyToggle(w.targetNode, false);
}
}
for (const w of this.widgets) {
if (w.type === "toggle" && w.targetNode !== targetNode) {
w.value = false;
this.applyToggle(w.targetNode, false);
}
}
}
this.applyToggle(targetNode, enabled);
}
applyToggle(targetNode, enabled) {
if (targetNode) {
targetNode.mode = enabled ? 0 : 2;
@@ -179,5 +208,39 @@ app.registerExtension({
registerCustomNodes() {
LiteGraph.registerNodeType("Wireless Master Toggle", WirelessMasterToggle);
WirelessMasterToggle.category = "utils";
},
setup() {
const notifyToggles = () => {
const graph = app.graph;
if (!graph) return;
for (const node of graph._nodes) {
if (node.type === WirelessMasterToggle.type && node.properties[PROPERTY_AUTO_REFRESH]) {
node.refreshWidgets();
}
}
};
// Debounce to avoid spamming refresh on paste/load
let timer = null;
const debouncedNotify = () => {
if (timer) clearTimeout(timer);
timer = setTimeout(notifyToggles, 100);
};
// Hook node added
const origOnNodeAdded = app.graph.onNodeAdded;
app.graph.onNodeAdded = function (node) {
origOnNodeAdded?.apply(this, arguments);
// Only refresh if the added node is NOT a toggle master (avoid infinite recursion if we did that)
// and it might match criteria. safest is just debounce refresh.
debouncedNotify();
};
// Hook node removed
const origOnNodeRemoved = app.graph.onNodeRemoved;
app.graph.onNodeRemoved = function (node) {
origOnNodeRemoved?.apply(this, arguments);
debouncedNotify();
};
}
});
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "comfyui-togglemaster"
version = "1.0.0"
version = "1.1.0"
description = "Wireless node toggle control for ComfyUI - control any nodes by title/color matching with radio-button restrictions"
license = "MIT"
readme = "README.md"