From ff287b5cb41c0442bbe6ee0a950ec0ab5f56b5f2 Mon Sep 17 00:00:00 2001 From: Shaun Johnston Date: Tue, 16 Dec 2025 09:23:08 +1000 Subject: [PATCH] interactive --- README.md | 19 ++-- js/multi_replace.js | 202 ++++++++++++++++++++++++++---------- nodes/find_replace_pairs.py | 98 +++++++---------- 3 files changed, 191 insertions(+), 128 deletions(-) diff --git a/README.md b/README.md index e14d8c6..19b0256 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@ A ComfyUI custom node package for creating and applying multiple find/replace pa ## Features - **Find/Replace Pairs Node**: Create an arbitrary number of find/replace pairs - - Supports up to 50 pairs per node - - Each pair can have values entered directly or connected from other nodes - - Dynamic UI shows only active pairs + - Starts with one pair, click "➕ Add Pair" button to add more + - Right-click menu also provides Add/Remove pair options + - Each pair can be converted to an input connector (right-click widget → Convert to Input) - Outputs: pairs object, JSON, and CSV formats - **Text Replacer Node**: Apply find/replace pairs to text @@ -32,15 +32,15 @@ A ComfyUI custom node package for creating and applying multiple find/replace pa ### Basic Usage 1. Add a **Find/Replace Pairs** node -2. Set the number of pairs you need with `pair_count` -3. Enter your find patterns and replacements +2. Enter your find pattern and replacement in the first pair +3. Click **➕ Add Pair** to add more pairs as needed 4. Connect the `pairs` output to a **Text Replacer** node 5. Enter or connect your input text 6. Get the modified text from the `result` output ### Connecting Dynamic Values -Each find/replace pair has optional input connectors (`find_N_input` and `replace_N_input`) that can receive values from other nodes. Connected inputs take priority over widget values. +Right-click on any find or replace widget and select "Convert to Input" to create a connector that can receive values from other nodes. ### Chaining Replacers @@ -65,11 +65,12 @@ The Text Replacer node passes through the pairs object, allowing you to chain mu | Input | Type | Description | |-------|------|-------------| -| `pair_count` | INT | Number of active pairs (1-50) | | `find_N` | STRING | Find pattern for pair N | | `replace_N` | STRING | Replacement for pair N | -| `find_N_input` | STRING (connector) | Optional connected find pattern | -| `replace_N_input` | STRING (connector) | Optional connected replacement | + +**Buttons:** +- **➕ Add Pair** - Add a new find/replace pair +- **➖ Remove Last Pair** - Remove the last pair (disabled when only 1 pair exists) ### Text Replacer diff --git a/js/multi_replace.js b/js/multi_replace.js index 339e823..0961fe4 100644 --- a/js/multi_replace.js +++ b/js/multi_replace.js @@ -1,18 +1,16 @@ import { app } from "../../scripts/app.js"; +import { ComfyWidgets } from "../../scripts/widgets.js"; /** * ComfyUI Multi-Replace Extension - * Handles dynamic visibility of find/replace pair inputs based on pair_count + * Handles dynamic addition of find/replace pair inputs */ -const MAX_PAIRS = 50; - app.registerExtension({ name: "comfyui-multi-replace", async beforeRegisterNodeDef(nodeType, nodeData, app) { if (nodeData.name === "FindReplacePairs") { - // Store original onNodeCreated const origOnNodeCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function() { @@ -20,84 +18,174 @@ app.registerExtension({ origOnNodeCreated.apply(this, arguments); } - // Find the pair_count widget - const pairCountWidget = this.widgets?.find(w => w.name === "pair_count"); + this.pairCount = 1; + this.serialize_widgets = true; - if (pairCountWidget) { - // Store original callback - const origCallback = pairCountWidget.callback; + // Add the "Add Pair" button + this.addWidget("button", "➕ Add Pair", null, () => { + this.addNewPair(); + }); - // Override callback to update visibility - pairCountWidget.callback = (value, ...args) => { - if (origCallback) { - origCallback.call(pairCountWidget, value, ...args); - } - this.updatePairVisibility(value); - }; + // Add the "Remove Last Pair" button + this.addWidget("button", "➖ Remove Last Pair", null, () => { + this.removeLastPair(); + }); - // Initial visibility update - setTimeout(() => { - this.updatePairVisibility(pairCountWidget.value); - }, 100); - } + // Store reference to button widgets + this.addPairButton = this.widgets[this.widgets.length - 2]; + this.removePairButton = this.widgets[this.widgets.length - 1]; + + this.updateRemoveButtonState(); }; - // Method to update visibility of pair widgets - nodeType.prototype.updatePairVisibility = function(pairCount) { - if (!this.widgets) return; + // Add a new find/replace pair + nodeType.prototype.addNewPair = function() { + this.pairCount++; + const idx = this.pairCount; - const count = parseInt(pairCount) || 1; + // Insert new widgets before the buttons + const buttonIndex = this.widgets.indexOf(this.addPairButton); - for (const widget of this.widgets) { - const match = widget.name.match(/^(find|replace)_(\d+)$/); - if (match) { - const pairIndex = parseInt(match[2]); - // Show widgets for active pairs + 1 empty pair for new entry - const shouldShow = pairIndex <= count; + // Create find widget + const findWidget = ComfyWidgets.STRING( + this, + `find_${idx}`, + ["STRING", { default: "", multiline: false, placeholder: `Find pattern ${idx}` }], + app + ).widget; - // ComfyUI uses different methods to hide widgets - if (shouldShow) { - widget.type = widget._originalType || widget.type; - widget.computeSize = widget._originalComputeSize; - } else { - widget._originalType = widget._originalType || widget.type; - widget._originalComputeSize = widget._originalComputeSize || widget.computeSize; - widget.type = "hidden"; - widget.computeSize = () => [0, -4]; - } - } - } + // Create replace widget + const replaceWidget = ComfyWidgets.STRING( + this, + `replace_${idx}`, + ["STRING", { default: "", multiline: false, placeholder: `Replacement ${idx}` }], + app + ).widget; - // Recalculate node size + // Move the new widgets before the buttons + // Remove from end and insert at button position + this.widgets.splice(this.widgets.length - 2, 2); // Remove the 2 new widgets from end + this.widgets.splice(buttonIndex, 0, findWidget, replaceWidget); + + // Re-add buttons at end + this.widgets.push(this.addPairButton, this.removePairButton); + + this.updateRemoveButtonState(); this.setSize(this.computeSize()); app.graph.setDirtyCanvas(true, true); }; - // Override onConfigure to restore visibility on load + // Remove the last find/replace pair + nodeType.prototype.removeLastPair = function() { + if (this.pairCount <= 1) return; + + const idx = this.pairCount; + const findName = `find_${idx}`; + const replaceName = `replace_${idx}`; + + // Find and remove the widgets + this.widgets = this.widgets.filter(w => + w.name !== findName && w.name !== replaceName + ); + + // Remove inputs if they exist + if (this.inputs) { + this.inputs = this.inputs.filter(input => + input.name !== findName && input.name !== replaceName + ); + } + + this.pairCount--; + this.updateRemoveButtonState(); + this.setSize(this.computeSize()); + app.graph.setDirtyCanvas(true, true); + }; + + // Update remove button enabled state + nodeType.prototype.updateRemoveButtonState = function() { + if (this.removePairButton) { + this.removePairButton.disabled = this.pairCount <= 1; + } + }; + + // Override getExtraMenuOptions to add right-click options + const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions; + nodeType.prototype.getExtraMenuOptions = function(_, options) { + if (origGetExtraMenuOptions) { + origGetExtraMenuOptions.apply(this, arguments); + } + + options.unshift( + { + content: "Add Find/Replace Pair", + callback: () => this.addNewPair() + }, + { + content: "Remove Last Pair", + disabled: this.pairCount <= 1, + callback: () => this.removeLastPair() + }, + null // separator + ); + }; + + // Handle serialization to save dynamic widgets + const origOnSerialize = nodeType.prototype.onSerialize; + nodeType.prototype.onSerialize = function(info) { + if (origOnSerialize) { + origOnSerialize.apply(this, arguments); + } + info.pairCount = this.pairCount; + }; + + // Handle deserialization to restore dynamic widgets const origOnConfigure = nodeType.prototype.onConfigure; - nodeType.prototype.onConfigure = function(config) { + nodeType.prototype.onConfigure = function(info) { + // Restore additional pairs before calling original + if (info.pairCount && info.pairCount > 1) { + const targetCount = info.pairCount; + + // Add pairs up to saved count + while (this.pairCount < targetCount) { + this.addNewPair(); + } + } + if (origOnConfigure) { origOnConfigure.apply(this, arguments); } - setTimeout(() => { - const pairCountWidget = this.widgets?.find(w => w.name === "pair_count"); - if (pairCountWidget) { - this.updatePairVisibility(pairCountWidget.value); + // Restore widget values from saved state + if (info.widgets_values) { + let valueIdx = 0; + for (const widget of this.widgets) { + if (widget.type !== "button" && valueIdx < info.widgets_values.length) { + widget.value = info.widgets_values[valueIdx]; + valueIdx++; + } } - }, 100); + } + }; + + // Allow converting widgets to inputs for connections + const origOnInputAdded = nodeType.prototype.onInputAdded; + nodeType.prototype.onInputAdded = function(input) { + if (origOnInputAdded) { + origOnInputAdded.apply(this, arguments); + } }; } }, async nodeCreated(node) { if (node.comfyClass === "FindReplacePairs") { - // Additional setup after node is fully created - const pairCountWidget = node.widgets?.find(w => w.name === "pair_count"); - if (pairCountWidget) { - setTimeout(() => { - node.updatePairVisibility?.(pairCountWidget.value); - }, 200); + // Enable converting widgets to inputs (right-click -> Convert to Input) + if (node.widgets) { + for (const widget of node.widgets) { + if (widget.name?.startsWith("find_") || widget.name?.startsWith("replace_")) { + widget.convertToInput = true; + } + } } } } diff --git a/nodes/find_replace_pairs.py b/nodes/find_replace_pairs.py index 804b822..c17ba6c 100644 --- a/nodes/find_replace_pairs.py +++ b/nodes/find_replace_pairs.py @@ -4,32 +4,33 @@ Creates a collection of find/replace pairs with dynamic inputs. """ import json -from typing import Any +from server import PromptServer +from aiohttp import web class FindReplacePairs: """ A node that creates an arbitrary number of find/replace pairs. - Each pair can have values entered directly or connected from other nodes. + Pairs are added dynamically via UI button. """ - MAX_PAIRS = 50 # Maximum number of pairs supported - @classmethod def INPUT_TYPES(cls): """ - Define dynamic inputs for find/replace pairs. - We define a base set of inputs plus many optional pair inputs. + Define base inputs. Additional pairs are added dynamically via JS. """ - inputs = { + return { "required": {}, "optional": { - "pair_count": ("INT", { - "default": 1, - "min": 1, - "max": cls.MAX_PAIRS, - "step": 1, - "display": "number" + "find_1": ("STRING", { + "default": "", + "multiline": False, + "placeholder": "Find pattern 1" + }), + "replace_1": ("STRING", { + "default": "", + "multiline": False, + "placeholder": "Replacement 1" }), }, "hidden": { @@ -37,28 +38,6 @@ class FindReplacePairs: } } - # Add optional inputs for each potential pair - for i in range(1, cls.MAX_PAIRS + 1): - inputs["optional"][f"find_{i}"] = ("STRING", { - "default": "", - "multiline": False, - "placeholder": f"Find pattern {i}" - }) - inputs["optional"][f"replace_{i}"] = ("STRING", { - "default": "", - "multiline": False, - "placeholder": f"Replacement {i}" - }) - # Also allow connected string inputs (these override the widget values) - inputs["optional"][f"find_{i}_input"] = ("STRING", { - "forceInput": True, - }) - inputs["optional"][f"replace_{i}_input"] = ("STRING", { - "forceInput": True, - }) - - return inputs - RETURN_TYPES = ("FR_PAIRS", "STRING", "STRING",) RETURN_NAMES = ("pairs", "json_output", "csv_output",) OUTPUT_TOOLTIPS = ( @@ -71,36 +50,32 @@ class FindReplacePairs: CATEGORY = "text/replace" DESCRIPTION = "Creates a collection of find/replace pairs that can be used with the TextReplacer node." - def create_pairs(self, pair_count=1, unique_id=None, **kwargs): + def create_pairs(self, unique_id=None, **kwargs): """ Create the find/replace pairs collection. Args: - pair_count: Number of active pairs unique_id: Node's unique ID - **kwargs: Dynamic find_N, replace_N, find_N_input, replace_N_input values + **kwargs: Dynamic find_N, replace_N values Returns: Tuple of (pairs_dict, json_string, csv_string) """ pairs = [] - for i in range(1, pair_count + 1): - # Check for connected input first, fall back to widget value - find_key = f"find_{i}_input" - find_widget_key = f"find_{i}" - replace_key = f"replace_{i}_input" - replace_widget_key = f"replace_{i}" + # Collect all pairs from kwargs (find_1, replace_1, find_2, replace_2, etc.) + pair_indices = set() + for key in kwargs: + if key.startswith("find_") or key.startswith("replace_"): + try: + idx = int(key.split("_")[1]) + pair_indices.add(idx) + except (ValueError, IndexError): + pass - # Get find value (connected input takes priority) - find_value = kwargs.get(find_key) - if find_value is None or find_value == "": - find_value = kwargs.get(find_widget_key, "") - - # Get replace value (connected input takes priority) - replace_value = kwargs.get(replace_key) - if replace_value is None or replace_value == "": - replace_value = kwargs.get(replace_widget_key, "") + for i in sorted(pair_indices): + find_value = kwargs.get(f"find_{i}", "") + replace_value = kwargs.get(f"replace_{i}", "") # Only add non-empty find patterns if find_value: @@ -136,13 +111,12 @@ class FindReplacePairs: return (pairs_obj, json_output, csv_output) @classmethod - def IS_CHANGED(cls, pair_count=1, **kwargs): + def IS_CHANGED(cls, **kwargs): """Force re-evaluation when inputs change.""" - # Create a hash of all input values - values = [str(pair_count)] - for i in range(1, pair_count + 1): - values.append(str(kwargs.get(f"find_{i}", ""))) - values.append(str(kwargs.get(f"replace_{i}", ""))) - values.append(str(kwargs.get(f"find_{i}_input", ""))) - values.append(str(kwargs.get(f"replace_{i}_input", ""))) - return hash(tuple(values)) + return hash(str(kwargs)) + + +# API route to get node info (optional, for debugging) +@PromptServer.instance.routes.get("/multi_replace/info") +async def get_info(request): + return web.json_response({"status": "ok", "name": "multi-replace"})