Files
Danchi93 75cbd8ff16 Fix and harden node UI across classic & Vue render modes; document Video Frame Extractor
- Multi LoRA Loader: robust auto-height and width lock, refit on workflow load, min-height contract for Vue nodes

- VideoFrameExtractor: adapt DOM structure on hot render-mode switch (classic <-> Vue, no reload); fix drag-resize stickiness; layout and badge fixes

- PromptSegments / ResolutionSwitcher: stability fixes

- README: document Video Frame Extractor node (EN/ZH)
2026-10-12 20:11:13 +08:00

953 lines
47 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.
import { app } from "../../../scripts/app.js";
// 语言配置:将下面一行的 "en" 改成 "zh" 即可切换中文
const LANG = "en";
const I18N = {
en: { on_off: "On/Off", lora: "LoRA", strength: "Strength", note: "Note", del: "Del",
trigger: "Trigger", trigger_ph: "(none)", url: "URL", order: "#",
select: "Select LoRA...", add: "+ Add LoRA", note_ph: "Note...",
prompt_url: "URL (paste download page link)",
root_dir: "— Root —" },
zh: { on_off: "开关", lora: "LoRA", strength: "权重", note: "备注", del: "删除",
trigger: "触发词", trigger_ph: "(无)", url: "网址", order: "序",
select: "选择 LoRA...", add: "+ 添加 LoRA", note_ph: "备注...",
prompt_url: "网址(粘贴下载页链接)",
root_dir: "— 根目录 —" },
};
const T = I18N[LANG] ?? I18N.en;
// 最小宽度(拖拽下限,经典 / Nodes 2.0 两种渲染模式都生效)
const NODE_MIN_W = 500;
// 行内固定列宽(名字列自适应剩余空间)
const COL = { order: 22, toggle: 32, strength: 66, trigger: 68, note: 78, url: 22, del: 22 };
const ROW_GAP = 4;
// 顶部槽位区预留(经典模式会随"贴合校正"动态学习增大,保证此后一步到位)
const VUE_RESERVE = 60;
let CLASSIC_RESERVE = 84;
// 内容区自然高度下限(表头 16 + 添加按钮 ~32 + padding 8 + 行 30 + gap 5)。
// 实测 1 行 ≈ 97,18 行 ≈ 692。用来拒绝“布局未完成”的偏小测量,
// 避免把节点窗口打回默认高度、内容整块冒出框外。
function expectedContentH(rowCount) {
const n = Math.max(1, rowCount | 0);
return 61 + n * 35;
}
// ---------- LoRA preview (ComfyUI /experiment/models/preview) ----------
let pathIndexMap = null; // normalizedPath -> pathIndex (null = 接口不可用,功能降级)
const noPreviewSet = new Set(); // 已确认无图路径,避免反复 404 请求
async function loadPathIndexMap() {
try {
const resp = await fetch("/experiment/models/loras");
if (!resp.ok) return;
const list = await resp.json();
const map = new Map();
if (Array.isArray(list)) {
for (const it of list) {
if (it && typeof it.name === "string") {
map.set(it.name.replace(/\\/g, "/"), it.pathIndex ?? 0);
}
}
}
pathIndexMap = map.size > 0 ? map : null;
} catch (e) { pathIndexMap = null; }
}
// 构建预览图 URL;接口不可用或路径未知时返回 null(调用方静默跳过)
function previewUrlFor(loraPath) {
if (!pathIndexMap) return null;
const norm = (loraPath || "").replace(/\\/g, "/");
const idx = pathIndexMap.get(norm);
if (idx === undefined) return null;
return `/experiment/models/preview/loras/${idx}/` +
norm.split("/").map(encodeURIComponent).join("/");
}
// hover 浮层:固定显示在下拉框右侧,不遮挡下拉列表
class HoverPreview {
constructor() {
this.el = document.createElement("div");
Object.assign(this.el.style, {
position: "fixed", zIndex: "100000", display: "none",
pointerEvents: "none", background: "#151515",
border: "1px solid #4a8a4a", borderRadius: "8px",
boxShadow: "0 8px 24px rgba(0,0,0,0.8)", padding: "4px"
});
this.img = document.createElement("img");
Object.assign(this.img.style, {
display: "block", maxWidth: "260px", maxHeight: "340px",
objectFit: "contain", borderRadius: "4px"
});
this.el.appendChild(this.img);
document.body.appendChild(this.el);
}
show(url, anchorRect) {
this.img.src = url;
this.el.style.display = "block";
const pad = 10;
let left = anchorRect.right + pad;
if (left + 280 > window.innerWidth) {
left = Math.max(4, anchorRect.left - 280 - pad); // 右缘不足 → 放左侧
}
let top = anchorRect.top;
const h = this.img.getBoundingClientRect().height || 300;
if (top + h > window.innerHeight) top = Math.max(4, window.innerHeight - h - 4);
this.el.style.left = left + "px";
this.el.style.top = top + "px";
}
hide() {
this.el.style.display = "none";
this.img.removeAttribute("src"); // 避免空src触发页面URL请求
}
}
const hoverPreview = new HoverPreview();
// ---------- 下拉框(body 上的 DOM 浮层;锚点直接使用元素 rect,两种渲染模式通用) ----------
class DomDropdown {
constructor() {
this.el = document.createElement("div");
Object.assign(this.el.style, {
position: "fixed", zIndex: "99999", background: "#1c1c1c",
border: "1px solid #4a8a4a", borderRadius: "6px", overflowY: "auto",
boxShadow: "0 6px 20px rgba(0,0,0,0.8)", display: "none",
});
document.body.appendChild(this.el);
this._onOutside = (e) => { if (!this.el.contains(e.target)) this.hide(); };
}
show(items, selectedItem, anchorRect, onSelect) {
this.el.innerHTML = "";
// 按文件夹分组(路径如 "画风/style.safetensors" 或 "角色\\xxx.safetensors")
const rootFiles = [];
const folderMap = new Map();
const originalMap = new Map();
for (const item of items) {
if (item === "None") continue;
const normalized = item.replace(/\\/g, "/");
originalMap.set(normalized, item);
const idx = normalized.lastIndexOf("/");
if (idx !== -1) {
const dir = normalized.substring(0, idx);
const file = normalized.substring(idx + 1);
if (!folderMap.has(dir)) folderMap.set(dir, []);
folderMap.get(dir).push(file);
} else {
rootFiles.push(normalized);
}
}
rootFiles.sort();
const sortedDirs = [...folderMap.keys()].sort();
for (const dir of sortedDirs) folderMap.get(dir).sort();
const selectedNormalized = (selectedItem && selectedItem !== "None")
? selectedItem.replace(/\\/g, "/") : selectedItem;
const expandDirs = new Set();
if (selectedNormalized) {
const si = selectedNormalized.lastIndexOf("/");
if (si !== -1) expandDirs.add(selectedNormalized.substring(0, si));
}
// ----- "None"(始终第一个) -----
const noneDiv = document.createElement("div");
noneDiv.textContent = "— None —";
Object.assign(noneDiv.style, {
padding: "5px 12px", cursor: "pointer", fontSize: "12px",
color: selectedNormalized === "None" ? "#7eb8f7" : "#ccc",
background: selectedNormalized === "None" ? "#1a3a5a" : "transparent",
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis"
});
noneDiv.addEventListener("mouseover", () => noneDiv.style.background = "#2a4a2a");
noneDiv.addEventListener("mouseout", () => noneDiv.style.background = selectedNormalized === "None" ? "#1a3a5a" : "transparent");
noneDiv.addEventListener("mousedown", (e) => { e.stopPropagation(); onSelect("None"); this.hide(); });
this.el.appendChild(noneDiv);
// ----- 单个 LoRA 行 -----
const makeFileRow = (displayName, fullPathNormalized) => {
const originalPath = originalMap.get(fullPathNormalized) || fullPathNormalized;
const div = document.createElement("div");
Object.assign(div.style, {
padding: "5px 12px 5px 28px", cursor: "pointer", fontSize: "12px",
color: selectedNormalized === fullPathNormalized ? "#7eb8f7" : "#bbb",
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
display: "flex", alignItems: "center", gap: "6px"
});
div.addEventListener("mouseover", () => div.style.background = "#2a4a2a");
div.addEventListener("mouseout", () => div.style.background = selectedNormalized === fullPathNormalized ? "#1a3a5a" : "transparent");
div.addEventListener("mousedown", (e) => { e.stopPropagation(); onSelect(originalPath); this.hide(); });
// 缩略图:有同名/同名.preview 图片时,名字后面显示小缩略图 + hover 浮层大图
const pUrl = previewUrlFor(originalPath);
const nameSpan = document.createElement("span");
nameSpan.textContent = displayName;
Object.assign(nameSpan.style, {
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
minWidth: "0", flex: "1 1 auto"
});
div.appendChild(nameSpan);
if (pUrl && !noPreviewSet.has(originalPath)) {
const thumb = document.createElement("img");
thumb.src = pUrl;
Object.assign(thumb.style, {
width: "14px", height: "14px", objectFit: "cover",
borderRadius: "3px", flexShrink: "0", display: "none"
});
thumb.addEventListener("load", () => { thumb.style.display = "block"; });
thumb.addEventListener("error", () => { thumb.remove(); noPreviewSet.add(originalPath); });
div.appendChild(thumb);
div.addEventListener("mouseenter", () => {
hoverPreview.show(pUrl, this.el.getBoundingClientRect());
});
div.addEventListener("mouseleave", () => hoverPreview.hide());
}
return div;
};
// ----- 文件夹 -----
for (const dir of sortedDirs) {
const files = folderMap.get(dir);
const expanded = expandDirs.has(dir);
const header = document.createElement("div");
const arrowSpan = document.createElement("span");
arrowSpan.textContent = expanded ? "\u25BE " : "\u25B8 ";
arrowSpan.style.cssText = "display:inline-block;width:14px;text-align:center;";
header.appendChild(arrowSpan);
header.appendChild(document.createTextNode(dir + "/"));
Object.assign(header.style, {
padding: "5px 12px", cursor: "pointer", fontSize: "12px",
color: "#aaa", fontWeight: "bold",
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis"
});
header.addEventListener("mouseover", () => header.style.background = "#2a2a2a");
header.addEventListener("mouseout", () => header.style.background = "transparent");
const children = document.createElement("div");
children.style.display = expanded ? "block" : "none";
for (const file of files) {
const displayName = file.replace(/\.(safetensors|pt|ckpt)$/i, "");
children.appendChild(makeFileRow(displayName, dir + "/" + file));
}
header.addEventListener("click", (e) => {
e.stopPropagation();
const isHidden = children.style.display === "none";
children.style.display = isHidden ? "block" : "none";
arrowSpan.textContent = isHidden ? "\u25BE " : "\u25B8 ";
});
this.el.appendChild(header);
this.el.appendChild(children);
}
// ----- 根目录文件 -----
if (rootFiles.length > 0 && sortedDirs.length > 0) {
const sep = document.createElement("div");
Object.assign(sep.style, {
height: "1px", margin: "4px 12px",
background: "#444", opacity: "0.6"
});
this.el.appendChild(sep);
const rootLabel = document.createElement("div");
rootLabel.textContent = T.root_dir;
Object.assign(rootLabel.style, {
padding: "3px 12px", fontSize: "10px",
color: "#666", fontStyle: "italic"
});
this.el.appendChild(rootLabel);
}
for (const file of rootFiles) {
const displayName = file.replace(/\.(safetensors|pt|ckpt)$/i, "").split(/[\\/]/).pop();
this.el.appendChild(makeFileRow(displayName, file));
}
// ----- 定位与显示(anchorRect 为锚点元素的 getBoundingClientRect()) -----
this.el.style.display = "block";
const w = Math.max(anchorRect.width, 260);
this.el.style.width = w + "px";
this.el.style.left = Math.max(4, Math.min(anchorRect.left, window.innerWidth - w - 8)) + "px";
const spaceBelow = window.innerHeight - anchorRect.bottom;
const spaceAbove = anchorRect.top;
this.el.style.maxHeight = Math.min(Math.max(spaceBelow, spaceAbove) - 20, 480) + "px";
if (spaceBelow >= 200 || spaceBelow > spaceAbove) {
this.el.style.top = anchorRect.bottom + "px"; this.el.style.bottom = "auto";
} else {
this.el.style.bottom = (window.innerHeight - anchorRect.top) + "px"; this.el.style.top = "auto";
}
setTimeout(() => {
document.addEventListener("mousedown", this._onOutside, true);
document.addEventListener("pointerdown", this._onOutside, true);
}, 0);
}
hide() {
if (this.el.style.display === "none") return;
this.el.style.display = "none";
hoverPreview.hide();
document.removeEventListener("mousedown", this._onOutside, true);
document.removeEventListener("pointerdown", this._onOutside, true);
}
}
const domDropdown = new DomDropdown();
// ---------- 行数据工具 ----------
function parseRows(val) {
try {
const rows = JSON.parse(val || "[]");
if (Array.isArray(rows) && rows.length) return rows;
} catch (e) { }
return null;
}
function defaultRows() {
return [{ enabled: true, lora: "None", strength: 1.0, trigger: "", note: "", url: "" }];
}
function loraBasename(path) {
return (path || "").replace(/\.(safetensors|pt|ckpt)$/i, "").split(/[\\/]/).pop();
}
const hasUrlRe = /^https?:\/\//i;
// ---------- 节点(DOM 渲染,和 Prompt Segments / Resolution Switcher 相同架构) ----------
app.registerExtension({
name: "MultiLoraLoader",
async nodeCreated(node) {
if (node.comfyClass !== "MultiLoraLoader") return;
// lora_stack 隐藏:两种渲染模式都认 hidden(值照常保存/恢复)
const stackWidget = node.widgets?.find(w => w.name === "lora_stack");
if (stackWidget) {
stackWidget.hidden = true;
if (!stackWidget.options) stackWidget.options = {};
stackWidget.options.hidden = true;
}
// 工作流恢复(含 2.0→1.0 重载)会把 size 写回存档里的默认高度,
// 且发生时机可能在我们量完高度之后。钩子必须在 await 之前装上,否则错过 configure。
let pendingRefit = false;
const refitFromData = () => {
if (typeof node._mlRender !== "function") {
pendingRefit = true;
return;
}
const parsed = parseRows(stackWidget?.value);
if (parsed && JSON.stringify(parsed) !== JSON.stringify(node._mlRows)) {
node._mlRows = parsed;
node._mlRender();
return;
}
// 已测到内容时同步拉回。configure 是直接写 size,不等这一下,下一帧就会画出“窗口默认、内容冒出”。
if (node._mlContentH > 0) {
const reserve = window.LiteGraph?.vueNodesMode ? VUE_RESERVE : CLASSIC_RESERVE;
const h = node._mlContentH + reserve;
if (node.size[1] < h - 2) node.setSize([node.size[0], h]);
}
try { node._mlApplySize?.(); } catch (e) { }
};
const origOnConfigure = node.onConfigure;
node.onConfigure = function () {
const r = origOnConfigure?.apply(this, arguments);
refitFromData();
return r;
};
const origAfter = node.onAfterGraphConfigured;
node.onAfterGraphConfigured = function () {
const r = origAfter?.apply(this, arguments);
const run = () => { try { refitFromData(); } catch (e) { } };
requestAnimationFrame(run);
setTimeout(run, 0);
return r;
};
// 宽度矫正 + 拖拽下限(两种模式通用)
if (node.size[0] < NODE_MIN_W) node.size = [NODE_MIN_W, node.size[1]];
if (!node._minWidthPatched) {
node._minWidthPatched = true;
const origOnResize = node.onResize;
node.onResize = function (size) {
if (size[0] < NODE_MIN_W) size[0] = NODE_MIN_W;
return origOnResize?.call(this, size);
};
}
// 绘制时兜底:无论谁把 size 写回默认高度(configure 直接赋值、模式切换后的迟到恢复),
// 下一帧画节点时拉回。只在确实偏矮时动手,不是定时轮询。
if (!node._mlDrawFitPatched) {
node._mlDrawFitPatched = true;
const origDraw = node.onDrawForeground;
node.onDrawForeground = function (ctx) {
const ret = origDraw?.apply(this, arguments);
try {
const rows = this._mlRows?.length || 0;
const floor = rows ? expectedContentH(rows) + 40 : 0;
if (floor && this.size[1] < floor) {
if (this._mlContentH > 0) {
const reserve = window.LiteGraph?.vueNodesMode ? VUE_RESERVE : CLASSIC_RESERVE;
const h = this._mlContentH + reserve;
if (this.size[1] < h - 2) this.setSize([this.size[0], h]);
} else if (!this._mlDrawFitQueued) {
this._mlDrawFitQueued = true;
const run = () => {
if (!this._mlDrawFitQueued) return;
this._mlDrawFitQueued = false;
try { this._mlApplySize?.(); } catch (e) { }
};
requestAnimationFrame(run);
setTimeout(run, 30);
}
}
} catch (e) { }
return ret;
};
}
// 可见性变化时重测高度:初次渲染的测量可能受画布布局/拉伸影响而失真,
// 等节点真正上屏后再测一次(经典模式必须有;Vue 模式同样受益)。
node._wasVisible = false;
if ("IntersectionObserver" in window) {
node._visObserver = new IntersectionObserver((entries) => {
for (const e of entries) {
if (e.isIntersecting) {
// 从未测到、或窗口仍明显矮于内容(被打回默认高度)时补测。
// 已正常贴合则仅首次可见时复测一次。
const nRows = node._mlRows?.length || 0;
const need = nRows ? expectedContentH(nRows) + 40 : 0;
const tooSmall = !node._mlContentH || (need > 0 && node.size[1] < need);
if (tooSmall || !node._wasVisible) {
node._mlMeasureTries = 0;
try { node._mlApplySize(); } catch (e2) { }
}
}
node._wasVisible = e.isIntersecting;
}
}, { threshold: 0 });
const attachObserver = () => {
if (node._visObserver && node._mlDomWidget?.element) {
node._visObserver.observe(node._mlDomWidget.element);
}
};
requestAnimationFrame(attachObserver);
setTimeout(attachObserver, 0);
}
const origOnRemoved = node.onRemoved;
node.onRemoved = function () {
origOnRemoved?.call(this);
if (node._visObserver) { node._visObserver.disconnect(); node._visObserver = null; }
};
// LoRA 列表
let loraList = ["None"];
try {
const resp = await fetch("/object_info/LoraLoader");
const data = await resp.json();
const vals = data?.LoraLoader?.input?.required?.lora_name?.[0];
if (Array.isArray(vals)) loraList = ["None", ...vals];
} catch (e) { }
// 预览图目录映射(接口不可用时静默降级)
loadPathIndexMap();
// 数据(向后兼容:沿用 lora_stack 的 widgets_values 作为持久化载体)
node._mlRows = parseRows(stackWidget?.value) ?? defaultRows();
node._mlContentH = 0;
// DOM 容器(flex:0 0 auto —— 防止 Nodes 2.0 下被 WidgetDOM 容器拉伸)
const container = document.createElement("div");
container.style.cssText =
`display:flex;flex-direction:column;gap:5px;padding:4px;box-sizing:border-box;width:100%;flex:0 0 auto;`;
node._mlContainer = container;
node._mlDomWidget = node.addDOMWidget("_ui", "div", container, {
serialize: false,
// 2.0 的最小高度读这个;没设时平台默认 50,节点就能缩回默认高度、内容冒出去
getMinHeight: () => node._mlContentH || expectedContentH(node._mlRows?.length || 1),
});
// ---- 数据同步(写回 lora_stack,py 端与工作流持久化使用) ----
node._mlSync = function () {
if (stackWidget) stackWidget.value = JSON.stringify(node._mlRows);
};
// 平台若用 computeSize()/setSize() 把窗口打回默认高度,这里兜住(内容已测到之后才锁)
if (!node._mlComputeSizePatched) {
node._mlComputeSizePatched = true;
const origCompute = node.computeSize;
node.computeSize = function (out) {
const sz = origCompute ? origCompute.call(this, out) : (out || [this.size[0], this.size[1]]);
const content = this._mlContentH || 0;
if (content > 0) {
const reserve = window.LiteGraph?.vueNodesMode ? VUE_RESERVE : CLASSIC_RESERVE;
const need = content + reserve;
if (sz[1] < need) sz[1] = need;
}
if (sz[0] < NODE_MIN_W) sz[0] = NODE_MIN_W;
return sz;
};
const origSetSize = node.setSize;
node.setSize = function (size) {
if (this._mlSetSizeGuard) return origSetSize?.call(this, size);
if (size && this._mlSizeLock && this._mlContentH > 0) {
const reserve = window.LiteGraph?.vueNodesMode ? VUE_RESERVE : CLASSIC_RESERVE;
const need = this._mlContentH + reserve;
if (size[1] < need - 2) size = [Math.max(size[0], NODE_MIN_W), need];
}
this._mlSetSizeGuard = true;
try {
return origSetSize ? origSetSize.call(this, size) : undefined;
} finally {
this._mlSetSizeGuard = false;
}
};
}
// ---- 高度自适应 ----
node._mlApplySize = function () {
const dw = node._mlDomWidget;
if (!dw) return;
dw.element.style.height = "auto";
dw.computeSize = () => [0, node._mlContentH || -4];
// 双通道调度:正常走 rAF;模式切换/后台加载等时序下 rAF 会被冻结,
// setTimeout 兜底保证校正不被拖延(二者只执行其一)。
let fired = false;
const measure = () => {
if (fired) return;
fired = true;
// 用"子元素累加"测量自然高度(不依赖容器是否被拉伸,避免读到虚高值)
let domH = 0;
try {
const cs = getComputedStyle(container);
domH = (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0);
const gap = parseFloat(cs.rowGap) || 0;
const kids = [...container.children];
kids.forEach((k, i) => { domH += k.offsetHeight + (i > 0 ? gap : 0); });
} catch (e) {
domH = container.scrollHeight;
}
const expectH = expectedContentH(node._mlRows?.length || 1);
// 布局未完成时子元素高度会偏小(甚至只剩表头)。这时若写回 size,
// 窗口会被打回默认高度,随后真正渲染的内容全部冒出框外。偏小就当没测到,继续重试。
if (!domH || domH <= 0 || domH < expectH * 0.85) {
// 元素可能尚未上屏(如工作流加载中 / 新 JS 冷启动):持续重试。
// 冷启动时布局就绪可能远超 1 秒,按文档加载状态给足重试窗口:
// 文档未完成前最多 ~40 秒;完成后 ~10 秒兜底。
node._mlMeasureTries = (node._mlMeasureTries || 0) + 1;
const limit = document.readyState === "complete" ? 40 : 160;
if (node._mlMeasureTries <= limit) {
setTimeout(() => { try { node._mlApplySize(); } catch (e) { } }, 250);
}
return;
}
node._mlMeasureTries = 0;
node._mlContentH = domH;
node._mlSizeLock = true;
try {
container.style.setProperty("--comfy-widget-min-height", Math.ceil(domH) + "px");
} catch (e) { }
// 顶部槽位区预留:2.0 约 60;经典模式基准 84(实测 76 + 8px 紧凑余量)。
// 一次性给足,避免"先小后补"的两段式高度跳变(视觉闪烁);
// 若个别环境槽位更高导致溢出,由下方校正逻辑兜底,并把补量"学习"进
// CLASSIC_RESERVE —— 之后所有节点直接一步到位,不会再反复补。
const reserve = window.LiteGraph?.vueNodesMode ? VUE_RESERVE : CLASSIC_RESERVE;
const h = domH + reserve;
if (Math.abs(h - node.size[1]) > 1) {
node.setSize([node.size[0], h]);
app.graph.setDirtyCanvas(true, true);
}
// 经典模式:布局生效后再量一次 —— 若 DOM 底部超出节点框底部,则补增高度。
// (不同 litegraph 版本的"槽位区"高度不同,静态预留无法通吃,实测校正最稳)
if (!window.LiteGraph?.vueNodesMode) {
requestAnimationFrame(() => {
try {
const gb = node.getBounding ? node.getBounding() : null;
if (!gb) return;
const cr = app.canvas.canvas.getBoundingClientRect();
const scale = app.canvas.ds?.scale || 1;
const offY = app.canvas.ds?.offset?.[1] ?? 0;
// 屏幕 = canvas 原点 + (世界坐标 + 视图偏移) × 缩放
const nodeBottomScreen = cr.top + (gb[1] + gb[3] + offY) * scale;
const domBottom = container.getBoundingClientRect().bottom;
const overflowScreen = domBottom - nodeBottomScreen;
// 只处理"轻微溢出":正常预留下溢出最多 ~10px;
// 超过 40px 视为"测量基准异常"(如加载态的假溢出)直接忽略,
// 更不允许把异常值学习进预留(否则造成"巨长/大空白")。
const overflowLayout = overflowScreen / scale;
if (overflowLayout > 2 && overflowLayout < 40) {
const add = Math.min(overflowLayout + 6, 200);
CLASSIC_RESERVE = Math.min(CLASSIC_RESERVE + add, 200); // 学习(封顶 200)
node.setSize([node.size[0], node.size[1] + add]);
app.graph.setDirtyCanvas(true, true);
}
} catch (e) { }
});
}
};
requestAnimationFrame(measure);
setTimeout(measure, 30);
};
// ---- 单个 LoRA 行 ----
const makeRow = (row, i) => {
const el = document.createElement("div");
el.style.cssText =
`display:flex;align-items:center;gap:${ROW_GAP}px;height:30px;padding:0 4px;` +
`border-radius:6px;box-sizing:border-box;flex-shrink:0;` +
`background:${row.enabled ? "#1a2a1a" : "#271818"};` +
`border:1px solid ${row.enabled ? "#2d5a2d" : "#5a2d2d"};`;
// 序号(输入数字排序)
const idx = document.createElement("input");
idx.type = "text";
idx.value = i + 1;
idx.title = T.order;
idx.style.cssText =
`width:${COL.order}px;height:22px;background:#1e1e28;color:#8a8a9a;flex-shrink:0;` +
`border:1px solid #35354a;border-radius:4px;text-align:center;font-size:11px;` +
`font-weight:bold;font-family:monospace;outline:none;box-sizing:border-box;padding:0;`;
idx.onfocus = () => { idx.style.borderColor = "#4a9eff"; idx.style.color = "#fff"; idx.select(); };
idx.onblur = () => { idx.style.borderColor = "#35354a"; idx.style.color = "#8a8a9a"; };
idx.onchange = () => {
const parsed = parseFloat(idx.value.trim());
const total = node._mlRows.length;
const current = i + 1;
if (!Number.isFinite(parsed)) { idx.value = current; return; }
let target = Math.round(parsed);
if (target <= 0) target = 1;
if (target > total) target = total;
if (target === current) { idx.value = current; return; }
const item = node._mlRows.splice(i, 1)[0];
node._mlRows.splice(target - 1, 0, item);
node._mlSync();
node._mlRender();
};
el.appendChild(idx);
// 开关胶囊
const tog = document.createElement("div");
tog.style.cssText = `width:${COL.toggle}px;flex-shrink:0;display:flex;justify-content:center;cursor:pointer;`;
const cap = document.createElement("div");
const paintCap = () => {
cap.style.cssText =
`width:28px;height:16px;border-radius:8px;position:relative;transition:background .15s,border-color .15s;` +
`background:${row.enabled ? "#2e7d32" : "#424242"};border:1px solid ${row.enabled ? "#43a047" : "#555"};`;
cap.innerHTML =
`<div style="position:absolute;top:1px;left:${row.enabled ? "13px" : "1px"};` +
`width:12px;height:12px;border-radius:50%;background:#fff;transition:left .15s;"></div>`;
};
paintCap();
tog.onclick = () => {
row.enabled = !row.enabled;
el.style.background = row.enabled ? "#1a2a1a" : "#271818";
el.style.borderColor = row.enabled ? "#2d5a2d" : "#5a2d2d";
paintCap();
node._mlSync();
};
tog.appendChild(cap);
el.appendChild(tog);
// LoRA 名(点击打开下拉;与触发词/备注按 2:1:1 弹性分配,拉宽时三者一起变宽)
const name = document.createElement("div");
name.style.cssText =
`flex:2 1 0;min-width:0;height:22px;line-height:20px;background:#252525;` +
`border:1px solid #3a3a3a;border-radius:4px;padding:0 6px;font-size:11.5px;` +
`white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer;` +
`box-sizing:border-box;color:${row.lora === "None" ? "#555" : "#ddd"};`;
name.textContent = row.lora === "None" ? T.select : loraBasename(row.lora);
name.onmouseenter = () => { name.style.borderColor = "#5a5a5a"; };
name.onmouseleave = () => { name.style.borderColor = "#3a3a3a"; };
name.onclick = () => {
const rect = name.getBoundingClientRect();
domDropdown.show(loraList, row.lora, rect, (sel) => {
row.lora = sel;
if (sel !== "None") {
try {
const t = localStorage.getItem("lora_trigger_" + sel);
row.trigger = t !== null ? t : "";
const u = localStorage.getItem("lora_url_" + sel);
row.url = u !== null ? u : "";
} catch (e) { }
} else {
row.trigger = "";
row.url = "";
}
name.textContent = sel === "None" ? T.select : loraBasename(sel);
name.style.color = sel === "None" ? "#555" : "#ddd";
trg.value = row.trigger;
paintUrl();
node._mlSync();
});
};
el.appendChild(name);
// 权重:[◀ 数值 ▶],数值可拖拽微调、直接输入
const stWrap = document.createElement("div");
stWrap.style.cssText =
`width:${COL.strength}px;height:22px;background:#252525;border:1px solid #3a3a3a;` +
`border-radius:4px;display:flex;align-items:center;box-sizing:border-box;flex-shrink:0;overflow:hidden;`;
const mkArrow = (txt, delta) => {
const b = document.createElement("div");
b.textContent = txt;
b.style.cssText =
`width:14px;flex-shrink:0;text-align:center;font-size:10px;color:#666;cursor:pointer;` +
`user-select:none;line-height:20px;`;
b.onmouseenter = () => { b.style.color = "#aaa"; };
b.onmouseleave = () => { b.style.color = "#666"; };
b.onclick = () => {
const v = Math.round(Math.min(100, Math.max(-100, row.strength + delta)) * 100) / 100;
row.strength = v;
num.value = v.toFixed(2);
num.style.color = v === 1.0 ? "#aaa" : "#7ec8e3";
node._mlSync();
};
return b;
};
const num = document.createElement("input");
num.type = "text";
num.value = row.strength.toFixed(2);
num.style.cssText =
`flex:1 1 auto;min-width:0;width:100%;background:transparent;border:none;outline:none;` +
`text-align:center;font-size:12px;font-family:'Courier New',monospace;padding:0;` +
`color:${row.strength === 1.0 ? "#aaa" : "#7ec8e3"};`;
num.onfocus = () => num.select();
num.onchange = () => {
let v = parseFloat(num.value);
if (!Number.isFinite(v)) v = row.strength;
v = Math.round(Math.min(100, Math.max(-100, v)) * 100) / 100;
row.strength = v;
num.value = v.toFixed(2);
num.style.color = v === 1.0 ? "#aaa" : "#7ec8e3";
node._mlSync();
};
num.onblur = () => { num.value = row.strength.toFixed(2); };
// 按住数值左右拖拽调整(1px ≈ 0.001);单击则聚焦编辑。
// 关键:pointerdown 阻止默认行为 —— 否则输入的"文本选择/HTML5 拖拽"会抢走
// 手势(拖动时拖出 ghost 文本而非改数值)。单击(未拖动)在 pointerup 时
// 手动聚焦并全选,保持"点击直接输入"的便利。
num.addEventListener("dragstart", (e) => e.preventDefault());
num.addEventListener("pointerdown", (e) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
const startX = e.clientX;
const startVal = row.strength;
let dragging = false;
const onMove = (ev) => {
const dx = ev.clientX - startX;
if (!dragging) {
if (Math.abs(dx) < 5) return; // 阈值:点击时的手抖不算拖拽
dragging = true;
try { num.setPointerCapture(ev.pointerId); } catch (er) { }
document.body.style.cursor = "ew-resize";
}
const v = Math.round(Math.min(100, Math.max(-100, startVal + dx * 0.001)) * 100) / 100;
if (v !== row.strength) {
row.strength = v;
num.value = v.toFixed(2);
num.style.color = v === 1.0 ? "#aaa" : "#7ec8e3";
node._mlSync();
}
};
const onUp = (ev) => {
num.removeEventListener("pointermove", onMove);
num.removeEventListener("pointerup", onUp);
num.removeEventListener("pointercancel", onUp);
document.body.style.cursor = "";
if (dragging) {
try { num.releasePointerCapture(ev.pointerId); } catch (er) { }
} else {
// 单击(未拖动):聚焦并全选,直接输入覆盖
num.focus();
num.select();
}
};
num.addEventListener("pointermove", onMove);
num.addEventListener("pointerup", onUp);
num.addEventListener("pointercancel", onUp);
});
stWrap.appendChild(mkArrow("◀", -0.01));
stWrap.appendChild(num);
stWrap.appendChild(mkArrow("▶", 0.01));
el.appendChild(stWrap);
// 触发词(弹性:与名字/备注按 3:1:1 分配剩余空间)
const trg = document.createElement("input");
trg.type = "text";
trg.value = row.trigger || "";
trg.placeholder = T.trigger_ph;
trg.style.cssText =
`flex:1 1 0;min-width:0;height:22px;background:#1e1e28;border:1px solid #35354a;` +
`border-radius:4px;padding:0 4px;font-size:10px;color:#e8c87e;outline:none;` +
`box-sizing:border-box;text-overflow:ellipsis;`;
trg.onfocus = () => { trg.style.borderColor = "#4a9eff"; };
trg.onblur = () => { trg.style.borderColor = "#35354a"; };
trg.oninput = () => {
row.trigger = trg.value;
if (row.lora !== "None") {
try { localStorage.setItem("lora_trigger_" + row.lora, trg.value); } catch (e) { }
}
node._mlSync();
};
el.appendChild(trg);
// 备注(弹性:与名字/触发词按 3:1:1 分配剩余空间)
const note = document.createElement("input");
note.type = "text";
note.value = row.note || "";
note.placeholder = T.note_ph;
note.style.cssText =
`flex:1 1 0;min-width:0;height:22px;background:#1e1e28;border:1px solid #35354a;` +
`border-radius:4px;padding:0 4px;font-size:11px;color:#bbb;outline:none;` +
`box-sizing:border-box;text-overflow:ellipsis;`;
note.onfocus = () => { note.style.borderColor = "#4a9eff"; };
note.onblur = () => { note.style.borderColor = "#35354a"; };
note.oninput = () => { row.note = note.value; node._mlSync(); };
el.appendChild(note);
// 网址按钮:未配置=灰 +,已配置=绿 ↗(点击跳转;Shift+点击 / 无网址时编辑)
const urlBtn = document.createElement("button");
urlBtn.style.cssText =
`width:${COL.url}px;height:22px;border-radius:5px;cursor:pointer;font-size:11px;` +
`flex-shrink:0;line-height:1;font-weight:bold;padding:0;transition:filter .15s;`;
const paintUrl = () => {
const on = hasUrlRe.test((row.url || "").trim());
urlBtn.textContent = on ? "↗" : "+";
urlBtn.style.background = on ? "#0f3a1a" : "#2a2a2a";
urlBtn.style.border = `1px solid ${on ? "#2e9e4f" : "#4a4a4a"}`;
urlBtn.style.color = on ? "#5ccf7a" : "#8a8a8a";
};
paintUrl();
urlBtn.onmouseenter = () => { urlBtn.style.filter = "brightness(1.3)"; };
urlBtn.onmouseleave = () => { urlBtn.style.filter = "none"; };
urlBtn.onclick = (e) => {
const url = row.url || "";
const setUrl = (val) => {
row.url = (val || "").trim();
if (row.lora !== "None") {
try { localStorage.setItem("lora_url_" + row.lora, row.url); } catch (er) { }
}
paintUrl();
node._mlSync();
};
if (hasUrlRe.test(url.trim())) {
if (e.ctrlKey || e.shiftKey) {
app.canvas.prompt(T.prompt_url, url, (val) => { setUrl(val); }, e);
} else {
window.open(url.trim(), "_blank");
}
} else {
app.canvas.prompt(T.prompt_url, url, (val) => { setUrl(val); }, e);
}
};
el.appendChild(urlBtn);
// 删除
const del = document.createElement("button");
del.textContent = "✕";
del.style.cssText =
`width:${COL.del}px;height:22px;background:#6a0000;color:#ff6b6b;flex-shrink:0;` +
`border:1px solid #c0392b;border-radius:5px;cursor:pointer;font-size:11px;` +
`line-height:1;font-weight:bold;padding:0;transition:filter .15s;`;
del.onmouseenter = () => { del.style.filter = "brightness(1.3)"; };
del.onmouseleave = () => { del.style.filter = "none"; };
del.onclick = () => {
const liveIdx = node._mlRows.indexOf(row);
if (liveIdx >= 0) node._mlRows.splice(liveIdx, 1);
node._mlSync();
node._mlRender();
};
el.appendChild(del);
return el;
};
// ---- 全量渲染 ----
node._mlRender = function () {
container.innerHTML = "";
// 表头
const header = document.createElement("div");
header.style.cssText =
`display:flex;align-items:center;gap:${ROW_GAP}px;padding:0 4px;height:16px;` +
`font-size:10px;color:#606060;font-weight:bold;box-sizing:border-box;flex-shrink:0;`;
const headCells = [
{ t: T.order, w: COL.order, align: "center" },
{ t: T.on_off, w: COL.toggle, align: "center" },
{ t: T.lora, flex: "2 1 0", align: "left", pad: true },
{ t: T.strength, w: COL.strength, align: "center" },
{ t: T.trigger, flex: "1 1 0", align: "center" },
{ t: T.note, flex: "1 1 0", align: "center" },
{ t: T.url, w: COL.url, align: "center" },
{ t: T.del, w: COL.del, align: "center" },
];
for (const hc of headCells) {
const c = document.createElement("div");
c.textContent = hc.t;
c.style.cssText =
`text-align:${hc.align};box-sizing:border-box;overflow:hidden;white-space:nowrap;` +
(hc.flex
? `flex:${hc.flex};min-width:0;` + (hc.pad ? "padding-left:6px;" : "")
: `width:${hc.w}px;flex-shrink:0;`);
header.appendChild(c);
}
container.appendChild(header);
// 行
node._mlRows.forEach((row, i) => {
container.appendChild(makeRow(row, i));
});
// 添加按钮
const addBtn = document.createElement("button");
addBtn.textContent = T.add;
addBtn.style.cssText =
`width:100%;padding:6px;background:#1e1e1e;color:#7c6cf0;border:1px solid #404040;` +
`border-radius:6px;cursor:pointer;font-size:13px;font-weight:bold;flex-shrink:0;transition:filter .15s;`;
addBtn.onmouseenter = () => { addBtn.style.filter = "brightness(1.3)"; };
addBtn.onmouseleave = () => { addBtn.style.filter = "none"; };
addBtn.onclick = () => {
node._mlRows.push({ enabled: true, lora: "None", strength: 1.0, trigger: "", note: "", url: "" });
node._mlSync();
node._mlRender();
};
container.appendChild(addBtn);
node._mlApplySize();
// 布局稳定后复测一次(纠正初次测量可能受画布布局影响的偏差)
setTimeout(() => { try { node._mlApplySize(); } catch (e) { } }, 350);
// 更晚的兜底复测:个别加载时序(如模式切换后首载)下,早期测量可能偏小,
// 页面完全就绪后再校正一次(一次性,非轮询)。
setTimeout(() => { try { node._mlApplySize(); } catch (e) { } }, 1600);
};
// configure 发生在渲染函数定义之前时,只记了一笔;这里把行数据补上再画
if (pendingRefit) {
pendingRefit = false;
const parsed = parseRows(stackWidget?.value);
if (parsed) node._mlRows = parsed;
}
// 初次渲染 + 复查(防止 configure 恢复数据晚于渲染)。
// 双通道调度:窗口无焦点/被遮挡时 rAF 可能被节流甚至停帧,
// setTimeout 兜底保证首渲染与校正不被拖延(二者只执行其一)。
let _mlInitRendered = false;
const firstRender = () => {
if (_mlInitRendered) return;
_mlInitRendered = true;
node._mlRender();
const verify = () => {
const parsed = parseRows(stackWidget?.value);
if (parsed && JSON.stringify(parsed) !== JSON.stringify(node._mlRows)) {
node._mlRows = parsed;
node._mlRender();
}
};
requestAnimationFrame(verify);
setTimeout(verify, 80);
};
requestAnimationFrame(firstRender);
setTimeout(firstRender, 0);
}
});