// 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"; // ---------------------------------------------------- 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); } // ---------------------------------------------------- settings dialog // Open ComfyUI settings, directly on the LinkRouter panel when possible. function openSettings() { try { const dlg = app.extensionManager?.dialog; if (dlg?.showSettingsDialog) { dlg.showSettingsDialog({ props: { defaultPanel: "LinkRouter" } }); return; } } catch {} 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 { if (app.ui?.settings?.show) { app.ui.settings.show(); return; } } catch {} try { if (app.ui?.settings?.element) { app.ui.settings.element.style.display = "block"; return; } } catch {} try { ( document.querySelector("button.comfy-settings-btn") || document.querySelector("[aria-label='Settings']") || document.querySelector(".pi-cog")?.closest("button") )?.click(); } catch {} } // ---------------------------------------------------- refresh bar 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 = "Flow markers: " + M.S.flowMode + " (click to cycle)"; M.barRefs.setActive(M.barRefs.debug, M.barState.debug); M.barRefs.debug.style.display = M.S.showDebugButton ? "" : "none"; const m = LINK_MODES.find((m) => m.value === getOfficialLinkMode()) || LINK_MODES[0]; M.barRefs.linkMode.textContent = m.emoji; M.barRefs.linkMode.title = "Official link style: " + m.name + " (click to cycle; used when LinkRouter is off)"; } // ---------------------------------------------------- build UI export function buildUI() { const box = (M.uiBox = document.createElement("div")); box.style.cssText = "position:fixed;z-index:9999;display:flex;gap:6px;align-items:center;" + "background:rgba(30,30,30,.88);border:1px solid #555;border-radius:10px;" + "padding:6px 8px;user-select:none;"; const defX = Math.max(8, innerWidth - 340); const defY = Math.max(8, Math.round(innerHeight * 0.38)); 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"; }; const toggle = mkBtn("πŸ”€", "LinkRouter routing on/off"); const linkMode = mkBtn("🌊", "Official link style (used when LinkRouter is off)"); const anim = mkBtn("✨", "Flow markers: animated / static arrows / none"); const settingsBtn = mkBtn("βš™οΈ", "Open ComfyUI settings"); const debug = mkBtn("🐞", "LinkRouter debug overlay"); const closeBtn = mkBtn("βœ–", "Hide this bar (re-enable in Settings > LinkRouter)"); const handle = mkBtn("βœ₯", "Drag to move this bar"); 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.debug = !M.barState.debug; M.saveBarState(); refreshBar(); app.canvas?.setDirty(true, true); }; box.append(toggle, linkMode, anim, settingsBtn, debug, closeBtn, handle); M.barRefs = { toggle, linkMode, anim, debug, setActive }; 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; M.barState.btnX = Math.max(0, Math.min(innerWidth - 60, ev.clientX - drag.dx)); M.barState.btnY = Math.max(0, Math.min(innerHeight - 30, ev.clientY - drag.dy)); box.style.left = M.barState.btnX + "px"; box.style.top = M.barState.btnY + "px"; }); handle.addEventListener("pointerup", (ev) => { if (!drag) return; drag = null; handle.style.cursor = "grab"; handle.releasePointerCapture(ev.pointerId); M.saveBarState(); }); document.body.appendChild(box); refreshBar(); } // ---------------------------------------------------- hover tracking // 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; 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; const cur = 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; 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; const gx = (M.mouseClient.x - rect.left) / scale - canvas.ds.offset[0]; const gy = (M.mouseClient.y - rect.top) / scale - canvas.ds.offset[1]; const nodes = canvas.graph?._nodes || []; for (let i = nodes.length - 1; i >= 0; i--) { const r = nodeRect(nodes[i]); if (gx >= r.x && gx <= r.x + r.w && gy >= r.y && gy <= r.y + r.h) return nodes[i].id; } return null; }