v1.1.0: Add Dynamic Text Concatenate node and fix width persistence
This commit is contained in:
+66
-3
@@ -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"
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user