Files
90-RED 8246479bee v1.2.0 — Adaptive drag modes, Heavy Deferred, large-workflow performance
Major features:
- Adaptive drag mode with 5 fixed modes (Normal, Freeze+Check, Freeze, Hide, Heavy Deferred)
- Heavy Deferred for lag-free dragging in large workflows (250+ nodes)
- Three drag phases: moving → held → released, with priority-based re-routing during pause
- Node hard-obstacle avoidance — links can never pass through nodes

Performance:
- Progressive routing with frame-budgeted progressive batches
- Fast graph signature for geometry-change detection
- OVG rebuild deferred during large drags
- Collision re-check limited to affected links only

Fixes:
- Hover no longer triggers full-workflow redraw
- Krea2 / Ultimate SD Upscale link disappearance during drag
- Stale sticky paths now validated before reuse
- Drag-endpoint dogleg for straight-line links

New modules: adaptive-policy, drag-policy, draw-policy, profiler, progressive, ui-policy
Tests: 39 automated tests covering routing, adaptive, drag, profiler
Floating bar: Adaptive 🧠 toggle, 5 fixed-mode buttons, debug row, Nodes 2.0 switch
2026-07-13 03:34:42 +08:00

429 lines
16 KiB
JavaScript
Raw Permalink 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";
import { profiler } from "./profiler.js";
import {
adaptiveToggleTarget,
FIXED_DRAG_MODES,
isFixedDragMode,
} from "./ui-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.
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
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");
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");
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";
M.barRefs.recordBtn.title = profiler.active
? "Stop profiler now and save the report"
: "Record LinkRouter performance for 30 seconds";
M.barRefs.setActive(M.barRefs.copyBtn, !!profiler.lastReport);
M.barRefs.copyBtn.title = profiler.lastReport
? "Copy the last profiler report"
: "No profiler report recorded yet";
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");
}
// ---------------------------------------------------- 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";
};
const mainRow = document.createElement("div");
mainRow.style.cssText = "display:flex;gap:6px;align-items:center;";
const dragModeRow = document.createElement("div");
dragModeRow.style.cssText =
"display:none;gap:6px;align-items:center;padding-top:6px;border-top:1px solid #555;";
const debugRow = document.createElement("div");
debugRow.style.cssText =
"display:none;gap:6px;align-items:center;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 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 recordBtn = mkBtn("⏺", "Record performance for 30 seconds");
const copyBtn = mkBtn("📋", "Copy the last performance report");
for (const b of [overlayBtn, flowBtn, hoverBtn, selectBtn, outlineBtn, cornerBtn, vueToggle, recordBtn, copyBtn]) {
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");
}
};
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();
};
recordBtn.onclick = () => {
if (profiler.active) profiler.stop("manual");
else profiler.start();
refreshBar();
};
copyBtn.onclick = async () => {
if (!(await profiler.copyLastReport())) return;
copyBtn.textContent = "✅";
setTimeout(() => refreshBar(), 1200);
};
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, hoverBtn, selectBtn, outlineBtn, cornerBtn, vueToggle, recordBtn, copyBtn);
box.append(mainRow, dragModeRow, debugRow);
M.barRefs = {
toggle, linkMode, anim, vueToggle, dragBtn, dragModeRow, dragModeButtons, debug, debugRow,
overlayBtn, flowBtn, hoverBtn, selectBtn, outlineBtn, cornerBtn,
recordBtn, copyBtn, 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;
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;
}