Initial release v1.0.0 - Wireless Toggle Master

This commit is contained in:
jonstreeter
2025-12-17 21:22:44 -06:00
commit 247463d9c3
7 changed files with 840 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
__pycache__/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Jon Streeter
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.
+64
View File
@@ -0,0 +1,64 @@
# ComfyUI Wireless Toggle Master
Control any nodes in your ComfyUI workflow by **title** or **color** matching. Toggle nodes on/off with a single click, with optional radio-button style restrictions.
## Features
- 🎯 **Wireless** - No connections needed! Control nodes anywhere in your workflow
- 🔍 **Regex Matching** - Filter nodes by title using regular expressions
- 🎨 **Color Matching** - Filter nodes by their node color
- 📦 **Subgraph Support** - Works across nested subgraphs
- 🔘 **Radio Mode** - Optional "max one" or "always one" restriction for exclusive selection
## Installation
### ComfyUI Manager (Recommended)
Search for "Wireless Toggle Master" in ComfyUI Manager and install.
### Manual Installation
```bash
cd ComfyUI/custom_nodes
git clone https://github.com/jonstreeter/comfyui-togglemaster
```
Restart ComfyUI after installation.
## Usage
1. Add **Wireless Master Toggle** node (found in `utils` category)
2. Right-click the node → **Properties Panel**
3. Set your filter criteria:
- `matchTitle`: Regex pattern (e.g., `^Char.*` for nodes starting with "Char")
- `matchColors`: Comma-separated colors (e.g., `red, #ff0000`)
4. Toggle matching nodes on/off using the generated toggle widgets
### Properties
| Property | Type | Description |
|----------|------|-------------|
| `matchTitle` | string | Regex pattern to match node titles |
| `matchColors` | string | Comma-separated list of colors to match |
| `showNav` | boolean | Show navigation arrows |
| `showAllGraphs` | boolean | Include nodes from subgraphs |
| `sort` | combo | Sort order: `position` or `alphanumeric` |
| `toggleRestriction` | combo | `default`, `max one`, or `always one` |
### Toggle Restriction Modes
- **default**: Multiple nodes can be enabled simultaneously
- **max one**: At most one node can be enabled at a time
- **always one**: Exactly one node is always enabled (radio button behavior)
## Example Use Cases
- **Character Selection**: Match nodes named `Char_A`, `Char_B`, etc. with "always one" for exclusive character selection
- **Style Presets**: Toggle between different style/LoRA preset nodes
- **A/B Testing**: Quickly switch between alternative workflow branches
## License
MIT License
## Credits
Inspired by [rgthree-comfy](https://github.com/rgthree/rgthree-comfy) Fast Groups Muter/Bypasser nodes.
+14
View File
@@ -0,0 +1,14 @@
"""
ComfyUI Wireless Toggle Master
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 = {}
WEB_DIRECTORY = "./js"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
+542
View File
@@ -0,0 +1,542 @@
import type {
LGraphNode,
LGraph as TLGraph,
LGraphCanvas as TLGraphCanvas,
Vector2,
Size,
LGraphGroup,
CanvasMouseEvent,
Point,
} from "@comfyorg/frontend";
import {app} from "scripts/app.js";
import {RgthreeBaseVirtualNode} from "./base_node.js";
import {NodeTypesString} from "./constants.js";
import {SERVICE as FAST_GROUPS_SERVICE} from "./services/fast_groups_service.js";
import {drawNodeWidget, fitString} from "./utils_canvas.js";
import {RgthreeBaseWidget} from "./utils_widgets.js";
import { changeModeOfNodes, getGroupNodes } from "./utils.js";
const PROPERTY_SORT = "sort";
const PROPERTY_SORT_CUSTOM_ALPHA = "customSortAlphabet";
const PROPERTY_MATCH_COLORS = "matchColors";
const PROPERTY_MATCH_TITLE = "matchTitle";
const PROPERTY_SHOW_NAV = "showNav";
const PROPERTY_SHOW_ALL_GRAPHS = "showAllGraphs";
const PROPERTY_RESTRICTION = "toggleRestriction";
/**
* Fast Muter implementation that looks for groups in the workflow and adds toggles to mute them.
*/
export abstract class BaseFastGroupsModeChanger extends RgthreeBaseVirtualNode {
static override type = NodeTypesString.FAST_GROUPS_MUTER;
static override title = NodeTypesString.FAST_GROUPS_MUTER;
static override exposedActions = ["Mute all", "Enable all", "Toggle all"];
readonly modeOn: number = LiteGraph.ALWAYS;
readonly modeOff: number = LiteGraph.NEVER;
private debouncerTempWidth: number = 0;
tempSize: Vector2 | null = null;
// We don't need to serizalize since we'll just be checking group data on startup anyway
override serialize_widgets = false;
protected helpActions = "mute and unmute";
static "@matchColors" = {type: "string"};
static "@matchTitle" = {type: "string"};
static "@showNav" = {type: "boolean"};
static "@showAllGraphs" = {type: "boolean"};
static "@sort" = {
type: "combo",
values: ["position", "alphanumeric", "custom alphabet"],
};
static "@customSortAlphabet" = {type: "string"};
override properties!: RgthreeBaseVirtualNode["properties"] & {
[PROPERTY_MATCH_COLORS]: string;
[PROPERTY_MATCH_TITLE]: string;
[PROPERTY_SHOW_NAV]: boolean;
[PROPERTY_SHOW_ALL_GRAPHS]: boolean;
[PROPERTY_SORT]: string;
[PROPERTY_SORT_CUSTOM_ALPHA]: string;
[PROPERTY_RESTRICTION]: string;
};
static "@toggleRestriction" = {
type: "combo",
values: ["default", "max one", "always one"],
};
constructor(title = FastGroupsMuter.title) {
super(title);
this.properties[PROPERTY_MATCH_COLORS] = "";
this.properties[PROPERTY_MATCH_TITLE] = "";
this.properties[PROPERTY_SHOW_NAV] = true;
this.properties[PROPERTY_SHOW_ALL_GRAPHS] = true;
this.properties[PROPERTY_SORT] = "position";
this.properties[PROPERTY_SORT_CUSTOM_ALPHA] = "";
this.properties[PROPERTY_RESTRICTION] = "default";
}
override onConstructed(): boolean {
this.addOutput("OPT_CONNECTION", "*");
return super.onConstructed();
}
override onAdded(graph: TLGraph): void {
FAST_GROUPS_SERVICE.addFastGroupNode(this);
}
override onRemoved(): void {
FAST_GROUPS_SERVICE.removeFastGroupNode(this);
}
refreshWidgets() {
const canvas = app.canvas as TLGraphCanvas;
let sort = this.properties?.[PROPERTY_SORT] || "position";
let customAlphabet: string[] | null = null;
if (sort === "custom alphabet") {
const customAlphaStr = this.properties?.[PROPERTY_SORT_CUSTOM_ALPHA]?.replace(/\n/g, "");
if (customAlphaStr && customAlphaStr.trim()) {
customAlphabet = customAlphaStr.includes(",")
? customAlphaStr.toLocaleLowerCase().split(",")
: customAlphaStr.toLocaleLowerCase().trim().split("");
}
if (!customAlphabet?.length) {
sort = "alphanumeric";
customAlphabet = null;
}
}
const groups = [...FAST_GROUPS_SERVICE.getGroups(sort)];
// The service will return pre-sorted groups for alphanumeric and position. If this node has a
// custom sort, then we need to sort it manually.
if (customAlphabet?.length) {
groups.sort((a, b) => {
let aIndex = -1;
let bIndex = -1;
// Loop and find indexes. As we're finding multiple, a single for loop is more efficient.
for (const [index, alpha] of customAlphabet!.entries()) {
aIndex =
aIndex < 0 ? (a.title.toLocaleLowerCase().startsWith(alpha) ? index : -1) : aIndex;
bIndex =
bIndex < 0 ? (b.title.toLocaleLowerCase().startsWith(alpha) ? index : -1) : bIndex;
if (aIndex > -1 && bIndex > -1) {
break;
}
}
// Now compare.
if (aIndex > -1 && bIndex > -1) {
const ret = aIndex - bIndex;
if (ret === 0) {
return a.title.localeCompare(b.title);
}
return ret;
} else if (aIndex > -1) {
return -1;
} else if (bIndex > -1) {
return 1;
}
return a.title.localeCompare(b.title);
});
}
// See if we're filtering by colors, and match against the built-in keywords and actuial hex
// values.
let filterColors = (
(this.properties?.[PROPERTY_MATCH_COLORS] as string)?.split(",") || []
).filter((c) => c.trim());
if (filterColors.length) {
filterColors = filterColors.map((color) => {
color = color.trim().toLocaleLowerCase();
if (LGraphCanvas.node_colors[color]) {
color = LGraphCanvas.node_colors[color]!.groupcolor;
}
color = color.replace("#", "").toLocaleLowerCase();
if (color.length === 3) {
color = color.replace(/(.)(.)(.)/, "$1$1$2$2$3$3");
}
return `#${color}`;
});
}
// Go over the groups
let index = 0;
for (const group of groups) {
if (filterColors.length) {
let groupColor = group.color?.replace("#", "").trim().toLocaleLowerCase();
if (!groupColor) {
continue;
}
if (groupColor.length === 3) {
groupColor = groupColor.replace(/(.)(.)(.)/, "$1$1$2$2$3$3");
}
groupColor = `#${groupColor}`;
if (!filterColors.includes(groupColor)) {
continue;
}
}
if (this.properties?.[PROPERTY_MATCH_TITLE]?.trim()) {
try {
if (!new RegExp(this.properties[PROPERTY_MATCH_TITLE], "i").exec(group.title)) {
continue;
}
} catch (e) {
console.error(e);
continue;
}
}
const showAllGraphs = this.properties?.[PROPERTY_SHOW_ALL_GRAPHS];
if (!showAllGraphs && group.graph !== app.canvas.getCurrentGraph()) {
continue;
}
let isDirty = false;
const widgetLabel = `Enable ${group.title}`;
let widget = this.widgets.find((w) => w.label === widgetLabel) as FastGroupsToggleRowWidget;
if (!widget) {
// When we add a widget, litegraph is going to mess up the size, so we
// store it so we can retrieve it in computeSize. Hacky..
this.tempSize = [...this.size] as Size;
widget = this.addCustomWidget(
new FastGroupsToggleRowWidget(group, this),
) as FastGroupsToggleRowWidget;
this.setSize(this.computeSize());
isDirty = true;
}
if (widget.label != widgetLabel) {
widget.label = widgetLabel;
isDirty = true;
}
if (
group.rgthree_hasAnyActiveNode != null &&
widget.toggled != group.rgthree_hasAnyActiveNode
) {
widget.toggled = group.rgthree_hasAnyActiveNode;
isDirty = true;
}
if (this.widgets[index] !== widget) {
const oldIndex = this.widgets.findIndex((w) => w === widget);
this.widgets.splice(index, 0, this.widgets.splice(oldIndex, 1)[0]!);
isDirty = true;
}
if (isDirty) {
this.setDirtyCanvas(true, false);
}
index++;
}
// Everything should now be in order, so let's remove all remaining widgets.
while ((this.widgets || [])[index]) {
this.removeWidget(index++);
}
}
override computeSize(out?: Vector2) {
let size = super.computeSize(out);
if (this.tempSize) {
size[0] = Math.max(this.tempSize[0], size[0]);
size[1] = Math.max(this.tempSize[1], size[1]);
// We sometimes get repeated calls to compute size, so debounce before clearing.
this.debouncerTempWidth && clearTimeout(this.debouncerTempWidth);
this.debouncerTempWidth = setTimeout(() => {
this.tempSize = null;
}, 32);
}
setTimeout(() => {
this.graph?.setDirtyCanvas(true, true);
}, 16);
return size;
}
override async handleAction(action: string) {
if (action === "Mute all" || action === "Bypass all") {
const alwaysOne = this.properties?.[PROPERTY_RESTRICTION] === "always one";
for (const [index, widget] of this.widgets.entries()) {
(widget as any)?.doModeChange(alwaysOne && !index ? true : false, true);
}
} else if (action === "Enable all") {
const onlyOne = this.properties?.[PROPERTY_RESTRICTION].includes(" one");
for (const [index, widget] of this.widgets.entries()) {
(widget as any)?.doModeChange(onlyOne && index > 0 ? false : true, true);
}
} else if (action === "Toggle all") {
const onlyOne = this.properties?.[PROPERTY_RESTRICTION].includes(" one");
let foundOne = false;
for (const [index, widget] of this.widgets.entries()) {
// If you have only one, then we'll stop at the first.
let newValue: boolean = onlyOne && foundOne ? false : !widget.value;
foundOne = foundOne || newValue;
(widget as any)?.doModeChange(newValue, true);
}
// And if you have always one, then we'll flip the last
if (!foundOne && this.properties?.[PROPERTY_RESTRICTION] === "always one") {
(this.widgets[this.widgets.length - 1] as any)?.doModeChange(true, true);
}
}
}
override getHelp() {
return `
<p>The ${this.type!.replace(
"(rgthree)",
"",
)} is an input-less node that automatically collects all groups in your current
workflow and allows you to quickly ${this.helpActions} all nodes within the group.</p>
<ul>
<li>
<p>
<strong>Properties.</strong> You can change the following properties (by right-clicking
on the node, and select "Properties" or "Properties Panel" from the menu):
</p>
<ul>
<li><p>
<code>${PROPERTY_MATCH_COLORS}</code> - Only add groups that match the provided
colors. Can be ComfyUI colors (red, pale_blue) or hex codes (#a4d399). Multiple can be
added, comma delimited.
</p></li>
<li><p>
<code>${PROPERTY_MATCH_TITLE}</code> - Filter the list of toggles by title match
(string match, or regular expression).
</p></li>
<li><p>
<code>${PROPERTY_SHOW_NAV}</code> - Add / remove a quick navigation arrow to take you
to the group. <i>(default: true)</i>
</p></li>
<li><p>
<code>${PROPERTY_SHOW_ALL_GRAPHS}</code> - Show groups from all [sub]graphs in the
workflow. <i>(default: true)</i>
</p></li>
<li><p>
<code>${PROPERTY_SORT}</code> - Sort the toggles' order by "alphanumeric", graph
"position", or "custom alphabet". <i>(default: "position")</i>
</p></li>
<li>
<p>
<code>${PROPERTY_SORT_CUSTOM_ALPHA}</code> - When the
<code>${PROPERTY_SORT}</code> property is "custom alphabet" you can define the
alphabet to use here, which will match the <i>beginning</i> of each group name and
sort against it. If group titles do not match any custom alphabet entry, then they
will be put after groups that do, ordered alphanumerically.
</p>
<p>
This can be a list of single characters, like "zyxw..." or comma delimited strings
for more control, like "sdxl,pro,sd,n,p".
</p>
<p>
Note, when two group title match the same custom alphabet entry, the <i>normal
alphanumeric alphabet</i> breaks the tie. For instance, a custom alphabet of
"e,s,d" will order groups names like "SDXL, SEGS, Detailer" eventhough the custom
alphabet has an "e" before "d" (where one may expect "SE" to be before "SD").
</p>
<p>
To have "SEGS" appear before "SDXL" you can use longer strings. For instance, the
custom alphabet value of "se,s,f" would work here.
</p>
</li>
<li><p>
<code>${PROPERTY_RESTRICTION}</code> - Optionally, attempt to restrict the number of
widgets that can be enabled to a maximum of one, or always one.
</p>
<p><em><strong>Note:</strong> If using "max one" or "always one" then this is only
enforced when clicking a toggle on this node; if nodes within groups are changed
outside of the initial toggle click, then these restriction will not be enforced, and
could result in a state where more than one toggle is enabled. This could also happen
if nodes are overlapped with multiple groups.
</p></li>
</ul>
</li>
</ul>`;
}
}
/**
* Fast Bypasser implementation that looks for groups in the workflow and adds toggles to mute them.
*/
export class FastGroupsMuter extends BaseFastGroupsModeChanger {
static override type = NodeTypesString.FAST_GROUPS_MUTER;
static override title = NodeTypesString.FAST_GROUPS_MUTER;
override comfyClass = NodeTypesString.FAST_GROUPS_MUTER;
static override exposedActions = ["Bypass all", "Enable all", "Toggle all"];
protected override helpActions = "mute and unmute";
override readonly modeOn: number = LiteGraph.ALWAYS;
override readonly modeOff: number = LiteGraph.NEVER;
constructor(title = FastGroupsMuter.title) {
super(title);
this.onConstructed();
}
}
/**
* The PowerLoraLoaderHeaderWidget that renders a toggle all switch, as well as some title info
* (more necessary for the double model & clip strengths to label them).
*/
class FastGroupsToggleRowWidget extends RgthreeBaseWidget<{toggled: boolean}> {
override value = {toggled: false};
override options = {on: "yes", off: "no"};
override readonly type = "custom";
label: string = "";
group: LGraphGroup;
node: BaseFastGroupsModeChanger;
constructor(group: LGraphGroup, node: BaseFastGroupsModeChanger) {
super("RGTHREE_TOGGLE_AND_NAV");
this.group = group;
this.node = node;
}
doModeChange(force?: boolean, skipOtherNodeCheck?: boolean) {
this.group.recomputeInsideNodes();
const hasAnyActiveNodes = getGroupNodes(this.group).some((n) => n.mode === LiteGraph.ALWAYS);
let newValue = force != null ? force : !hasAnyActiveNodes;
if (skipOtherNodeCheck !== true) {
// TODO: This work should probably live in BaseFastGroupsModeChanger instead of the widgets.
if (newValue && this.node.properties?.[PROPERTY_RESTRICTION]?.includes(" one")) {
for (const widget of this.node.widgets) {
if (widget instanceof FastGroupsToggleRowWidget) {
widget.doModeChange(false, true);
}
}
} else if (!newValue && this.node.properties?.[PROPERTY_RESTRICTION] === "always one") {
newValue = this.node.widgets.every((w) => !w.value || w === this);
}
}
changeModeOfNodes(getGroupNodes(this.group), (newValue ? this.node.modeOn : this.node.modeOff));
this.group.rgthree_hasAnyActiveNode = newValue;
this.toggled = newValue;
this.group.graph?.setDirtyCanvas(true, false);
}
get toggled() {
return this.value.toggled;
}
set toggled(value: boolean) {
this.value.toggled = value;
}
toggle(value?: boolean) {
value = value == null ? !this.toggled : value;
if (value !== this.toggled) {
this.value.toggled = value;
this.doModeChange();
}
}
draw(
ctx: CanvasRenderingContext2D,
node: FastGroupsMuter,
width: number,
posY: number,
height: number,
) {
const widgetData = drawNodeWidget(ctx, {size: [width, height], pos: [15, posY]});
const showNav = node.properties?.[PROPERTY_SHOW_NAV] !== false;
// Render from right to left, since the text on left will take available space.
// `currentX` markes the current x position moving backwards.
let currentX = widgetData.width - widgetData.margin;
// The nav arrow
if (!widgetData.lowQuality && showNav) {
currentX -= 7; // Arrow space margin
const midY = widgetData.posY + widgetData.height * 0.5;
ctx.fillStyle = ctx.strokeStyle = "#89A";
ctx.lineJoin = "round";
ctx.lineCap = "round";
const arrow = new Path2D(`M${currentX} ${midY} l -7 6 v -3 h -7 v -6 h 7 v -3 z`);
ctx.fill(arrow);
ctx.stroke(arrow);
currentX -= 14;
currentX -= 7;
ctx.strokeStyle = widgetData.colorOutline;
ctx.stroke(new Path2D(`M ${currentX} ${widgetData.posY} v ${widgetData.height}`));
} else if (widgetData.lowQuality && showNav) {
currentX -= 28;
}
// The toggle itself.
currentX -= 7;
ctx.fillStyle = this.toggled ? "#89A" : "#333";
ctx.beginPath();
const toggleRadius = height * 0.36;
ctx.arc(currentX - toggleRadius, posY + height * 0.5, toggleRadius, 0, Math.PI * 2);
ctx.fill();
currentX -= toggleRadius * 2;
if (!widgetData.lowQuality) {
currentX -= 4;
ctx.textAlign = "right";
ctx.fillStyle = this.toggled ? widgetData.colorText : widgetData.colorTextSecondary;
const label = this.label;
const toggleLabelOn = this.options.on || "true";
const toggleLabelOff = this.options.off || "false";
ctx.fillText(this.toggled ? toggleLabelOn : toggleLabelOff, currentX, posY + height * 0.7);
currentX -= Math.max(
ctx.measureText(toggleLabelOn).width,
ctx.measureText(toggleLabelOff).width,
);
currentX -= 7;
ctx.textAlign = "left";
let maxLabelWidth = widgetData.width - widgetData.margin - 10 - (widgetData.width - currentX);
if (label != null) {
ctx.fillText(
fitString(ctx, label, maxLabelWidth),
widgetData.margin + 10,
posY + height * 0.7,
);
}
}
}
override serializeValue(node: LGraphNode, index: number) {
return this.value;
}
override mouse(event: CanvasMouseEvent, pos: Vector2, node: LGraphNode): boolean {
if (event.type == "pointerdown") {
if (node.properties?.[PROPERTY_SHOW_NAV] !== false && pos[0] >= node.size[0] - 15 - 28 - 1) {
const canvas = app.canvas as TLGraphCanvas;
const lowQuality = (canvas.ds?.scale || 1) <= 0.5;
if (!lowQuality) {
// Clicked on right half with nav arrow, go to the group, center on group and set
// zoom to see it all.
canvas.centerOnNode(this.group);
const zoomCurrent = canvas.ds?.scale || 1;
const zoomX = canvas.canvas.width / this.group._size[0] - 0.02;
const zoomY = canvas.canvas.height / this.group._size[1] - 0.02;
canvas.setZoom(Math.min(zoomCurrent, zoomX, zoomY), [
canvas.canvas.width / 2,
canvas.canvas.height / 2,
]);
canvas.setDirty(true, true);
}
} else {
this.toggle();
}
}
return true;
}
}
app.registerExtension({
name: "rgthree.FastGroupsMuter",
registerCustomNodes() {
FastGroupsMuter.setUp();
},
loadedGraphNode(node: LGraphNode) {
if (node.type == FastGroupsMuter.title) {
(node as FastGroupsMuter).tempSize = [...node.size] as Point;
}
},
});
+183
View File
@@ -0,0 +1,183 @@
import { app } from "../../scripts/app.js";
// Property constants
const PROPERTY_MATCH_COLORS = "matchColors";
const PROPERTY_MATCH_TITLE = "matchTitle";
const PROPERTY_SHOW_NAV = "showNav";
const PROPERTY_SHOW_ALL_GRAPHS = "showAllGraphs";
const PROPERTY_SORT = "sort";
const PROPERTY_SORT_CUSTOM_ALPHA = "customSortAlphabet";
const PROPERTY_RESTRICTION = "toggleRestriction";
/**
* Recursively find all nodes in a graph and its subgraphs.
*/
function findAllNodesRecursive(graph, predicate) {
let results = [];
if (!graph || !graph._nodes) return results;
for (const node of graph._nodes) {
if (predicate(node)) {
results.push(node);
}
if (node.subgraph) {
results.push(...findAllNodesRecursive(node.subgraph, predicate));
}
}
return results;
}
/**
* Wireless Master Toggle - Control nodes anywhere in the workflow by title/color match.
*
* Features:
* - Filter nodes by title (regex) and/or color
* - Toggle nodes on/off (mute/unmute)
* - Works across subgraphs
* - Restriction modes: "max one" or "always one" for radio-button behavior
*/
class WirelessMasterToggle extends LiteGraph.LGraphNode {
static title = "Wireless Master Toggle";
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 ["@sort"] = {
type: "combo",
values: ["position", "alphanumeric"]
};
static ["@customSortAlphabet"] = {type: "string"};
static ["@toggleRestriction"] = {
type: "combo",
values: ["default", "max one", "always one"]
};
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] = "";
this.properties[PROPERTY_SHOW_NAV] = true;
this.properties[PROPERTY_SHOW_ALL_GRAPHS] = true;
this.properties[PROPERTY_SORT] = "position";
this.properties[PROPERTY_SORT_CUSTOM_ALPHA] = "";
this.properties[PROPERTY_RESTRICTION] = "default";
}
onNodeCreated() {
// Auto-refresh on creation
this.refreshWidgets();
}
onPropertyChanged(name, value) {
// Auto-refresh toggles when properties change
this.refreshWidgets();
return true;
}
refreshWidgets() {
// Clear all existing widgets
if (this.widgets) {
this.widgets.length = 0;
}
const matchTitleStr = this.properties[PROPERTY_MATCH_TITLE];
const matchColorsStr = this.properties[PROPERTY_MATCH_COLORS];
let regex = null;
if (matchTitleStr) {
try {
regex = new RegExp(matchTitleStr, "i");
} catch (e) {
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());
return;
}
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));
} else {
matchedNodes.sort((a, b) => a.id - b.id);
}
for (const node of matchedNodes) {
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());
this.setDirtyCanvas(true);
}
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);
}
}
}
this.applyToggle(targetNode, enabled);
}
applyToggle(targetNode, enabled) {
if (targetNode) {
targetNode.mode = enabled ? 0 : 2;
}
}
}
app.registerExtension({
name: "togglemaster.WirelessMasterToggle",
registerCustomNodes() {
LiteGraph.registerNodeType("Wireless Master Toggle", WirelessMasterToggle);
WirelessMasterToggle.category = "utils";
}
});
+15
View File
@@ -0,0 +1,15 @@
[project]
name = "comfyui-togglemaster"
version = "1.0.0"
description = "Wireless node toggle control for ComfyUI - control any nodes by title/color matching with radio-button restrictions"
license = "MIT"
readme = "README.md"
requires-python = ">=3.10"
[project.urls]
Repository = "https://github.com/jonstreeter/comfyui-togglemaster"
[tool.comfy]
PublisherId = "jonstreeter"
DisplayName = "Wireless Toggle Master"
Icon = ""