Initial release v1.0.0 - Wireless Toggle Master
This commit is contained in:
@@ -0,0 +1 @@
|
||||
__pycache__/
|
||||
@@ -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.
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
});
|
||||
@@ -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 = ""
|
||||
Reference in New Issue
Block a user