From 794dcadf8c33c19a8242c4f8ff7edcf8b0883f7c Mon Sep 17 00:00:00 2001 From: jonstreeter Date: Sat, 20 Dec 2025 15:51:23 -0600 Subject: [PATCH] v1.1.0: Add Dynamic Text Concatenate node and fix width persistence --- __init__.py | 69 +++++++++++++- js/dynamic_text_concatenate.js | 164 +++++++++++++++++++++++++++++++++ js/wireless_master_toggle.js | 125 ++++++++++++++++++------- pyproject.toml | 2 +- 4 files changed, 325 insertions(+), 35 deletions(-) create mode 100644 js/dynamic_text_concatenate.js diff --git a/__init__.py b/__init__.py index 65aa23b..066118b 100644 --- a/__init__.py +++ b/__init__.py @@ -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" diff --git a/js/dynamic_text_concatenate.js b/js/dynamic_text_concatenate.js new file mode 100644 index 0000000..14f73b2 --- /dev/null +++ b/js/dynamic_text_concatenate.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); + }; + } +}); diff --git a/js/wireless_master_toggle.js b/js/wireless_master_toggle.js index b8e9119..4c2d9ae 100644 --- a/js/wireless_master_toggle.js +++ b/js/wireless_master_toggle.js @@ -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(); + }; } }); diff --git a/pyproject.toml b/pyproject.toml index 794e753..8d0dfba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"