Files
90-RED-ComfyUI-LinkRouter/web/ui.js
T
90-RED 9317aa3c2a v1.0.0 — initial release
- Orthogonal edge routing (OVG + A*, Wybrow GD'09)
- Flow animation (dots/pills/arrows/ovals/dashes)
- Hover highlight + select dim
- Incremental rerouting with path stickiness
- Floating button bar
- Full ComfyUI settings panel
- Subgraph compatible
2026-07-06 04:16:55 +08:00

267 lines
9.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
}