- 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
267 lines
9.3 KiB
JavaScript
267 lines
9.3 KiB
JavaScript
// 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;
|
||
}
|