commit 247463d9c3369f0a7c6c8fd96428cbe3f513aa6b Author: jonstreeter Date: Wed Dec 17 21:22:44 2025 -0600 Initial release v1.0.0 - Wireless Toggle Master diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..28f46d6 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..14e5271 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0ed81c2 --- /dev/null +++ b/README.md @@ -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. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..65aa23b --- /dev/null +++ b/__init__.py @@ -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"] diff --git a/fast_groups_muter.txt b/fast_groups_muter.txt new file mode 100644 index 0000000..ae5175a --- /dev/null +++ b/fast_groups_muter.txt @@ -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 ` +

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.

+ `; + } +} + +/** + * 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; + } + }, +}); diff --git a/js/wireless_master_toggle.js b/js/wireless_master_toggle.js new file mode 100644 index 0000000..b8e9119 --- /dev/null +++ b/js/wireless_master_toggle.js @@ -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"; + } +}); diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..794e753 --- /dev/null +++ b/pyproject.toml @@ -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 = ""