diff --git a/README.md b/README.md index d1dd6de..13de375 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,7 @@ ## 概要 1ノードで複数のbooleanトグルを管理できるComfyUIカスタムノードです。 -最大20個の名前付きトグルを持ち、各トグルは独立したBOOLEAN出力ソケットを持ちます。 -`slot_count` でトグル数を1〜20の範囲で調整でき、`all_switch` で全トグルをONにできます。 +ノード自身の名前(タイトル)を接頭語にして操作対象ノードを自動検出し、各対象にon/offトグルを表示します。出力ソケットはありません。 ## インストール @@ -17,32 +16,37 @@ **手動コピーの場合**: このフォルダ(`MultiBoolPanel`)を `ComfyUI/custom_nodes/` にコピー → ComfyUI再起動 →「Multi Bool Panel」を検索して追加 -## 使い方(基本) +## 使い方 -- トグルスイッチのON/OFFがそのまま `Switch 1`〜`Switch 20` のBOOLEAN出力に出ます -- `slot_count` を超える番号の出力は常に `False` です -- `Labels` 出力には、ONかつラベル名が設定されているトグル名がカンマ区切りで出力されます -- トグル名は `label_N` で変更できます +1. 「Multi Bool Panel」ノードを追加します +2. パネルのタイトルをダブルクリックして、接頭語にしたい名前に変更します(例: `A`) +3. タイトルが `A:名前` で始まるノードが自動的に操作対象になり、パネル内に「名前」のリストとトグルスイッチが表示されます +4. トグルを切り替えると対象ノードに即時適用されます -## 使い方(一括操作) +例: -`name_filter` にノード名(タイトルまたはクラス名)の部分文字列を指定します。 -`broadcast_mode` で適用方法を選択します: +| パネルのタイトル | 対象になるノードのタイトル | パネル内の表示 | +|---|---|---| +| `A` | `A:高画質`、`A:顔修正`、`A:背景除去` | 高画質 / 顔修正 / 背景除去 の3行 | -- `widgets`: 対象ノードのbooleanウィジェット(チェックボックス)全てをトグル値に書き換えます +## モード + +`mode` ウィジェットで適用方法を選択します: + +- `widgets`: 対象ノードの最初のbooleanウィジェット(チェックボックス)を切り替えます。booleanウィジェットが無いノードの行はグレー表示され操作できません - `bypass`: 対象ノードのbypass状態を切り替えます - `mute`: 対象ノードのmute状態を切り替えます -- `off`: 一括操作しません(通常のトグルパネルとして使用) -トグル番号Nは「フィルタに一致するN番目のノード(タイトル順ソート)」に対応します。 -`all_switch` がONの場合は全対象ノードがONになります。 - -適用タイミング: -- ノード上の「Apply to matching nodes」ボタン(手動) -- `auto_apply` がONの場合は、キュー実行時に自動適用されます +トグルはクリックした瞬間に適用されます。トグル状態はワークフローに保存されます。 ## 制限・注意 -- トグルは最大20個固定です -- `widgets` モードは対象ノードのBOOLEAN型ウィジェットのみ書き換えます(入力ソケット経由の接続には影響しません) -- 名前フィルタは部分一致で、タイトルとクラス名の両方を確認します +- 操作対象は最大20件まで表示されます(超過分は「他N件」と表示) +- 対象の照合は「パネルのタイトル + `:`」の完全一致です(大文字小文字は区別されます) +- パネル自身は操作対象になりません +- ノード追加・削除・リネームは自動で反映されます + +## バージョン履歴 + +- **v2.0.0**: 出力ソケットを撤去し、タイトル接頭語による自動検出方式に変更(破壊的変更) +- **v1.0.0**: 初期公開(名前付きトグル + 独立BOOLEAN出力 + 名前フィルタによる一括操作) diff --git a/multi_bool_panel.py b/multi_bool_panel.py index 120bb33..f95848a 100644 --- a/multi_bool_panel.py +++ b/multi_bool_panel.py @@ -1,48 +1,25 @@ """ -Multi Bool Panel — 1ノードに複数の名前付きbooleanトグルを持つComfyUIカスタムノード。 -各トグルは独立したBOOLEAN出力ソケットを持つ。slot_countでトグル数を調整できる。 -名前フィルタによる一括操作(他ノードのbooleanウィジェット / bypass / mute)は -フロントエンド拡張 (web/js/multi_bool_panel.js) が担当する。 +Multi Bool Panel — 1ノードで他ノードへの一括boolean操作 (widgets/bypass/mute) を +行うComfyUIカスタムノード。出力ソケットは持たず、操作はフロントエンド拡張 +(web/js/multi_bool_panel.js) が担当する。 """ -MAX_SLOTS = 20 - class SolidlimeMultiBoolPanel: @classmethod def INPUT_TYPES(cls): - required = { - "slot_count": ("INT", {"default": 4, "min": 1, "max": MAX_SLOTS, "step": 1}), - "name_filter": ("STRING", {"default": "", "multiline": False}), - "broadcast_mode": (["off", "widgets", "bypass", "mute"], {"default": "off"}), - "auto_apply": ("BOOLEAN", {"default": False}), - "all_switch": ("BOOLEAN", {"default": False}), + return { + "required": {"mode": (["widgets", "bypass", "mute"], {"default": "widgets"})} } - for i in range(1, MAX_SLOTS + 1): - required[f"label_{i}"] = ("STRING", {"default": "", "multiline": False}) - required[f"switch_{i}"] = ("BOOLEAN", {"default": False}) - return {"required": required} - RETURN_TYPES = ("STRING",) + ("BOOLEAN",) * MAX_SLOTS - RETURN_NAMES = ("Labels",) + tuple(f"Switch {i}" for i in range(1, MAX_SLOTS + 1)) + RETURN_TYPES = () + RETURN_NAMES = () + OUTPUT_NODE = False FUNCTION = "execute" CATEGORY = "Logic" def execute(self, **kwargs): - slot_count = max(1, min(MAX_SLOTS, int(kwargs.get("slot_count", 4)))) - all_switch = bool(kwargs.get("all_switch", False)) - labels_out = [] - out = [] - for i in range(1, MAX_SLOTS + 1): - if i > slot_count: - out.append(False) - continue - label = str(kwargs.get(f"label_{i}", "") or "") - val = bool(kwargs.get(f"switch_{i}", False)) or all_switch - out.append(val) - if val and label: - labels_out.append(label) - return (", ".join(labels_out),) + tuple(out) + return () NODE_CLASS_MAPPINGS = {"SolidlimeMultiBoolPanel": SolidlimeMultiBoolPanel} diff --git a/pyproject.toml b/pyproject.toml index 04a12b2..4bee3b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "multi-bool-panel" -description = "Multiple named boolean toggle switches in a single node, each with an independent BOOLEAN output. Batch-apply to other nodes by name filter (widgets/bypass/mute)." -version = "1.0.0" +description = "Boolean toggle panel with no outputs: auto-detects target nodes by title prefix (panel named \"A\" controls nodes named \"A:...\") and applies widget/bypass/mute states." +version = "2.0.0" license = { file = "LICENSE" } dependencies = [] diff --git a/web/js/multi_bool_panel.js b/web/js/multi_bool_panel.js index 0de04e4..2cb73a6 100644 --- a/web/js/multi_bool_panel.js +++ b/web/js/multi_bool_panel.js @@ -1,55 +1,304 @@ import { app } from "../../scripts/app.js"; const PANEL_TYPE = "SolidlimeMultiBoolPanel"; +const ROW_HEIGHT = 23; +const HEADER_PAD = 6; +const MAX_ITEMS = 20; +const DEBOUNCE_MS = 120; -function widget(node, name) { - return (node.widgets || []).find((w) => w.name === name); +// ── helpers ────────────────────────────────────────────── + +function getModeWidget(node) { + return (node.widgets || []).find((w) => w.name === "mode"); } -function applyBroadcast(node) { - const filter = (widget(node, "name_filter")?.value || "").trim(); - const mode = widget(node, "broadcast_mode")?.value; - if (!filter || !mode || mode === "off") return; +function getPanelMode(node) { + return getModeWidget(node)?.value ?? "widgets"; +} - const slotCount = Math.max(1, parseInt(widget(node, "slot_count")?.value ?? "4", 10) || 1); - const targets = (app.graph._nodes || []) - .filter((n) => n.id !== node.id && ( - (n.title && n.title.includes(filter)) || - (n.type && n.type.includes(filter)) - )) - .sort((a, b) => (a.title || a.type).localeCompare(b.title || b.type, "ja")); - - targets.forEach((t, idx) => { - const on = idx < slotCount ? !!widget(node, `switch_${idx + 1}`)?.value : false; - if (mode === "bypass") { - t.bypass = on; - } else if (mode === "mute") { - t.mute = on; - } else if (mode === "widgets") { - (t.widgets || []).forEach((wid) => { - if (wid.type === "boolean" || (wid.options && wid.options.type === "boolean")) { - wid.value = on; - } - }); - } +/** Resolve target nodes whose title starts with ":" and item part is non-empty. */ +function resolveTargets(panelNode) { + const prefix = panelNode.title; + if (!prefix) return []; + const needle = prefix + ":"; + return (app.graph._nodes || []).filter((n) => { + if (n.id === panelNode.id) return false; + if (!n.title || !n.title.startsWith(needle)) return false; + const item = n.title.slice(needle.length).trim(); + return item.length > 0; }); +} + +/** Extract item name (part after ":") from a target node title. */ +function itemName(panelNode, targetNode) { + const prefix = panelNode.title + ":"; + return targetNode.title.slice(prefix.length).trim(); +} + +/** Get current real state of a target node for a given panel mode. */ +function currentRealValue(targetNode, mode) { + if (mode === "bypass") return targetNode.mode === 2; + if (mode === "mute") return targetNode.mode === 4; + // widgets: first boolean widget value + const bw = (targetNode.widgets || []).find( + (w) => w.type === "BOOLEAN" || w.type === "toggle" || (w.options && w.options.type === "boolean") + ); + return bw ? !!bw.value : false; +} + +/** Check if target node has a boolean widget. */ +function hasBooleanWidget(targetNode) { + return (targetNode.widgets || []).some( + (w) => w.type === "BOOLEAN" || w.type === "toggle" || (w.options && w.options.type === "boolean") + ); +} + +/** Apply a toggle value to a target node. */ +function applyValue(targetNode, mode, value) { + if (mode === "bypass") { + targetNode.mode = value ? 2 : 0; + } else if (mode === "mute") { + targetNode.mode = value ? 4 : 0; + } else { + // widgets + const bw = (targetNode.widgets || []).find( + (w) => w.type === "BOOLEAN" || w.type === "toggle" || (w.options && w.options.type === "boolean") + ); + if (bw) { + bw.value = value; + if (typeof bw.callback === "function") { + try { bw.callback(value); } catch (_) { /* ignore */ } + } + } + } app.graph.setDirtyCanvas(true, true); } +// ── panel widget builder ───────────────────────────────── + +function buildPanel(panelNode) { + // Ensure properties container + if (!panelNode.properties.switches) { + panelNode.properties.switches = {}; + } + + const container = document.createElement("div"); + container.style.cssText = + "font-family:monospace;font-size:12px;color:#ccc;overflow-y:auto;user-select:none;"; + + const listEl = document.createElement("div"); + container.appendChild(listEl); + + // addDOMWidget + const domWidget = panelNode.addDOMWidget("multi_bool_panel", "multi_bool_panel", container, { + serialize: false, + computeSize: () => { + const rows = listEl.children.length; + const h = rows * ROW_HEIGHT + HEADER_PAD; + return [panelNode.size[0], Math.max(h + HEADER_PAD, 60)]; + }, + }); + + // ── render ──────────────────────────────────────────── + + let debounceTimer = null; + + function scheduleRefresh() { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => render(), DEBOUNCE_MS); + } + + function render() { + const mode = getPanelMode(panelNode); + const switches = panelNode.properties.switches; + const targets = resolveTargets(panelNode); + + // Sort case-insensitively by item name + targets.sort((a, b) => { + const na = itemName(panelNode, a).toLowerCase(); + const nb = itemName(panelNode, b).toLowerCase(); + return na.localeCompare(nb); + }); + + // Build rows data + let rows = targets.map((t) => { + const name = itemName(panelNode, t); + return { node: t, name }; + }); + + // Limit + const overflow = rows.length > MAX_ITEMS ? rows.length - MAX_ITEMS : 0; + if (overflow > 0) rows = rows.slice(0, MAX_ITEMS); + + // Clear list + listEl.innerHTML = ""; + + // Empty state hint + if (rows.length === 0 && overflow === 0) { + const hint = document.createElement("div"); + hint.style.cssText = + "padding:6px 4px;color:#888;font-size:11px;line-height:1.4;text-align:center;"; + hint.textContent = + 'このノードの名前を「A」に変えると、タイトルが「A:名前」のノードを操作対象にできる'; + listEl.appendChild(hint); + resizeNode(); + return; + } + + // Render rows + rows.forEach(({ node: t, name }) => { + const row = document.createElement("div"); + row.style.cssText = + "display:flex;align-items:center;justify-content:space-between;" + + "padding:2px 6px;border-bottom:1px solid #2a2a2a;cursor:default;height:" + + ROW_HEIGHT + "px;box-sizing:border-box;"; + + // Determine enabled state + let disabled = false; + if (mode === "widgets" && !hasBooleanWidget(t)) { + disabled = true; + } + + // Label + const label = document.createElement("span"); + label.style.cssText = + "flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" + + "font-size:12px;" + (disabled ? "color:#555;" : "color:#ccc;"); + label.textContent = name; + if (disabled) { + label.title = "booleanウィジェットがありません"; + } + row.appendChild(label); + + // Toggle + const toggle = createToggle(disabled); + const saved = switches[name]; + let isOn; + if (saved !== undefined) { + isOn = !!saved; + } else { + isOn = currentRealValue(t, mode); + } + if (isOn) toggle.classList.add("on"); + if (disabled) toggle.classList.add("disabled"); + + toggle.addEventListener("click", (e) => { + e.stopPropagation(); + if (disabled) return; + const nowOn = !toggle.classList.contains("on"); + toggle.classList.toggle("on", nowOn); + switches[name] = nowOn; + applyValue(t, mode, nowOn); + }); + + row.appendChild(toggle); + + // Hover highlight + row.addEventListener("mouseenter", () => { + if (!disabled) row.style.background = "#2a2a2a"; + }); + row.addEventListener("mouseleave", () => { + row.style.background = ""; + }); + + listEl.appendChild(row); + }); + + // Overflow note + if (overflow > 0) { + const note = document.createElement("div"); + note.style.cssText = + "padding:3px 6px;color:#666;font-size:11px;text-align:center;border-top:1px solid #333;"; + note.textContent = "他" + overflow + "件"; + listEl.appendChild(note); + } + + resizeNode(); + } + + function resizeNode() { + const rows = listEl.children.length; + const h = rows * ROW_HEIGHT + HEADER_PAD; + panelNode.size = [panelNode.size[0], Math.max(h + HEADER_PAD, 60)]; + panelNode.setDirtyCanvas(true, true); + } + + // ── toggle element factory ──────────────────────────── + + function createToggle(disabled) { + const el = document.createElement("div"); + el.className = "mbp-toggle" + (disabled ? " disabled" : ""); + el.style.cssText = + "width:32px;height:16px;border-radius:8px;position:relative;cursor:pointer;" + + "background:#3a3a3a;transition:background 0.15s;flex-shrink:0;margin-left:8px;"; + const knob = document.createElement("div"); + knob.style.cssText = + "width:12px;height:12px;border-radius:50%;background:#888;position:absolute;" + + "top:2px;left:2px;transition:left 0.15s,background 0.15s;"; + el.appendChild(knob); + + // Use a MutationObserver-like approach via class toggle handler + const observer = new MutationObserver(() => { + if (el.classList.contains("on")) { + el.style.background = "#4a7a4a"; + knob.style.left = "18px"; + knob.style.background = "#8f8"; + } else { + el.style.background = "#3a3a3a"; + knob.style.left = "2px"; + knob.style.background = el.classList.contains("disabled") ? "#555" : "#888"; + } + }); + observer.observe(el, { attributes: true, attributeFilter: ["class"] }); + + return el; + } + + // ── event hooks ─────────────────────────────────────── + + // Graph changes (add/remove/rename nodes) + const onGraphChange = () => scheduleRefresh(); + if (panelNode.graph) { + panelNode.graph.on("change", onGraphChange); + } + + // Title / property changes + const origOnPropertyChanged = panelNode.onPropertyChanged; + panelNode.onPropertyChanged = function (prop, value) { + if (prop === "title") scheduleRefresh(); + if (origOnPropertyChanged) origOnPropertyChanged.call(this, prop, value); + }; + + // Mode widget changes — re-evaluate enabled/disabled rows + const modeW = getModeWidget(panelNode); + if (modeW) { + const origCB = modeW.callback; + modeW.callback = function (v) { + render(); + if (origCB) origCB.call(this, v); + }; + } + + // Initial render + render(); + + // Cleanup on removal + const origOnRemoved = panelNode.onRemoved; + panelNode.onRemoved = function () { + if (panelNode.graph) { + panelNode.graph.off("change", onGraphChange); + } + if (origOnRemoved) origOnRemoved.call(this); + }; +} + +// ── extension registration ─────────────────────────────── + app.registerExtension({ name: "MultiBoolPanel", - async setup() { - const origQueue = app.queuePrompt.bind(app); - app.queuePrompt = function (number, detail) { - (app.graph._nodes || []).forEach((n) => { - if (n.type === PANEL_TYPE && widget(n, "auto_apply")?.value) applyBroadcast(n); - }); - return origQueue(number, detail); - }; - }, async nodeCreated(node) { - if (node.type === PANEL_TYPE || node.constructor.type === PANEL_TYPE) { - node.addWidget("button", "Apply to matching nodes", null, () => applyBroadcast(node)); + if (node.type === PANEL_TYPE || node.constructor?.type === PANEL_TYPE) { + buildPanel(node); } }, });