453 lines
16 KiB
JavaScript
453 lines
16 KiB
JavaScript
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/groups whose title starts with "<panelTitle>:" and item part is non-empty. */
|
|
function resolveTargets(panelNode) {
|
|
const prefix = panelNode.title;
|
|
if (!prefix) return [];
|
|
const needle = prefix + ":";
|
|
const mode = getPanelMode(panelNode);
|
|
const targets = [];
|
|
// Nodes
|
|
(app.graph._nodes || []).forEach((n) => {
|
|
if (n.id === panelNode.id) return;
|
|
if (!n.title || !n.title.startsWith(needle)) return;
|
|
const name = n.title.slice(needle.length).trim();
|
|
if (name.length > 0) targets.push({ kind: "node", ref: n, name });
|
|
});
|
|
// Groups — only relevant in bypass/mute modes (widgets mode targets boolean widgets)
|
|
if (mode !== "widgets") {
|
|
(app.graph._groups || []).forEach((g) => {
|
|
if (!g.title || !g.title.startsWith(needle)) return;
|
|
const name = g.title.slice(needle.length).trim();
|
|
if (name.length > 0) targets.push({ kind: "group", ref: g, name });
|
|
});
|
|
}
|
|
return targets;
|
|
}
|
|
|
|
/** Get current real state of a target (node or group) for a given panel mode. */
|
|
function currentRealValue(target, mode) {
|
|
if (target.kind === "group") {
|
|
const g = target.ref;
|
|
if (typeof g.recomputeInsideNodes === "function") g.recomputeInsideNodes();
|
|
const members = g._nodes || [];
|
|
if (members.length === 0) return false;
|
|
// ON = node runs normally (mode 0); OFF = bypassed/muted
|
|
return members.every((n) => n.mode === 0);
|
|
}
|
|
const targetNode = target.ref;
|
|
if (mode === "bypass") return targetNode.mode === 0;
|
|
if (mode === "mute") return targetNode.mode === 0;
|
|
// 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 or group). */
|
|
function applyValue(target, mode, value) {
|
|
if (target.kind === "group") {
|
|
if (mode === "widgets") return; // groups are never included in widgets mode
|
|
const g = target.ref;
|
|
if (typeof g.recomputeInsideNodes === "function") g.recomputeInsideNodes();
|
|
const m = value ? 0 : (mode === "bypass" ? 4 : 2);
|
|
(g._nodes || []).forEach((n) => { n.mode = m; });
|
|
app.graph.setDirtyCanvas(true, true);
|
|
return;
|
|
}
|
|
const targetNode = target.ref;
|
|
if (mode === "bypass") {
|
|
targetNode.mode = value ? 0 : 4;
|
|
} else if (mode === "mute") {
|
|
targetNode.mode = value ? 0 : 2;
|
|
} 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);
|
|
}
|
|
|
|
// ── canvas.setDirty hook (rename detection) ──────────────
|
|
// The new frontend renames nodes by assigning node.title directly and only
|
|
// calls canvas.setDirty — neither graph.change() nor onPropertyChanged fire.
|
|
// Wrapping canvas.setDirty (installed once) is the only observable signal
|
|
// for label changes; panels then re-render only when their own relevant key
|
|
// actually changed. Add/remove are already covered by graph hooks.
|
|
const panels = new Set();
|
|
let panelsCheckTimer = null;
|
|
const PANELS_CHECK_MS = 150;
|
|
|
|
function relevantKey(panel) {
|
|
const mode = getPanelMode(panel);
|
|
const names = resolveTargets(panel)
|
|
.map((t) => t.name.toLowerCase())
|
|
.sort();
|
|
return (panel.title || "") + "\u0000" + mode + "\u0000" + names.join("\u0000");
|
|
}
|
|
|
|
function checkPanels() {
|
|
panels.forEach((p) => {
|
|
if (typeof p.__scheduleRefresh !== "function") return;
|
|
const k = relevantKey(p);
|
|
if (k !== p.__mbpLastKey) {
|
|
p.__mbpLastKey = k;
|
|
p.__scheduleRefresh();
|
|
}
|
|
});
|
|
}
|
|
|
|
function schedulePanelsCheck() {
|
|
if (panelsCheckTimer) clearTimeout(panelsCheckTimer);
|
|
panelsCheckTimer = setTimeout(() => {
|
|
panelsCheckTimer = null;
|
|
checkPanels();
|
|
}, PANELS_CHECK_MS);
|
|
}
|
|
|
|
function mbpSetDirtyWrapper(fg, bg) {
|
|
const r = this.__mbpPrevSetDirty.call(this, fg, bg);
|
|
schedulePanelsCheck();
|
|
return r;
|
|
}
|
|
|
|
function installCanvasHook(canvas) {
|
|
if (!canvas || canvas.__mbpHooked) return;
|
|
canvas.__mbpPrevSetDirty = canvas.setDirty;
|
|
canvas.setDirty = mbpSetDirtyWrapper;
|
|
canvas.__mbpHooked = true;
|
|
}
|
|
|
|
function uninstallCanvasHook() {
|
|
if (panels.size > 0) return;
|
|
const canvas = app.canvas;
|
|
if (canvas && canvas.__mbpHooked && canvas.setDirty === mbpSetDirtyWrapper) {
|
|
canvas.setDirty = canvas.__mbpPrevSetDirty;
|
|
canvas.__mbpPrevSetDirty = null;
|
|
canvas.__mbpHooked = false;
|
|
}
|
|
}
|
|
|
|
// ── panel widget builder ─────────────────────────────────
|
|
|
|
function buildPanel(panelNode) {
|
|
// Ensure properties container
|
|
if (!panelNode.properties.switches) {
|
|
panelNode.properties.switches = {};
|
|
}
|
|
// Drop stale state keys: v1 was keyed by name only (shared across modes),
|
|
// v2 used "<mode>:<name>" with ON = bypass/mute applied semantics. Current
|
|
// keys are "<mode>:v3:<name>" with ON = node runs normally (switch-panel
|
|
// semantics: OFF applies bypass/mute).
|
|
{
|
|
const legacy = panelNode.properties.switches;
|
|
for (const k of Object.keys(legacy)) {
|
|
if (!k.includes(":v3:")) delete legacy[k];
|
|
}
|
|
}
|
|
|
|
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 = a.name.toLowerCase();
|
|
const nb = b.name.toLowerCase();
|
|
return na.localeCompare(nb);
|
|
});
|
|
|
|
// Build rows data
|
|
let rows = targets.map((t) => ({ target: t, name: 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(({ target: 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;
|
|
let disabledTip = "";
|
|
if (t.kind === "group") {
|
|
if (typeof t.ref.recomputeInsideNodes === "function") t.ref.recomputeInsideNodes();
|
|
if ((t.ref._nodes || []).length === 0) {
|
|
disabled = true;
|
|
disabledTip = "グループ内にノードがありません";
|
|
}
|
|
} else if (mode === "widgets" && !hasBooleanWidget(t.ref)) {
|
|
disabled = true;
|
|
disabledTip = "booleanウィジェットがありません";
|
|
}
|
|
|
|
// 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 = disabledTip;
|
|
}
|
|
row.appendChild(label);
|
|
|
|
// Toggle — state is persisted per (mode, name) so bypass/mute/widgets
|
|
// never share each other's on/off state.
|
|
const toggle = createToggle(disabled);
|
|
const stateKey = mode + ":v3:" + name;
|
|
const saved = switches[stateKey];
|
|
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[stateKey] = 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);
|
|
// Skip resize/redraw when the height is unchanged (avoids flicker from
|
|
// redundant setDirtyCanvas on every render pass).
|
|
if (panelNode.size[1] !== rowsHeight) {
|
|
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 ───────────────────────────────────────
|
|
|
|
// Expose scheduleRefresh so checkPanels() (driven by the canvas.setDirty
|
|
// hook) can trigger refreshes on this panel.
|
|
panelNode.__scheduleRefresh = scheduleRefresh;
|
|
|
|
// nodeCreated runs inside the ComfyNode constructor, where node.graph is
|
|
// always null — so graph hooks must be installed in onAdded (fires with the
|
|
// graph argument; node.graph is set by then). Add/remove both fire
|
|
// graph.change(), so chaining graph.on_change plus onNodeAdded/onNodeRemoved
|
|
// (belt & suspenders) covers structural changes.
|
|
let prevChange = null;
|
|
let prevNodeAdded = null;
|
|
let prevNodeRemoved = null;
|
|
const onChangeWrapper = (g) => { prevChange?.(g); schedulePanelsCheck(); };
|
|
const onNodeAddedWrapper = (n) => { prevNodeAdded?.(n); schedulePanelsCheck(); };
|
|
const onNodeRemovedWrapper = (n) => { prevNodeRemoved?.(n); schedulePanelsCheck(); };
|
|
|
|
function installGraphHooks(g) {
|
|
if (!g || panelNode.__hooksInstalled) return;
|
|
panelNode.__hooksInstalled = true;
|
|
prevChange = g.on_change;
|
|
prevNodeAdded = g.onNodeAdded;
|
|
prevNodeRemoved = g.onNodeRemoved;
|
|
g.on_change = onChangeWrapper;
|
|
g.onNodeAdded = onNodeAddedWrapper;
|
|
g.onNodeRemoved = onNodeRemovedWrapper;
|
|
}
|
|
|
|
function uninstallGraphHooks() {
|
|
const g = panelNode.graph;
|
|
if (!panelNode.__hooksInstalled || !g) return;
|
|
// Restore only when our wrapper is still the current handler.
|
|
if (g.on_change === onChangeWrapper) g.on_change = prevChange;
|
|
if (g.onNodeAdded === onNodeAddedWrapper) g.onNodeAdded = prevNodeAdded;
|
|
if (g.onNodeRemoved === onNodeRemovedWrapper) g.onNodeRemoved = prevNodeRemoved;
|
|
panelNode.__hooksInstalled = false;
|
|
prevChange = prevNodeAdded = prevNodeRemoved = null;
|
|
}
|
|
|
|
const origOnAdded = panelNode.onAdded;
|
|
panelNode.onAdded = function (graph) {
|
|
installGraphHooks(graph || panelNode.graph);
|
|
panels.add(panelNode);
|
|
installCanvasHook(app.canvas);
|
|
if (origOnAdded) origOnAdded.call(this, graph);
|
|
};
|
|
|
|
// 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();
|
|
// Baseline for change detection so the first relevant change re-renders.
|
|
panelNode.__mbpLastKey = relevantKey(panelNode);
|
|
|
|
// Cleanup on removal
|
|
const origOnRemoved = panelNode.onRemoved;
|
|
panelNode.onRemoved = function () {
|
|
uninstallGraphHooks();
|
|
panels.delete(panelNode);
|
|
panelNode.__scheduleRefresh = null;
|
|
panelNode.__mbpLastKey = null;
|
|
uninstallCanvasHook();
|
|
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);
|
|
}
|
|
},
|
|
});
|