// ui.js β€” floating button bar, hover tracking, and settings dialog for LinkRouter. import { app } from "../../scripts/app.js"; import { M } from "./state.js"; import { nodeRect } from "./routing.js"; import { SETTINGS, applySetting } from "./settings.js"; import { profiler } from "./profiler.js"; import { adaptiveToggleTarget, barLeftFor, barStoredXFor, FIXED_DRAG_MODES, isFixedDragMode, } from "./ui-policy.js"; import { linkIdAtPoint, slotLinkIdsAt } from "./draw-policy.js"; // ---------------------------------------------------- link mode helpers const LINK_MODES = [ { emoji: "🌊", name: "Spline", value: 2 }, { emoji: "πŸ“", name: "Linear", value: 1 }, { emoji: "βž–", name: "Straight", value: 0 }, ]; function getOfficialLinkMode() { try { const v = app.ui.settings.getSettingValue("Comfy.LinkRenderMode"); if (v !== undefined && v !== null) return +v; } catch {} return app.canvas?.links_render_mode ?? 2; } function hiddenLinkValue() { return typeof LiteGraph !== "undefined" && LiteGraph.HIDDEN_LINK !== undefined ? LiteGraph.HIDDEN_LINK : 3; } export function linksHidden(canvas) { const mode = canvas?.links_render_mode ?? getOfficialLinkMode(); return mode === hiddenLinkValue(); } function setOfficialLinkMode(v) { try { app.ui.settings.setSettingValue("Comfy.LinkRenderMode", v); } catch {} if (app.canvas) app.canvas.links_render_mode = v; app.canvas?.setDirty(true, true); } // ---------------------------------------------------- drag mode // ---------------------------------------------------- settings dialog // Open ComfyUI settings, directly on the LinkRouter panel when possible. // 1.45.x: the official path is the Comfy.ShowSettingsDialog command // (useCoreCommands.ts); app.ui.settings.show() only opens an empty legacy // dialog shell (scripts/ui/settings.ts is a deprecated compat shim), so those // fallbacks were removed. function openSettings() { try { const cmds = app.extensionManager?.command; if (cmds?.execute) { cmds.execute("Comfy.ShowSettingsDialog"); setTimeout(() => { try { const items = document.querySelectorAll( ".settings-sidebar li, .p-listbox-option, [role='option'], .p-tree-node-label", ); for (const el of items) if (el.textContent?.trim() === "LinkRouter") { el.click(); break; } } catch {} }, 120); return; } } catch {} try { const dlg = app.extensionManager?.dialog; if (dlg?.showSettingsDialog) { dlg.showSettingsDialog({ props: { defaultPanel: "LinkRouter" } }); return; } } catch {} try { ( document.querySelector("button.comfy-settings-btn") || document.querySelector("[aria-label='Settings']") || document.querySelector(".pi-cog")?.closest("button") )?.click(); } catch {} } // ---------------------------------------------------- refresh bar function isVueNodesEnabled() { return typeof LiteGraph !== "undefined" && LiteGraph.vueNodesMode === true; } function toggleVueNodes() { try { const next = !isVueNodesEnabled(); if (typeof LiteGraph !== "undefined") LiteGraph.vueNodesMode = next; app.ui.settings.setSettingValue("Comfy.VueNodes.Enabled", next); M.resetRouter(); app.canvas?.setDirty(true, true); } catch {} } export function refreshBar() { if (!M.uiBox || !M.barRefs) return; M.uiBox.style.display = M.S.showButton ? "flex" : "none"; M.barRefs.setActive(M.barRefs.toggle, M.S.enabled); const flowEmoji = { animated: "✨", static: "➀", none: "β—Ύ" }; M.barRefs.anim.textContent = flowEmoji[M.S.flowMode] || "✨"; M.barRefs.setActive(M.barRefs.anim, M.S.flowMode !== "none"); M.barRefs.anim.title = ["animated", "static", "none"] .map((v) => (v === M.S.flowMode ? "✨ " + v + " β—€" : "➀ " + v)) .join("\n"); M.barRefs.setActive(M.barRefs.vueToggle, isVueNodesEnabled()); M.barRefs.vueToggle.title = "Nodes 2.0: " + (isVueNodesEnabled() ? "ON" : "OFF"); M.barRefs.setActive(M.barRefs.workerBtn, !!M.S.workerRouting && !M._workerFailed); M.barRefs.workerBtn.title = M._workerFailed ? "Worker routing: unavailable (using main thread)" : "Worker routing: " + (M.S.workerRouting ? "ON" : "OFF"); M.barRefs.setActive(M.barRefs.pauseWorkerBtn, !!M.S.workerHeldPause && !M._workerFailed); M.barRefs.pauseWorkerBtn.title = M._workerFailed ? "Held-pause worker: unavailable (using main thread)" : "Held-pause worker routing: " + (M.S.workerHeldPause ? "ON" : "OFF"); M.barRefs.setActive(M.barRefs.escalateBtn, M.S.adaptiveEscalation !== false); M.barRefs.escalateBtn.title = "Adaptive measured-lag escalation: " + (M.S.adaptiveEscalation !== false ? "ON" : "OFF"); const dragMode = M.S.dragMode || "adaptive"; const adaptiveOn = dragMode === "adaptive"; M.barRefs.dragBtn.textContent = "🧠"; M.barRefs.dragBtn.title = "Adaptive drag mode: " + (adaptiveOn ? "ON" : "OFF"); M.barRefs.setActive(M.barRefs.dragBtn, adaptiveOn); M.barRefs.dragModeRow.style.display = adaptiveOn ? "none" : "flex"; for (const [value, button] of M.barRefs.dragModeButtons) M.barRefs.setActive(button, value === dragMode); const debugPanelOpen = M.S.showDebugButton && M.barState.debugPanel; M.barRefs.setActive(M.barRefs.debug, debugPanelOpen); M.barRefs.debug.style.display = M.S.showDebugButton ? "" : "none"; M.barRefs.debug.title = debugPanelOpen ? "Close debug controls" : "Open debug controls"; M.barRefs.debugRow.style.display = debugPanelOpen ? "flex" : "none"; M.barRefs.setActive(M.barRefs.overlayBtn, M.barState.debug); M.barRefs.overlayBtn.title = "Routing debug overlay: " + (M.barState.debug ? "ON" : "OFF"); M.barRefs.setActive(M.barRefs.flowBtn, M.S.flowMode !== "none"); M.barRefs.flowBtn.title = "Flow markers: " + (M.S.flowMode !== "none" ? "ON" : "OFF"); const animThr = +M.S.animAdaptiveThreshold || 10; M.barRefs.setActive(M.barRefs.densityBtn, M.S.animAdaptive !== false); M.barRefs.densityBtn.title = "Adaptive marker density: " + (M.S.animAdaptive !== false ? "ON" : "OFF") + " (>" + animThr + " animated links β†’ half, >" + (animThr * 3) + " β†’ 20%)"; M.barRefs.setActive(M.barRefs.hoverBtn, M.S.hoverAnim); M.barRefs.hoverBtn.title = "Hover animation: " + (M.S.hoverAnim ? "ON" : "OFF"); M.barRefs.setActive(M.barRefs.selectBtn, M.S.selectAnim); M.barRefs.selectBtn.title = "Selection animation: " + (M.S.selectAnim ? "ON" : "OFF"); M.barRefs.setActive(M.barRefs.outlineBtn, M.S.outline); M.barRefs.outlineBtn.title = "Line outline: " + (M.S.outline ? "ON" : "OFF"); M.barRefs.setActive(M.barRefs.cornerBtn, M.S.cornerMode !== "off"); M.barRefs.cornerBtn.title = "Rounded corners: " + (M.S.cornerMode !== "off" ? "ON" : "OFF"); M.barRefs.recordBtn.textContent = profiler.active ? "⏹" : "⏺"; M.barRefs.setActive(M.barRefs.recordBtn, profiler.active); if (profiler.active) M.barRefs.recordBtn.style.background = "#b33"; const recordSecs = M.barState.recordSeconds || 30; M.barRefs.recordBtn.title = profiler.active ? "Stop now and save the report to LinkRouter_log" : "Record LinkRouter performance for " + recordSecs + "s (auto-saves to LinkRouter_log)"; // Duration cycler (was the copy button): reports auto-save on every stop. M.barRefs.durBtn.textContent = recordSecs + "s"; M.barRefs.setActive(M.barRefs.durBtn, true); M.barRefs.durBtn.title = "Profiler duration: " + recordSecs + "s β€” click to cycle 15 / 30 / 45\n" + (profiler.saving ? "Saving report…" : profiler.lastSaveOk === false ? "⚠ Last save FAILED β€” restart ComfyUI so the backend endpoint loads" : profiler.lastSavedFile ? "Last saved: LinkRouter_log/" + profiler.lastSavedFile : "Reports auto-save to custom_nodes/.disabled/LinkRouter_log"); const linkCur = getOfficialLinkMode(); const m = LINK_MODES.find((l) => l.value === linkCur) || LINK_MODES[0]; M.barRefs.linkMode.textContent = m.emoji; M.barRefs.linkMode.title = LINK_MODES.map((l) => l.emoji + " " + l.name + (l.value === linkCur ? " β—€" : "")).join("\n"); // Grow leftward: anchor the collapsed bar's right edge so opening a wider // row (drag modes / debug controls) extends the box to the left instead of // pushing its right edge across the canvas. baseW is re-measured whenever // every extra row is hidden; btnX stays the collapsed-basis left edge // (adopt the build-time position once if it was never stored). const box = M.uiBox; const rowsHidden = M.barRefs.dragModeRow.style.display === "none" && M.barRefs.debugRow.style.display === "none"; if (M.barState.btnX == null && box.offsetWidth > 0) M.barState.btnX = parseFloat(box.style.left) || 0; if (rowsHidden) { if (box.offsetWidth > 0) M._barBaseW = box.offsetWidth; if (M.barState.btnX != null) box.style.left = M.barState.btnX + "px"; } else { const baseW = M._barBaseW || box.offsetWidth; box.style.left = barLeftFor(M.barState.btnX ?? 0, baseW, box.offsetWidth) + "px"; } } // ---------------------------------------------------- build UI export function buildUI() { const box = (M.uiBox = document.createElement("div")); box.style.cssText = "position:fixed;z-index:9999;display:flex;flex-direction:column;gap:6px;align-items:stretch;" + "background:rgba(30,30,30,.88);border:1px solid #555;border-radius:10px;" + "padding:6px 8px;user-select:none;"; const defX = Math.max(8, Math.round(innerWidth * 0.5 - 100)); const defY = Math.max(8, Math.round(innerHeight * 0.35)); box.style.left = (M.barState.btnX ?? defX) + "px"; box.style.top = (M.barState.btnY ?? defY) + "px"; const mkBtn = (emoji, title) => { const b = document.createElement("button"); b.textContent = emoji; b.title = title; b.style.cssText = "border:1px solid #666;border-radius:8px;background:#333;color:#eee;" + "font-size:20px;line-height:1;padding:8px 10px;cursor:pointer;" + "transition:transform .08s ease, background .12s ease, box-shadow .12s ease;"; b.addEventListener("pointerenter", () => { b.style.transform = "scale(1.12)"; b.style.boxShadow = "0 0 6px rgba(120,220,160,.5)"; }); b.addEventListener("pointerleave", () => { b.style.transform = "scale(1)"; b.style.boxShadow = "none"; }); b.addEventListener("pointerdown", () => (b.style.transform = "scale(0.88)")); b.addEventListener("pointerup", () => (b.style.transform = "scale(1.12)")); return b; }; const setActive = (b, on) => { b.style.background = on ? "#2a6" : "#333"; }; // Rows right-align their buttons (justify-content:flex-end): when a wider // row opens and the box grows leftward (barLeftFor), the top row's buttons // stay pinned to the anchored right edge instead of travelling left. const mainRow = document.createElement("div"); mainRow.style.cssText = "display:flex;gap:6px;align-items:center;justify-content:flex-end;"; const dragModeRow = document.createElement("div"); dragModeRow.style.cssText = "display:none;gap:6px;align-items:center;justify-content:flex-end;padding-top:6px;border-top:1px solid #555;"; const debugRow = document.createElement("div"); debugRow.style.cssText = "display:none;gap:6px;align-items:center;justify-content:flex-end;padding-top:6px;border-top:1px solid #555;"; const setPluginSetting = (key, value) => { applySetting(key, value); try { app.ui.settings.setSettingValue(SETTINGS[key][0], value); } catch {} refreshBar(); }; const toggle = mkBtn("πŸ”€", "Route on/off"); const linkMode = mkBtn("🌊", "Link style"); const anim = mkBtn("✨", "Flow markers"); const dragBtn = mkBtn("🧠", "Drag mode"); const dragModeButtons = new Map(); for (const mode of FIXED_DRAG_MODES) { const button = mkBtn(mode.emoji, mode.name); button.style.fontSize = "17px"; button.style.padding = "7px 9px"; button.onclick = () => { M.barState.lastManualDragMode = mode.value; M.saveBarState(); setPluginSetting("dragMode", mode.value); }; dragModeButtons.set(mode.value, button); dragModeRow.append(button); } const settingsBtn = mkBtn("βš™οΈ", "Settings"); const debug = mkBtn("🐞", "Open debug controls"); const closeBtn = mkBtn("βœ–", "Hide bar"); const handle = mkBtn("βœ₯", "Move bar"); const overlayBtn = mkBtn("πŸŸ₯", "Routing debug overlay"); const flowBtn = mkBtn("✨", "Flow markers on/off"); const densityBtn = mkBtn("🎚️", "Adaptive marker density on/off"); const hoverBtn = mkBtn("πŸ–±οΈ", "Hover animation on/off"); const selectBtn = mkBtn("🎯", "Selection animation on/off"); const outlineBtn = mkBtn("β—‰", "Line outline on/off"); const cornerBtn = mkBtn("β—œ", "Rounded corners on/off"); const vueToggle = mkBtn("2️⃣", "Nodes 2.0 on/off"); const workerBtn = mkBtn("🧡", "Background worker routing on/off"); const pauseWorkerBtn = mkBtn("βœ‹", "Held-pause worker routing on/off"); const escalateBtn = mkBtn("πŸ“ˆ", "Adaptive measured-lag escalation on/off"); const recordBtn = mkBtn("⏺", "Record performance"); const durBtn = mkBtn((M.barState.recordSeconds || 30) + "s", "Profiler duration: click to cycle 15 / 30 / 45 seconds"); for (const b of [overlayBtn, flowBtn, densityBtn, hoverBtn, selectBtn, outlineBtn, cornerBtn, vueToggle, workerBtn, pauseWorkerBtn, escalateBtn, recordBtn, durBtn]) { b.style.fontSize = "16px"; b.style.padding = "7px 9px"; } handle.style.cursor = "grab"; handle.style.background = "transparent"; handle.style.border = "none"; toggle.onclick = () => { applySetting("enabled", !M.S.enabled); try { app.ui.settings.setSettingValue(SETTINGS.enabled[0], M.S.enabled); } catch {} refreshBar(); }; linkMode.onclick = () => { const cur = getOfficialLinkMode(); const idx = LINK_MODES.findIndex((m) => m.value === cur); const next = LINK_MODES[(idx + 1) % LINK_MODES.length]; setOfficialLinkMode(next.value); refreshBar(); }; anim.onclick = () => { const order = ["animated", "static", "none"]; const next = order[(order.indexOf(M.S.flowMode) + 1) % order.length]; applySetting("flowMode", next); try { app.ui.settings.setSettingValue(SETTINGS.flowMode[0], next); } catch {} refreshBar(); }; closeBtn.onclick = () => { applySetting("showButton", false); try { app.ui.settings.setSettingValue(SETTINGS.showButton[0], false); } catch {} }; settingsBtn.onclick = () => { openSettings(); }; debug.onclick = () => { M.barState.debugPanel = !M.barState.debugPanel; M.saveBarState(); refreshBar(); }; overlayBtn.onclick = () => { M.barState.debug = !M.barState.debug; M.saveBarState(); refreshBar(); app.canvas?.setDirty(true, true); }; flowBtn.onclick = () => { if (M.S.flowMode === "none") { setPluginSetting("flowMode", M.barState.lastFlowMode || "animated"); } else { M.barState.lastFlowMode = M.S.flowMode; M.saveBarState(); setPluginSetting("flowMode", "none"); } }; densityBtn.onclick = () => setPluginSetting("animAdaptive", M.S.animAdaptive === false); hoverBtn.onclick = () => setPluginSetting("hoverAnim", !M.S.hoverAnim); selectBtn.onclick = () => setPluginSetting("selectAnim", !M.S.selectAnim); outlineBtn.onclick = () => setPluginSetting("outline", !M.S.outline); cornerBtn.onclick = () => { if (M.S.cornerMode === "off") { setPluginSetting("cornerMode", M.barState.lastCornerMode || "per-line"); } else { M.barState.lastCornerMode = M.S.cornerMode; M.saveBarState(); setPluginSetting("cornerMode", "off"); } }; vueToggle.onclick = () => { toggleVueNodes(); refreshBar(); }; workerBtn.onclick = () => setPluginSetting("workerRouting", !M.S.workerRouting); pauseWorkerBtn.onclick = () => setPluginSetting("workerHeldPause", !M.S.workerHeldPause); escalateBtn.onclick = () => setPluginSetting("adaptiveEscalation", M.S.adaptiveEscalation === false); recordBtn.onclick = () => { if (profiler.active) profiler.stop("manual"); else profiler.start((M.barState.recordSeconds || 30) * 1000); refreshBar(); }; durBtn.onclick = () => { const order = [15, 30, 45]; const cur = M.barState.recordSeconds || 30; M.barState.recordSeconds = order[(order.indexOf(cur) + 1) % order.length]; M.saveBarState(); refreshBar(); }; dragBtn.onclick = () => { const current = M.S.dragMode || "adaptive"; if (isFixedDragMode(current)) { M.barState.lastManualDragMode = current; M.saveBarState(); } setPluginSetting( "dragMode", adaptiveToggleTarget(current, M.barState.lastManualDragMode), ); }; mainRow.append(toggle, linkMode, anim, dragBtn, settingsBtn, debug, closeBtn, handle); debugRow.append(overlayBtn, flowBtn, densityBtn, hoverBtn, selectBtn, outlineBtn, cornerBtn, vueToggle, workerBtn, pauseWorkerBtn, escalateBtn, recordBtn, durBtn); box.append(mainRow, dragModeRow, debugRow); M.barRefs = { toggle, linkMode, anim, vueToggle, workerBtn, pauseWorkerBtn, escalateBtn, dragBtn, dragModeRow, dragModeButtons, debug, debugRow, overlayBtn, flowBtn, densityBtn, hoverBtn, selectBtn, outlineBtn, cornerBtn, recordBtn, durBtn, setActive, }; profiler.onChange = refreshBar; let drag = null; handle.addEventListener("pointerdown", (ev) => { drag = { dx: ev.clientX - box.offsetLeft, dy: ev.clientY - box.offsetTop }; handle.setPointerCapture(ev.pointerId); handle.style.cursor = "grabbing"; ev.preventDefault(); }); handle.addEventListener("pointermove", (ev) => { if (!drag) return; const rawX = Math.max(0, Math.min(innerWidth - 60, ev.clientX - drag.dx)); const rawY = Math.max(0, Math.min(innerHeight - 30, ev.clientY - drag.dy)); // The box tracks the pointer directly; barState stores the collapsed- // basis position so the left-growth anchor in refreshBar keeps holding. M.barState.btnX = barStoredXFor(rawX, M._barBaseW || box.offsetWidth, box.offsetWidth); M.barState.btnY = rawY; box.style.left = rawX + "px"; box.style.top = rawY + "px"; }); handle.addEventListener("pointerup", (ev) => { if (!drag) return; drag = null; handle.style.cursor = "grab"; handle.releasePointerCapture(ev.pointerId); M.saveBarState(); }); document.body.appendChild(box); // Measure the collapsed base width BEFORE the first refreshBar() can open // an extra row (debugPanel may be persisted open): both rows are still // display:none here, so offsetWidth is exactly the collapsed width. Without // this, a first load with the debug panel open measures nothing and the // bar does not grow leftward until every row is closed once. if (box.offsetWidth > 0) M._barBaseW = box.offsetWidth; refreshBar(); } // ---------------------------------------------------- hover tracking // Graph-space pointer position, or null when the pointer is outside the // canvas element. function pointerGraphPos(canvas) { if (!M.mouseClient || !canvas?.ds) return null; const el = canvas.canvas; if (!el) return null; const rect = el.getBoundingClientRect(); if ( M.mouseClient.x < rect.left || M.mouseClient.x > rect.right || M.mouseClient.y < rect.top || M.mouseClient.y > rect.bottom ) return null; const scale = canvas.ds.scale || 1; return { gx: (M.mouseClient.x - rect.left) / scale - canvas.ds.offset[0], gy: (M.mouseClient.y - rect.top) / scale - canvas.ds.offset[1], scale, }; } function nodeAtPoint(canvas, p) { const nodes = canvas.graph?._nodes || []; for (let i = nodes.length - 1; i >= 0; i--) { const r = nodeRect(nodes[i]); if (p.gx >= r.x && p.gx <= r.x + r.w && p.gy >= r.y && p.gy <= r.y + r.h) return nodes[i]; } return null; } // Slot-label hover: pointer over an input/output name or dot on the node // under the cursor. Returns { ids, nodeId } for the connected links, or null // when no linked slot is under the pointer (callers then fall back to // link/node hover). function hoverSlotLinkIdsAt(canvas) { const p = pointerGraphPos(canvas); if (!p) return null; const node = canvas.node_over || nodeAtPoint(canvas, p); if (!node) return null; const ids = slotLinkIdsAt(node, p.gx, p.gy); return ids ? { ids, nodeId: node.id } : null; } // Single-link hover: pointer directly over a link's drawn path. Tolerance is // screen-relative (~9px) converted to graph units, min 3. Uses the routed set // from the last drawn frame (M.routeResults) β€” exactly what is on screen. function hoverLinkIdAt(canvas) { const p = pointerGraphPos(canvas); if (!p) return null; const tol = Math.max(3, 9 / p.scale); return linkIdAtPoint(M.routeResults || [], p.gx, p.gy, tol); } // Track pointer at document level (works above DOM widget overlays like // text areas) and repaint when the hovered node changes. export function watchHover() { let lastHover = null; let lastHoverLink = null; let lastHoverSlotKey = null; document.addEventListener( "pointermove", (ev) => { M.mouseClient = { x: ev.clientX, y: ev.clientY }; if (!M.S.enabled || !M.S.hoverAnim) return; const canvas = app.canvas; if (!canvas) return; // Slot-label hover takes priority over link hover and node hover: // pointing at an input/output name or dot animates/thickens only that // slot's links. Skipped during a node drag, like link hover. const slotHit = M._nodeDragActive ? null : hoverSlotLinkIdsAt(canvas); const slotKey = slotHit ? String(slotHit.nodeId) + ":" + slotHit.ids.join(",") : null; if (slotKey !== lastHoverSlotKey) { lastHoverSlotKey = slotKey; M._hoverSlotLinkIds = slotHit ? new Set(slotHit.ids) : null; M._hoverSlotNodeId = slotHit ? slotHit.nodeId : null; canvas.setDirty(true, true); } // Single-link hover: pointing directly at a link animates/thickens just // that link. Suppressed while a slot hover is active. const curLink = slotHit || M._nodeDragActive ? null : hoverLinkIdAt(canvas); if (curLink !== lastHoverLink) { lastHoverLink = curLink; M._hoverLinkId = curLink; canvas.setDirty(true, true); } const cur = slotHit || curLink !== null ? null : drawHoverNodeId(canvas); if (cur !== lastHover) { lastHover = cur; canvas.setDirty(true, true); } }, { passive: true }, ); } // Duplicated from draw.js hoverNodeId to avoid circular drawβ†’ui import. // (ui exports watchHover which is called from smart-edge.js setup, and // draw.js also needs hoverNodeId for drawAll. Keeping the detection // logic here and using it in both places would require drawβ†’ui which // we don't want. The ~20-line function is small enough to duplicate.) function drawHoverNodeId(canvas) { if (canvas.node_over) return canvas.node_over.id; const p = pointerGraphPos(canvas); if (!p) return null; return nodeAtPoint(canvas, p)?.id ?? null; }