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; // ── helpers ────────────────────────────────────────────── function getModeWidget(node) { return (node.widgets || []).find((w) => w.name === "mode"); } function getPanelMode(node) { return getModeWidget(node)?.value ?? "widgets"; } /** 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 — the new frontend (comfyui_frontend_package) drops // options.computeSize, so sizing must be attached to the widget instance. // Provide both computeLayoutSize (new) and computeSize (legacy) paths. let rowsHeight = 60; const domWidget = panelNode.addDOMWidget("multi_bool_panel", "multi_bool_panel", container, { serialize: false, }); domWidget.computeLayoutSize = () => ({ minHeight: rowsHeight, minWidth: 150, maxHeight: rowsHeight, }); domWidget.computeSize = (nodeWidth) => [nodeWidth, rowsHeight]; // ── 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; rowsHeight = Math.max(h + HEADER_PAD, 60); panelNode.size = [panelNode.size[0], rowsHeight]; 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) — LiteGraph has no graph.on("change") // event; chain the on_change property hook instead (works on old and new litegraph). const graph = panelNode.graph; const prevOnChange = graph ? graph.on_change : null; const onGraphChangeWrapper = (g) => { if (prevOnChange) prevOnChange(g); scheduleRefresh(); }; if (graph) graph.on_change = onGraphChangeWrapper; // 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.on_change === onGraphChangeWrapper) { panelNode.graph.on_change = prevOnChange; } if (origOnRemoved) origOnRemoved.call(this); }; } // ── extension registration ─────────────────────────────── app.registerExtension({ name: "MultiBoolPanel", async nodeCreated(node) { if (node.type === PANEL_TYPE || node.constructor?.type === PANEL_TYPE) { buildPanel(node); } }, });