Double-clicking a group row flips its enabled state via the rgthree Fast Groups Muter (off->on, on->off), with the pill label and row highlight updated immediately. No-op for placeholder rows with no muter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1076 lines
39 KiB
JavaScript
1076 lines
39 KiB
JavaScript
// comfyui-navigator: floating side panel listing every group in the current
|
||
// workflow. Click → animated pan/zoom to that group. Right-click → solo /
|
||
// reset (via rgthree FastGroupsMuter if installed, else per-node fallback).
|
||
|
||
import { app } from "../../scripts/app.js";
|
||
|
||
// Bump on every release. Exposed on window so it's trivial to check in the
|
||
// console (`window.__cnv_version`) whether the browser is on the latest JS
|
||
// or still serving a cached older copy.
|
||
const CNV_VERSION = "0.4.5";
|
||
try {
|
||
window.__cnv_version = CNV_VERSION;
|
||
console.info(`[comfyui-navigator] loaded v${CNV_VERSION}`);
|
||
} catch {}
|
||
|
||
const STYLE_ID = "cnv-styles";
|
||
const PANEL_ID = "cnv-panel";
|
||
const POS_KEY = "comfyui-navigator.panel-pos";
|
||
const COLLAPSE_KEY = "comfyui-navigator.collapsed";
|
||
const COLORS_KEY = "comfyui-navigator.colors";
|
||
const SHORTCUTS_KEY = "comfyui-navigator.shortcuts";
|
||
const ORDER_KEY = "comfyui-navigator.group-order";
|
||
|
||
const COLOR_FIELDS = [
|
||
{ key: "--cnv-bg", label: "Body background", default: "#0e283f" },
|
||
{ key: "--cnv-header-bg", label: "Header bar", default: "#1e4f88" },
|
||
{ key: "--cnv-accent", label: "Accent (on toggle)",default: "#2864ad" },
|
||
{ key: "--cnv-row-hover", label: "Row hover", default: "#163657" },
|
||
];
|
||
|
||
function loadColors() {
|
||
try {
|
||
const v = localStorage.getItem(COLORS_KEY);
|
||
if (v) return JSON.parse(v);
|
||
} catch {}
|
||
return {};
|
||
}
|
||
function saveColors(map) {
|
||
try { localStorage.setItem(COLORS_KEY, JSON.stringify(map)); } catch {}
|
||
}
|
||
function applyColors(panel) {
|
||
const stored = loadColors();
|
||
for (const { key, default: def } of COLOR_FIELDS) {
|
||
panel.style.setProperty(key, stored[key] || def);
|
||
}
|
||
}
|
||
|
||
/** Shortcuts are stored as { "key char": "Group Title" }.
|
||
* Defaults (when nothing is set OR map is empty): 1..9 → first 9 navigable
|
||
* groups by order. An empty {} object would otherwise short-circuit the
|
||
* defaults; treat it as "no overrides" so defaults kick back in. */
|
||
function loadShortcuts() {
|
||
try {
|
||
const v = localStorage.getItem(SHORTCUTS_KEY);
|
||
if (v) {
|
||
const parsed = JSON.parse(v);
|
||
if (parsed && typeof parsed === "object" && Object.keys(parsed).length > 0) return parsed;
|
||
}
|
||
} catch {}
|
||
return null;
|
||
}
|
||
function saveShortcuts(map) {
|
||
try {
|
||
if (!map || Object.keys(map).length === 0) {
|
||
localStorage.removeItem(SHORTCUTS_KEY);
|
||
} else {
|
||
localStorage.setItem(SHORTCUTS_KEY, JSON.stringify(map));
|
||
}
|
||
} catch {}
|
||
}
|
||
function resolveShortcut(key) {
|
||
// Custom mapping wins ONLY if there's an explicit entry for this key.
|
||
const map = loadShortcuts();
|
||
if (map && map[key]) return map[key];
|
||
// Default fallback: any positive integer maps to that 1-indexed row.
|
||
// (Previously 1..9 only; now supports "10", "11", ... via the keyBuffer.)
|
||
if (/^[1-9][0-9]*$/.test(key)) {
|
||
const idx = parseInt(key, 10) - 1;
|
||
const g = getNavigableGroups()[idx];
|
||
return g ? groupTitle(g, idx) : null;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** Inverse of resolveShortcut: which key (if any) jumps to this group?
|
||
* Used to render the badge on each row in the panel. */
|
||
function getShortcutKeyForGroup(title, index) {
|
||
const map = loadShortcuts();
|
||
if (map) {
|
||
for (const [k, t] of Object.entries(map)) {
|
||
if (t === title) return k;
|
||
}
|
||
}
|
||
// Default badge: this row's 1-indexed position. Works for any row count.
|
||
const defaultKey = String(index + 1);
|
||
if (map && map[defaultKey] && map[defaultKey] !== title) return null;
|
||
return defaultKey;
|
||
}
|
||
|
||
const state = {
|
||
panel: null,
|
||
searchInput: null,
|
||
listEl: null,
|
||
query: "",
|
||
collapsed: false,
|
||
};
|
||
|
||
// --- styles --------------------------------------------------------------
|
||
|
||
function ensureStyles() {
|
||
if (document.getElementById(STYLE_ID)) return;
|
||
const link = document.createElement("link");
|
||
link.id = STYLE_ID;
|
||
link.rel = "stylesheet";
|
||
link.href = new URL("./styles.css", import.meta.url).toString();
|
||
document.head.appendChild(link);
|
||
}
|
||
|
||
// --- panel position persistence -----------------------------------------
|
||
|
||
function loadPanelPos() {
|
||
try {
|
||
const v = localStorage.getItem(POS_KEY);
|
||
if (!v) return null;
|
||
const p = JSON.parse(v);
|
||
if (typeof p?.left === "number" && typeof p?.top === "number") return p;
|
||
} catch {}
|
||
return null;
|
||
}
|
||
|
||
function savePanelPos(left, top) {
|
||
try { localStorage.setItem(POS_KEY, JSON.stringify({ left, top })); } catch {}
|
||
}
|
||
|
||
function applyPanelPos(panel) {
|
||
const p = loadPanelPos();
|
||
if (!p) return;
|
||
panel.style.left = `${p.left}px`;
|
||
panel.style.top = `${p.top}px`;
|
||
panel.style.right = "auto";
|
||
}
|
||
|
||
// --- drag wiring (header is the handle) ---------------------------------
|
||
|
||
function installPanelDrag(panel, handle, onClickHeader) {
|
||
// Treat a small-movement mousedown→up sequence on the header as a CLICK
|
||
// (toggles collapse). Real drags only kick in after the cursor has moved
|
||
// more than DRAG_THRESHOLD pixels from the start.
|
||
const DRAG_THRESHOLD = 4;
|
||
let pressed = false; // mouse is down on header
|
||
let dragging = false; // we've crossed the threshold, now in drag mode
|
||
let dx = 0, dy = 0;
|
||
let startX = 0, startY = 0;
|
||
|
||
handle.addEventListener("mousedown", (e) => {
|
||
if (e.button !== 0) return;
|
||
if (e.target.closest("button")) return; // header buttons handle themselves
|
||
e.preventDefault();
|
||
const r = panel.getBoundingClientRect();
|
||
dx = e.clientX - r.left;
|
||
dy = e.clientY - r.top;
|
||
startX = e.clientX;
|
||
startY = e.clientY;
|
||
pressed = true;
|
||
dragging = false;
|
||
});
|
||
window.addEventListener("mousemove", (e) => {
|
||
if (!pressed) return;
|
||
const distance = Math.abs(e.clientX - startX) + Math.abs(e.clientY - startY);
|
||
if (!dragging && distance < DRAG_THRESHOLD) return;
|
||
if (!dragging) {
|
||
dragging = true;
|
||
panel.classList.add("cnv-panel--dragging");
|
||
}
|
||
const left = Math.max(0, Math.min(window.innerWidth - 50, e.clientX - dx));
|
||
const top = Math.max(0, Math.min(window.innerHeight - 50, e.clientY - dy));
|
||
panel.style.left = `${left}px`;
|
||
panel.style.top = `${top}px`;
|
||
panel.style.right = "auto";
|
||
});
|
||
window.addEventListener("mouseup", (e) => {
|
||
if (!pressed) return;
|
||
pressed = false;
|
||
if (dragging) {
|
||
dragging = false;
|
||
panel.classList.remove("cnv-panel--dragging");
|
||
const r = panel.getBoundingClientRect();
|
||
savePanelPos(r.left, r.top);
|
||
} else {
|
||
// Stayed within threshold → treat as click
|
||
if (typeof onClickHeader === "function") onClickHeader(e);
|
||
}
|
||
});
|
||
}
|
||
|
||
// --- group access -------------------------------------------------------
|
||
|
||
function getGroups() {
|
||
return (app.graph?._groups || []).filter(Boolean);
|
||
}
|
||
|
||
/** The groups the panel actually navigates between. If the workflow has any
|
||
* Fast Groups Muter, we mirror the *primary* one (= the muter with the most
|
||
* managed widgets). That respects the user's matchColors partition: a sub-
|
||
* group with a different color belongs to a smaller muter and gets filtered
|
||
* out of the nav list. If no muter exists, fall back to every group.
|
||
* Finally, apply the user's custom drag-reorder (if set). */
|
||
function getNavigableGroups() {
|
||
const all = getGroups();
|
||
const muters = getMutersAndWidgets();
|
||
let filtered;
|
||
if (muters.length === 0) {
|
||
filtered = all;
|
||
} else {
|
||
const primary = muters.reduce((a, b) => (a.widgets.size >= b.widgets.size ? a : b));
|
||
filtered = all.filter((g, i) => primary.widgets.has(groupTitle(g, i)));
|
||
}
|
||
return sortByCustomOrder(filtered);
|
||
}
|
||
|
||
function loadGroupOrder() {
|
||
try {
|
||
const v = localStorage.getItem(ORDER_KEY);
|
||
if (v) {
|
||
const arr = JSON.parse(v);
|
||
if (Array.isArray(arr)) return arr;
|
||
}
|
||
} catch {}
|
||
return null;
|
||
}
|
||
function saveGroupOrder(titles) {
|
||
try {
|
||
if (!titles || !titles.length) localStorage.removeItem(ORDER_KEY);
|
||
else localStorage.setItem(ORDER_KEY, JSON.stringify(titles));
|
||
} catch {}
|
||
}
|
||
|
||
/** Sort `groups` by the custom-order array. Unlisted groups keep their
|
||
* relative order and end up after listed ones. */
|
||
function sortByCustomOrder(groups) {
|
||
const order = loadGroupOrder();
|
||
if (!order) return groups;
|
||
const idx = new Map();
|
||
order.forEach((t, i) => idx.set(t, i));
|
||
return [...groups]
|
||
.map((g, i) => ({ g, origIdx: i, title: groupTitle(g, i) }))
|
||
.sort((a, b) => {
|
||
const ai = idx.has(a.title) ? idx.get(a.title) : Number.POSITIVE_INFINITY;
|
||
const bi = idx.has(b.title) ? idx.get(b.title) : Number.POSITIVE_INFINITY;
|
||
if (ai !== bi) return ai - bi;
|
||
return a.origIdx - b.origIdx;
|
||
})
|
||
.map((x) => x.g);
|
||
}
|
||
|
||
/** Move `movingTitle` to be right before/after `targetTitle` in the
|
||
* persisted custom order. */
|
||
function reorderGroup(movingTitle, targetTitle, placement) {
|
||
// Build current order, seeding with the current navigable titles so that
|
||
// any group not yet in the saved order gets a stable slot.
|
||
const navTitles = getNavigableGroups().map((g, i) => groupTitle(g, i));
|
||
let order = loadGroupOrder();
|
||
if (!order) order = [...navTitles];
|
||
// Drop anything no longer present so the stored order stays clean
|
||
order = order.filter((t) => navTitles.includes(t));
|
||
// Append any new titles missing from order
|
||
for (const t of navTitles) if (!order.includes(t)) order.push(t);
|
||
|
||
// Now actually move
|
||
order = order.filter((t) => t !== movingTitle);
|
||
const targetIdx = order.indexOf(targetTitle);
|
||
if (targetIdx < 0) order.push(movingTitle);
|
||
else order.splice(placement === "after" ? targetIdx + 1 : targetIdx, 0, movingTitle);
|
||
saveGroupOrder(order);
|
||
scheduleRender();
|
||
}
|
||
|
||
function groupBounds(g) {
|
||
// LiteGraph stores bounding as [x, y, w, h]
|
||
const b = g._bounding || g.bounding;
|
||
if (b && b.length === 4) return { x: b[0], y: b[1], w: b[2], h: b[3] };
|
||
// Fallback to pos/size
|
||
return {
|
||
x: g.pos?.[0] ?? 0,
|
||
y: g.pos?.[1] ?? 0,
|
||
w: g.size?.[0] ?? 200,
|
||
h: g.size?.[1] ?? 100,
|
||
};
|
||
}
|
||
|
||
function groupColorOrDefault(g) {
|
||
return g.color || "#3b82f6";
|
||
}
|
||
|
||
function groupTitle(g, i) {
|
||
return (g.title || `Group ${i + 1}`).trim();
|
||
}
|
||
|
||
// --- pan + zoom to fit a bbox -------------------------------------------
|
||
|
||
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
|
||
|
||
function animatePanZoom(targetOffset, targetScale, durationMs = 280) {
|
||
const c = app.canvas;
|
||
if (!c?.ds) return;
|
||
const startOffset = [c.ds.offset[0], c.ds.offset[1]];
|
||
const startScale = c.ds.scale;
|
||
const start = performance.now();
|
||
function frame(now) {
|
||
const t = Math.min(1, (now - start) / durationMs);
|
||
const k = easeOutCubic(t);
|
||
c.ds.offset[0] = startOffset[0] + (targetOffset[0] - startOffset[0]) * k;
|
||
c.ds.offset[1] = startOffset[1] + (targetOffset[1] - startOffset[1]) * k;
|
||
c.ds.scale = startScale + (targetScale - startScale) * k;
|
||
c.setDirty?.(true, true);
|
||
if (t < 1) requestAnimationFrame(frame);
|
||
}
|
||
requestAnimationFrame(frame);
|
||
}
|
||
|
||
function fitToBounds(b, padding = 80) {
|
||
const c = app.canvas;
|
||
if (!c?.canvas) return;
|
||
const rect = c.canvas.getBoundingClientRect();
|
||
// Want: visible_area should contain b. visible_area = [-offset, viewport/scale]
|
||
// Compute scale to fit (with padding subtracted from viewport).
|
||
const vwUsable = Math.max(50, rect.width - padding * 2);
|
||
const vhUsable = Math.max(50, rect.height - padding * 2);
|
||
const scaleX = vwUsable / Math.max(1, b.w);
|
||
const scaleY = vhUsable / Math.max(1, b.h);
|
||
let targetScale = Math.min(scaleX, scaleY, 1.0); // never zoom past 100%
|
||
targetScale = Math.max(0.05, targetScale);
|
||
// Compute offset so the group's center lands at the canvas center.
|
||
const cx = b.x + b.w / 2;
|
||
const cy = b.y + b.h / 2;
|
||
const targetOffsetX = (rect.width / 2) / targetScale - cx;
|
||
const targetOffsetY = (rect.height / 2) / targetScale - cy;
|
||
animatePanZoom([targetOffsetX, targetOffsetY], targetScale);
|
||
}
|
||
|
||
function jumpToGroup(g) {
|
||
fitToBounds(groupBounds(g));
|
||
}
|
||
|
||
// --- rgthree FastGroupsMuter integration --------------------------------
|
||
//
|
||
// rgthree's Fast Groups Muter exposes one widget per group:
|
||
// - name: "RGTHREE_TOGGLE_AND_NAV" (generic — same for every widget)
|
||
// - type: "custom"
|
||
// - label: "Enable {group_title}" ← this is the only link back to the group
|
||
// - value: { toggled: bool } ← OBJECT, not a plain boolean
|
||
// A single workflow may have multiple muters (each managing a subset of
|
||
// groups), so we scan them all and aggregate.
|
||
|
||
function findFastGroupsMuters() {
|
||
return (app.graph?._nodes || []).filter((n) => n.type === "Fast Groups Muter (rgthree)");
|
||
}
|
||
|
||
/** Returns [{muter, widgets: Map<groupTitle, widget>}] per muter on the graph.
|
||
* Per-muter grouping matters: rgthree's `matchColors` property scopes each
|
||
* muter to a subset of groups, and Solo should only toggle within the muter
|
||
* that actually owns the target group — touching other muters would clobber
|
||
* unrelated state in the user's color-coded setup. */
|
||
function getMutersAndWidgets() {
|
||
const out = [];
|
||
for (const muter of findFastGroupsMuters()) {
|
||
const widgets = new Map();
|
||
for (const w of (muter.widgets || [])) {
|
||
const label = w?.label || w?.name || "";
|
||
const m = String(label).match(/^Enable\s+(.+)$/);
|
||
if (m) widgets.set(m[1].trim(), w);
|
||
}
|
||
if (widgets.size > 0) out.push({ muter, widgets });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** Find the muter that owns this group (first match if multiple). */
|
||
function findMuterOwningGroup(targetTitle) {
|
||
for (const entry of getMutersAndWidgets()) {
|
||
if (entry.widgets.has(targetTitle)) return entry;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function getMuterWidgetForGroup(title) {
|
||
const owner = findMuterOwningGroup(title);
|
||
return owner ? owner.widgets.get(title) : null;
|
||
}
|
||
|
||
function isGroupEnabled(title) {
|
||
const w = getMuterWidgetForGroup(title);
|
||
return w ? !!w?.value?.toggled : null; // null = no muter manages it
|
||
}
|
||
|
||
function setGroupEnabled(title, on) {
|
||
const w = getMuterWidgetForGroup(title);
|
||
if (!w) return false;
|
||
setMuterWidget(w, on);
|
||
const owner = findMuterOwningGroup(title);
|
||
owner?.muter?.setDirtyCanvas?.(true, true);
|
||
return true;
|
||
}
|
||
|
||
function setMuterWidget(widget, toggled) {
|
||
const target = !!toggled;
|
||
// The right call is doModeChange(force, skipOtherNodeCheck) — that's the
|
||
// only path that ACTUALLY forces a target state.
|
||
//
|
||
// The public widget.toggle(value) method looks like it should work, but it
|
||
// calls doModeChange() WITHOUT passing force, and doModeChange then
|
||
// recomputes the next state from current node modes:
|
||
// newValue = !hasAnyActiveNodes
|
||
// So if any inner node is already ALWAYS (e.g. shared with a previously-
|
||
// enabled overlapping/parent group), toggle(true) actually DISABLES the
|
||
// group instead of enabling it. That surfaced as: Enable All flipped 13 of
|
||
// 14 groups but "Place an image" stayed off because its 25 inner nodes
|
||
// overlapped with an earlier-enabled neighbor.
|
||
try {
|
||
if (typeof widget.doModeChange === "function") {
|
||
widget.doModeChange(target, true);
|
||
// Mirror into the value object so any reader (our 600ms sync loop)
|
||
// picks it up immediately without waiting for rgthree's observer.
|
||
if (widget.value && typeof widget.value === "object") widget.value.toggled = target;
|
||
return;
|
||
}
|
||
} catch (e) { console.warn("[comfyui-navigator] widget.doModeChange threw:", e); }
|
||
// Fallback chain: public toggle, then raw value set.
|
||
try {
|
||
if (typeof widget.toggle === "function") { widget.toggle(target); return; }
|
||
} catch (e) { console.warn("[comfyui-navigator] widget.toggle() threw:", e); }
|
||
try {
|
||
const next = { ...(widget.value || {}), toggled: target };
|
||
widget.value = next;
|
||
if (typeof widget.callback === "function") widget.callback(next);
|
||
} catch (e) { console.warn("[comfyui-navigator] fallback widget set threw:", e); }
|
||
}
|
||
|
||
/** Reset = turn every widget on, across every muter. */
|
||
function setAllMutersTo(toggled) {
|
||
let count = 0;
|
||
for (const { muter, widgets } of getMutersAndWidgets()) {
|
||
for (const widget of widgets.values()) {
|
||
setMuterWidget(widget, toggled);
|
||
count++;
|
||
}
|
||
muter.setDirtyCanvas?.(true, true);
|
||
}
|
||
return count;
|
||
}
|
||
|
||
/** Solo only inside the muter that owns this group. Other muters untouched. */
|
||
function soloGroupViaMuter(targetTitle) {
|
||
const owner = findMuterOwningGroup(targetTitle);
|
||
if (!owner) return false;
|
||
for (const [title, widget] of owner.widgets) {
|
||
setMuterWidget(widget, title === targetTitle);
|
||
}
|
||
owner.muter.setDirtyCanvas?.(true, true);
|
||
return true;
|
||
}
|
||
|
||
// Fallback: directly set LiteGraph mode on every node, based on whether
|
||
// the node's center is inside the soloed group's bounds.
|
||
function soloGroupViaModeFlag(targetGroup) {
|
||
const b = groupBounds(targetGroup);
|
||
const NEVER = (window.LiteGraph?.NEVER ?? 2);
|
||
const ALWAYS = (window.LiteGraph?.ALWAYS ?? 0);
|
||
for (const n of (app.graph?._nodes || [])) {
|
||
const nx = (n.pos?.[0] || 0) + (n.size?.[0] || 0) / 2;
|
||
const ny = (n.pos?.[1] || 0) + (n.size?.[1] || 0) / 2;
|
||
const inside = nx >= b.x && nx <= b.x + b.w && ny >= b.y && ny <= b.y + b.h;
|
||
n.__cnv_pre_solo_mode = n.__cnv_pre_solo_mode ?? n.mode;
|
||
n.mode = inside ? ALWAYS : NEVER;
|
||
}
|
||
app.canvas?.setDirty?.(true, true);
|
||
}
|
||
|
||
function resetSoloModeFlags() {
|
||
for (const n of (app.graph?._nodes || [])) {
|
||
if (n.__cnv_pre_solo_mode !== undefined) {
|
||
n.mode = n.__cnv_pre_solo_mode;
|
||
delete n.__cnv_pre_solo_mode;
|
||
}
|
||
}
|
||
app.canvas?.setDirty?.(true, true);
|
||
}
|
||
|
||
function soloGroup(g) {
|
||
const muters = findFastGroupsMuters();
|
||
if (muters.length) {
|
||
const title = groupTitle(g, 0);
|
||
const ok = soloGroupViaMuter(title);
|
||
if (!ok) {
|
||
console.warn(`[comfyui-navigator] muter present but no widget matched group title "${title}". Falling back to per-node mode flag.`);
|
||
soloGroupViaModeFlag(g);
|
||
}
|
||
} else {
|
||
soloGroupViaModeFlag(g);
|
||
}
|
||
}
|
||
|
||
function resetMuteAll() {
|
||
const muters = findFastGroupsMuters();
|
||
if (muters.length) setAllMutersTo(true);
|
||
else resetSoloModeFlags();
|
||
}
|
||
|
||
// --- right-click chip menu ---------------------------------------------
|
||
|
||
async function queuePromptSafely() {
|
||
// Try the standard app.queuePrompt API path first; fall back to clicking
|
||
// the Run button if the API shape changes.
|
||
try {
|
||
if (typeof app.queuePrompt === "function") {
|
||
await app.queuePrompt(0); // 0 = end of queue
|
||
return true;
|
||
}
|
||
} catch (e) { console.warn("[comfyui-navigator] app.queuePrompt threw:", e); }
|
||
// Fallback: simulate a click on the Run button
|
||
const runBtn = Array.from(document.querySelectorAll("button")).find((b) => /^run( |$)/i.test(b.textContent.trim()));
|
||
if (runBtn) { runBtn.click(); return true; }
|
||
console.warn("[comfyui-navigator] could not queue prompt — no API and no Run button found");
|
||
return false;
|
||
}
|
||
|
||
function openRowMenu(g, evt) {
|
||
const muterCount = findFastGroupsMuters().length;
|
||
const muterScope = muterCount ? `via ${muterCount} rgthree muter${muterCount === 1 ? "" : "s"}` : "per-node fallback";
|
||
const opts = [
|
||
{ content: "Jump here", callback: () => jumpToGroup(g) },
|
||
{ content: "Center only (no zoom change)", callback: () => {
|
||
const b = groupBounds(g);
|
||
const c = app.canvas;
|
||
if (!c?.ds || !c.canvas) return;
|
||
const rect = c.canvas.getBoundingClientRect();
|
||
const cx = b.x + b.w / 2;
|
||
const cy = b.y + b.h / 2;
|
||
const targetOffsetX = (rect.width / 2) / c.ds.scale - cx;
|
||
const targetOffsetY = (rect.height / 2) / c.ds.scale - cy;
|
||
animatePanZoom([targetOffsetX, targetOffsetY], c.ds.scale);
|
||
}},
|
||
null,
|
||
{
|
||
content: `Run only this group (${muterScope})`,
|
||
callback: async () => {
|
||
soloGroup(g);
|
||
await new Promise((r) => setTimeout(r, 60));
|
||
await queuePromptSafely();
|
||
},
|
||
},
|
||
{
|
||
content: `Mute other groups (${muterScope})`,
|
||
callback: () => soloGroup(g),
|
||
},
|
||
null,
|
||
{ content: "Reset (enable all groups)", callback: () => resetMuteAll() },
|
||
];
|
||
new LiteGraph.ContextMenu(opts, { event: evt });
|
||
}
|
||
|
||
// --- panel render -------------------------------------------------------
|
||
|
||
function ensurePanel() {
|
||
if (state.panel) return state.panel;
|
||
ensureStyles();
|
||
const panel = document.createElement("div");
|
||
panel.className = "cnv-panel";
|
||
panel.id = PANEL_ID;
|
||
panel.innerHTML = `
|
||
<div class="cnv-panel__header">
|
||
<span class="cnv-panel__header-grip">⋮⋮</span>
|
||
<span class="cnv-panel__header-title">Workflow Group Navigator</span>
|
||
<span class="cnv-panel__header-count">0</span>
|
||
<button class="cnv-panel__header-collapse" type="button" title="Collapse / expand">▾</button>
|
||
</div>
|
||
<div class="cnv-panel__toolbar">
|
||
<button class="cnv-tb-btn" type="button" data-action="enable-all" title="Turn every group ON">Enable All</button>
|
||
<button class="cnv-tb-btn" type="button" data-action="disable-all" title="Turn every group OFF">Disable All</button>
|
||
<button class="cnv-tb-btn cnv-tb-btn--run" type="button" data-action="run" title="Queue the workflow (only enabled groups execute)">▶ Run</button>
|
||
<span class="cnv-tb-spacer"></span>
|
||
<button class="cnv-tb-btn cnv-tb-btn--icon" type="button" data-action="settings" title="Settings: colors, shortcuts, row order">⚙</button>
|
||
</div>
|
||
<div class="cnv-settings" data-settings></div>
|
||
<div class="cnv-panel__list"></div>
|
||
`;
|
||
document.body.appendChild(panel);
|
||
state.panel = panel;
|
||
state.listEl = panel.querySelector(".cnv-panel__list");
|
||
const header = panel.querySelector(".cnv-panel__header");
|
||
installPanelDrag(panel, header, () => toggleCollapse());
|
||
applyPanelPos(panel);
|
||
|
||
// Toolbar actions
|
||
panel.querySelector('[data-action="enable-all"]').addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
setAllMutersTo(true);
|
||
});
|
||
panel.querySelector('[data-action="disable-all"]').addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
setAllMutersTo(false);
|
||
});
|
||
panel.querySelector('[data-action="run"]').addEventListener("click", async (e) => {
|
||
e.stopPropagation();
|
||
await queuePromptSafely();
|
||
});
|
||
const settingsEl = panel.querySelector("[data-settings]");
|
||
panel.querySelector('[data-action="settings"]').addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
const open = settingsEl.classList.toggle("cnv-settings--open");
|
||
if (open) buildSettings(settingsEl);
|
||
});
|
||
|
||
// Apply saved colors on initial render
|
||
applyColors(panel);
|
||
|
||
const collapseBtn = panel.querySelector(".cnv-panel__header-collapse");
|
||
try { state.collapsed = localStorage.getItem(COLLAPSE_KEY) === "1"; } catch {}
|
||
applyCollapsed();
|
||
collapseBtn.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
toggleCollapse();
|
||
});
|
||
|
||
return panel;
|
||
}
|
||
|
||
function buildSettings(el) {
|
||
const stored = loadColors();
|
||
const shortcuts = loadShortcuts() || {};
|
||
const groups = getNavigableGroups().map((g, i) => groupTitle(g, i));
|
||
|
||
// Colors section
|
||
const colorRows = COLOR_FIELDS.map(({ key, label, default: def }) => `
|
||
<div class="cnv-color-row">
|
||
<span class="cnv-color-row__label">${label}</span>
|
||
<input type="color" data-color-key="${key}" value="${stored[key] || def}">
|
||
</div>
|
||
`).join("");
|
||
|
||
// Shortcuts section
|
||
const shortcutEntries = Object.entries(shortcuts);
|
||
const shortcutRows = shortcutEntries.map(([key, title]) => renderShortcutRow(key, title, groups)).join("");
|
||
|
||
el.innerHTML = `
|
||
<div>
|
||
<div class="cnv-settings__section-title">Colors</div>
|
||
${colorRows}
|
||
</div>
|
||
<div>
|
||
<div class="cnv-settings__section-title">Keyboard shortcuts ${shortcutEntries.length === 0 ? "(default: 1–9 jump to first 9 groups)" : "(overrides defaults)"}</div>
|
||
<div data-shortcut-list>${shortcutRows}</div>
|
||
<button class="cnv-settings__add-btn" type="button" data-add-shortcut>+ Add shortcut</button>
|
||
</div>
|
||
<div style="display:flex; gap:6px; justify-content:flex-end;">
|
||
<button class="cnv-settings__reset" type="button" data-reset-order>Reset row order</button>
|
||
<button class="cnv-settings__reset" type="button" data-reset>Reset all settings</button>
|
||
</div>
|
||
`;
|
||
|
||
// Color picker handlers
|
||
for (const input of el.querySelectorAll('input[type="color"]')) {
|
||
input.addEventListener("input", () => {
|
||
const next = { ...loadColors(), [input.dataset.colorKey]: input.value };
|
||
saveColors(next);
|
||
applyColors(state.panel);
|
||
});
|
||
}
|
||
|
||
// Shortcut row handlers (delegated). Use BOTH "input" and "change" so we
|
||
// save on every keystroke (input fires per char) AND on select changes.
|
||
// Earlier version only listened to "change" — text inputs only fire change
|
||
// on blur, so typing a key then immediately picking a group from the select
|
||
// could lose the unsaved key value when the panel closed.
|
||
const list = el.querySelector("[data-shortcut-list]");
|
||
list.addEventListener("input", onShortcutChange);
|
||
list.addEventListener("change", onShortcutChange);
|
||
list.addEventListener("click", (e) => {
|
||
if (e.target.matches("[data-remove]")) {
|
||
const row = e.target.closest(".cnv-shortcut-row");
|
||
row?.remove();
|
||
persistShortcutsFromDom(el);
|
||
}
|
||
});
|
||
|
||
// Add-shortcut button
|
||
el.querySelector("[data-add-shortcut]").addEventListener("click", () => {
|
||
const tmp = document.createElement("div");
|
||
tmp.innerHTML = renderShortcutRow("", "", groups);
|
||
list.appendChild(tmp.firstElementChild);
|
||
persistShortcutsFromDom(el);
|
||
});
|
||
|
||
// Reset row order only
|
||
el.querySelector("[data-reset-order]").addEventListener("click", () => {
|
||
if (!confirm("Reset row order to the default (rgthree's order)?")) return;
|
||
saveGroupOrder(null);
|
||
scheduleRender();
|
||
});
|
||
|
||
// Reset everything
|
||
el.querySelector("[data-reset]").addEventListener("click", () => {
|
||
if (!confirm("Reset colors, shortcuts, and row order to defaults?")) return;
|
||
try { localStorage.removeItem(COLORS_KEY); } catch {}
|
||
try { localStorage.removeItem(SHORTCUTS_KEY); } catch {}
|
||
try { localStorage.removeItem(ORDER_KEY); } catch {}
|
||
applyColors(state.panel);
|
||
buildSettings(el);
|
||
scheduleRender();
|
||
});
|
||
}
|
||
|
||
function renderShortcutRow(key, title, groups) {
|
||
const options = ['<option value="">— select group —</option>']
|
||
.concat(groups.map((g) => `<option value="${escapeAttr(g)}" ${g === title ? "selected" : ""}>${escapeHtml(g)}</option>`))
|
||
.join("");
|
||
return `
|
||
<div class="cnv-shortcut-row">
|
||
<input class="cnv-shortcut-row__key" data-shortcut-key value="${escapeAttr(key)}" maxlength="1" placeholder="?">
|
||
<select class="cnv-shortcut-row__group" data-shortcut-group>${options}</select>
|
||
<button class="cnv-shortcut-row__remove" type="button" data-remove title="Remove">×</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function onShortcutChange(e) {
|
||
if (e.target.matches("[data-shortcut-key], [data-shortcut-group]")) {
|
||
// Normalize key to lowercase single char
|
||
if (e.target.matches("[data-shortcut-key]")) {
|
||
e.target.value = (e.target.value || "").slice(0, 1).toLowerCase();
|
||
}
|
||
persistShortcutsFromDom(state.panel.querySelector("[data-settings]"));
|
||
}
|
||
}
|
||
|
||
function persistShortcutsFromDom(settingsEl) {
|
||
const map = {};
|
||
for (const row of settingsEl.querySelectorAll(".cnv-shortcut-row")) {
|
||
const k = row.querySelector("[data-shortcut-key]").value.trim();
|
||
const g = row.querySelector("[data-shortcut-group]").value;
|
||
if (k && g) map[k] = g;
|
||
}
|
||
saveShortcuts(map);
|
||
// Re-render the group list so badges reflect the new mapping
|
||
scheduleRender();
|
||
}
|
||
|
||
function escapeAttr(s) { return String(s).replace(/&/g, "&").replace(/"/g, """); }
|
||
function escapeHtml(s) { return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); }
|
||
|
||
function toggleCollapse() {
|
||
state.collapsed = !state.collapsed;
|
||
try { localStorage.setItem(COLLAPSE_KEY, state.collapsed ? "1" : "0"); } catch {}
|
||
applyCollapsed();
|
||
}
|
||
|
||
function applyCollapsed() {
|
||
if (!state.panel) return;
|
||
state.panel.classList.toggle("cnv-panel--collapsed", state.collapsed);
|
||
const btn = state.panel.querySelector(".cnv-panel__header-collapse");
|
||
if (btn) btn.textContent = state.collapsed ? "▸" : "▾";
|
||
}
|
||
|
||
function renderList() {
|
||
const panel = ensurePanel();
|
||
const allGroupCount = getGroups().length;
|
||
const groups = getNavigableGroups();
|
||
const countEl = panel.querySelector(".cnv-panel__header-count");
|
||
const filteredOut = allGroupCount - groups.length;
|
||
countEl.textContent = filteredOut > 0
|
||
? `${groups.length} / ${allGroupCount}`
|
||
: String(groups.length);
|
||
countEl.title = filteredOut > 0
|
||
? `Showing ${groups.length} groups (${filteredOut} sub-group${filteredOut === 1 ? "" : "s"} hidden via matchColors)`
|
||
: `${groups.length} groups`;
|
||
|
||
// Hide panel entirely if fewer than 2 groups (nothing to navigate between)
|
||
if (groups.length < 2) {
|
||
panel.style.display = "none";
|
||
return;
|
||
}
|
||
panel.style.display = "";
|
||
|
||
state.listEl.replaceChildren();
|
||
for (const { g, i, title } of groups.map((g, i) => ({ g, i, title: groupTitle(g, i) }))) {
|
||
const row = document.createElement("div");
|
||
row.className = "cnv-row";
|
||
row.dataset.title = title;
|
||
row.draggable = true;
|
||
|
||
const enabled = isGroupEnabled(title);
|
||
const hasMuter = enabled !== null;
|
||
if (enabled === true) row.classList.add("cnv-row--enabled");
|
||
|
||
row.addEventListener("dragstart", (e) => {
|
||
state.dragTitle = title;
|
||
e.dataTransfer.effectAllowed = "move";
|
||
try { e.dataTransfer.setData("text/plain", title); } catch {}
|
||
row.classList.add("cnv-row--dragging");
|
||
});
|
||
row.addEventListener("dragend", () => {
|
||
state.dragTitle = null;
|
||
row.classList.remove("cnv-row--dragging");
|
||
state.listEl.querySelectorAll(".cnv-row--drop-above, .cnv-row--drop-below")
|
||
.forEach((r) => r.classList.remove("cnv-row--drop-above", "cnv-row--drop-below"));
|
||
});
|
||
row.addEventListener("dragover", (e) => {
|
||
if (!state.dragTitle || state.dragTitle === title) return;
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = "move";
|
||
const rect = row.getBoundingClientRect();
|
||
const below = (e.clientY - rect.top) > rect.height / 2;
|
||
row.classList.toggle("cnv-row--drop-above", !below);
|
||
row.classList.toggle("cnv-row--drop-below", below);
|
||
});
|
||
row.addEventListener("dragleave", () => {
|
||
row.classList.remove("cnv-row--drop-above", "cnv-row--drop-below");
|
||
});
|
||
row.addEventListener("drop", (e) => {
|
||
const moving = state.dragTitle;
|
||
if (!moving || moving === title) return;
|
||
e.preventDefault();
|
||
const rect = row.getBoundingClientRect();
|
||
const below = (e.clientY - rect.top) > rect.height / 2;
|
||
reorderGroup(moving, title, below ? "after" : "before");
|
||
});
|
||
|
||
const toggleHtml = hasMuter
|
||
? `<span class="cnv-toggle ${enabled ? "cnv-toggle--on" : ""}" data-toggle title="Click to toggle"><span class="cnv-toggle__label">${enabled ? "yes" : "no"}</span><span class="cnv-toggle__knob"></span></span>`
|
||
: `<span class="cnv-toggle cnv-toggle--placeholder" title="No muter manages this group"><span class="cnv-toggle__label">—</span><span class="cnv-toggle__knob"></span></span>`;
|
||
|
||
const shortcutKey = getShortcutKeyForGroup(title, i);
|
||
row.innerHTML = `
|
||
<span class="cnv-row__title"></span>
|
||
${shortcutKey ? `<span class="cnv-row__shortcut" title="Press ${escapeAttr(shortcutKey)} to jump here">${escapeHtml(shortcutKey)}</span>` : ""}
|
||
${toggleHtml}
|
||
`;
|
||
row.querySelector(".cnv-row__title").textContent = title;
|
||
|
||
// Toggle pill: flip muter widget; don't jump
|
||
const toggle = row.querySelector("[data-toggle]");
|
||
if (toggle) {
|
||
toggle.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
const newState = !isGroupEnabled(title);
|
||
setGroupEnabled(title, newState);
|
||
toggle.classList.toggle("cnv-toggle--on", newState);
|
||
toggle.querySelector(".cnv-toggle__label").textContent = newState ? "yes" : "no";
|
||
row.classList.toggle("cnv-row--enabled", newState);
|
||
});
|
||
}
|
||
|
||
// Click anywhere else on the row → jump to that group
|
||
row.addEventListener("click", (e) => {
|
||
if (e.button !== 0) return;
|
||
if (e.target.closest(".cnv-toggle")) return;
|
||
jumpToGroup(g);
|
||
});
|
||
|
||
// Double-click the row → TOGGLE the group: enable it when off, disable it
|
||
// when on. The toggle pill keeps its own click handler too; this is a quick
|
||
// "flip this group" gesture on the whole row. (The underlying single clicks
|
||
// still fire, so a dbl-click also pans here.)
|
||
row.addEventListener("dblclick", (e) => {
|
||
if (e.target.closest(".cnv-toggle")) return; // pill manages itself
|
||
const current = isGroupEnabled(title);
|
||
if (current === null) return; // no muter owns this group → nothing to flip
|
||
const next = !current;
|
||
if (!setGroupEnabled(title, next)) return;
|
||
if (toggle) {
|
||
toggle.classList.toggle("cnv-toggle--on", next);
|
||
const lbl = toggle.querySelector(".cnv-toggle__label");
|
||
if (lbl) lbl.textContent = next ? "yes" : "no";
|
||
}
|
||
row.classList.toggle("cnv-row--enabled", next);
|
||
});
|
||
|
||
state.listEl.appendChild(row);
|
||
}
|
||
}
|
||
|
||
/** Cheap re-sync of just the checkbox states without rebuilding the list.
|
||
* Catches the case where the user toggles a widget directly on the muter
|
||
* node (or via rgthree's keyboard shortcuts) and we need to reflect it in
|
||
* the panel. */
|
||
function syncCheckboxStates() {
|
||
if (!state.listEl) return;
|
||
for (const row of state.listEl.querySelectorAll(".cnv-row")) {
|
||
const title = row.dataset.title;
|
||
const toggle = row.querySelector(".cnv-toggle[data-toggle]");
|
||
if (!toggle) continue;
|
||
const enabled = isGroupEnabled(title);
|
||
if (enabled === null) continue;
|
||
const labelEl = toggle.querySelector(".cnv-toggle__label");
|
||
const isCurrentlyOn = toggle.classList.contains("cnv-toggle--on");
|
||
if (isCurrentlyOn !== enabled) {
|
||
toggle.classList.toggle("cnv-toggle--on", enabled);
|
||
if (labelEl) labelEl.textContent = enabled ? "yes" : "no";
|
||
}
|
||
// Keep the row's enabled-highlight class in sync with the toggle state
|
||
row.classList.toggle("cnv-row--enabled", !!enabled);
|
||
}
|
||
}
|
||
|
||
// --- keyboard hotkeys ---------------------------------------------------
|
||
|
||
function isTypingTarget(el) {
|
||
if (!el) return false;
|
||
const tag = el.tagName;
|
||
return tag === "INPUT" || tag === "TEXTAREA" || el.isContentEditable;
|
||
}
|
||
|
||
// Multi-digit shortcut buffer. With N groups we need keys "1".."N" to work,
|
||
// which means single digits like "1" might be a prefix of "10".."19" etc.
|
||
// Strategy: accumulate digits into a buffer; commit on (a) timeout, (b) a
|
||
// non-digit keypress, or (c) when no longer prefix could possibly extend
|
||
// into a valid row number.
|
||
let keyBuffer = "";
|
||
let bufferTimer = null;
|
||
const BUFFER_TIMEOUT = 350;
|
||
|
||
// Cancel any STOP, the *event* has already been consumed by the capture-phase
|
||
// handler — this just kills the pending flush timer (used when we commit early
|
||
// or when the buffer is reset).
|
||
function clearBufferTimer() {
|
||
if (bufferTimer) { clearTimeout(bufferTimer); bufferTimer = null; }
|
||
}
|
||
|
||
function resetBuffer() {
|
||
clearBufferTimer();
|
||
keyBuffer = "";
|
||
}
|
||
|
||
function tryJump(key) {
|
||
const targetTitle = resolveShortcut(key);
|
||
if (!targetTitle) return false;
|
||
const groups = getNavigableGroups();
|
||
for (let i = 0; i < groups.length; i++) {
|
||
if (groupTitle(groups[i], i) === targetTitle) {
|
||
jumpToGroup(groups[i]);
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function flushBuffer() {
|
||
const k = keyBuffer;
|
||
resetBuffer();
|
||
if (k) tryJump(k);
|
||
}
|
||
|
||
function onKeyDown(e) {
|
||
if (isTypingTarget(document.activeElement)) return;
|
||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||
const k = (e.key || "").toLowerCase();
|
||
if (k.length !== 1) return;
|
||
|
||
// CRITICAL: stop the event before rgthree's Bookmark node can grab it.
|
||
// rgthree subscribes to keydown via its own KEY_EVENT_SERVICE which
|
||
// listens on window/document. Calling stopImmediatePropagation on this
|
||
// capture-phase handler prevents the same event from reaching any
|
||
// other listeners attached to the same target, AND preventDefault
|
||
// tells the underlying ComfyUI / browser stack to leave this key alone.
|
||
// We only swallow the event when we're *going* to handle it (i.e. a
|
||
// mapping exists, or we're buffering a partial multi-digit shortcut).
|
||
const swallow = () => {
|
||
e.preventDefault();
|
||
e.stopImmediatePropagation();
|
||
e.stopPropagation();
|
||
};
|
||
|
||
// --- digit: append to buffer and decide whether to commit now or wait
|
||
if (/^[0-9]$/.test(k)) {
|
||
const newBuf = keyBuffer + k;
|
||
const totalRows = getNavigableGroups().length;
|
||
const asNum = parseInt(newBuf, 10);
|
||
const hasMappingNow = resolveShortcut(newBuf) != null;
|
||
// A *longer* buffer could still resolve iff (asNum * 10) is a valid row
|
||
// index, i.e. there's room to grow the number by one more digit.
|
||
// We cap buffer length at 3 (rows 1..999) as a sanity ceiling.
|
||
const canExtend =
|
||
asNum > 0 && (asNum * 10) <= totalRows && newBuf.length < 3;
|
||
|
||
if (!hasMappingNow && !canExtend) {
|
||
// Dead end. Flush whatever WAS buffered, then ignore this key entirely
|
||
// so it bubbles to other handlers (rgthree bookmark, etc.).
|
||
flushBuffer();
|
||
return;
|
||
}
|
||
|
||
keyBuffer = newBuf;
|
||
clearBufferTimer();
|
||
swallow();
|
||
|
||
if (!canExtend) {
|
||
// No longer prefix is possible — commit immediately, no need to wait.
|
||
flushBuffer();
|
||
return;
|
||
}
|
||
|
||
// Wait briefly for more digits; on timeout, commit current buffer.
|
||
bufferTimer = setTimeout(flushBuffer, BUFFER_TIMEOUT);
|
||
return;
|
||
}
|
||
|
||
// --- non-digit: any buffered digits are now stale → flush, then handle key
|
||
flushBuffer();
|
||
if (tryJump(k)) swallow();
|
||
}
|
||
|
||
// --- lifecycle ----------------------------------------------------------
|
||
|
||
let renderScheduled = false;
|
||
function scheduleRender() {
|
||
if (renderScheduled) return;
|
||
renderScheduled = true;
|
||
setTimeout(() => { renderScheduled = false; renderList(); }, 50);
|
||
}
|
||
|
||
app.registerExtension({
|
||
name: "comfyui.navigator",
|
||
async setup() {
|
||
ensureStyles();
|
||
ensurePanel();
|
||
renderList();
|
||
window.addEventListener("keydown", onKeyDown, true);
|
||
|
||
// Re-render when groups added / removed / graph swapped
|
||
const origNodeAdded = app.graph.onNodeAdded;
|
||
app.graph.onNodeAdded = function (node) {
|
||
origNodeAdded?.call(this, node);
|
||
scheduleRender();
|
||
};
|
||
const origNodeRemoved = app.graph.onNodeRemoved;
|
||
app.graph.onNodeRemoved = function (node) {
|
||
origNodeRemoved?.call(this, node);
|
||
scheduleRender();
|
||
};
|
||
|
||
// Catch graph swaps (loading a different workflow). ComfyUI fires
|
||
// a custom "graphConfigured" or similar; polling _groups length as
|
||
// a cheap fallback. Also re-sync checkbox states so that toggles
|
||
// made directly on the muter node propagate to the panel UI.
|
||
//
|
||
// CRITICAL: also re-render when placeholders could become real toggles.
|
||
// Workflow load + muter-widget construction is async; the panel often
|
||
// renders BEFORE any muter widget exists, baking every row into a
|
||
// placeholder (no click handler). Without this check, the panel stays
|
||
// dead even after widgets become available.
|
||
let lastGroupCount = -1;
|
||
let lastMuterWidgetCount = -1;
|
||
setInterval(() => {
|
||
const n = (app.graph?._groups?.length || 0);
|
||
const muterWidgetCount = (app.graph?._nodes || [])
|
||
.filter((node) => node?.type === "Fast Groups Muter (rgthree)")
|
||
.reduce((sum, m) => sum + (m.widgets?.length || 0), 0);
|
||
const placeholderCount = document.querySelectorAll(".cnv-toggle--placeholder").length;
|
||
const needsRerender =
|
||
n !== lastGroupCount ||
|
||
muterWidgetCount !== lastMuterWidgetCount ||
|
||
(placeholderCount > 0 && muterWidgetCount > 0);
|
||
if (needsRerender) {
|
||
lastGroupCount = n;
|
||
lastMuterWidgetCount = muterWidgetCount;
|
||
scheduleRender();
|
||
} else {
|
||
syncCheckboxStates();
|
||
}
|
||
}, 600);
|
||
},
|
||
async loadedGraphNode() {
|
||
scheduleRender();
|
||
},
|
||
});
|