808 lines
34 KiB
JavaScript
808 lines
34 KiB
JavaScript
/**
|
||
* XB_ToolBox — ComfyUI “Nodes 2.0”(DOM/Vue 节点渲染)兼容工具
|
||
* ============================================================
|
||
* 背景(前端 1.5x 实测源码结论):
|
||
* · 开启 `Comfy.VueNodes.Enabled` 后,节点由 DOM 渲染,`LGraphCanvas.drawNode()` 直接 early-return
|
||
* ⇒ 节点级 onDrawForeground / onDrawBackground / node.draw 一律不执行。
|
||
* · 节点级 onMouseDown / onDblClick(画布坐标命中)不再触发。
|
||
* · 画布 widget 仍会画(WidgetLegacy 把 widget.draw 画进独立 canvas)。
|
||
* · 可见性判定:经典模式看 `type === "hidden"`;Nodes 2.0 看 `widget.hidden` / `widget.options.hidden`。
|
||
* · DOM widget 高度:Nodes 2.0 走 `computeLayoutSize()`(= options.getMinHeight 或 CSS 变量
|
||
* `--comfy-widget-min-height`),经典模式走 `computeSize()`。
|
||
*
|
||
* 本文件被 WEB_DIRECTORY 当作扩展一起加载,但只导出工具函数、不注册扩展。
|
||
*/
|
||
import { app } from "../../scripts/app.js";
|
||
|
||
/** 当前是否运行在 Nodes 2.0(DOM 节点渲染)模式 */
|
||
export function isNodes2() {
|
||
try {
|
||
return app?.ui?.settings?.getSettingValue?.("Comfy.VueNodes.Enabled") === true;
|
||
} catch (_) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/** 取“当前活动的”widget 对象:按名字重新查找,确保拿到 Nodes 2.0 的响应式代理 */
|
||
export function liveWidget(node, w) {
|
||
try {
|
||
const list = node?.widgets;
|
||
if (!list || !w) return w;
|
||
return list.find((x) => x === w) || list.find((x) => x.name === w.name) || w;
|
||
} catch (_) {
|
||
return w;
|
||
}
|
||
}
|
||
|
||
/** 按名字收集 widget(含 type === "button" 的通配) */
|
||
export function findWidgets(node, names, opts = {}) {
|
||
const set = new Set(names);
|
||
const wantButtons = !!opts.buttons;
|
||
return (node?.widgets || []).filter((w) => w && (set.has(w.name) || (wantButtons && w.type === "button")));
|
||
}
|
||
|
||
/**
|
||
* 让 Nodes 2.0 的 widget 列表刷新一次。
|
||
* 前端把 node.widgets 包成 reactive 数组并监听长度/元素变化,重新赋值同内容数组即触发重新映射。
|
||
*/
|
||
export function refreshWidgets(node) {
|
||
try {
|
||
if (Array.isArray(node?.widgets)) node.widgets = node.widgets.slice();
|
||
} catch (_) { /* ignore */ }
|
||
// ⚠️ 实测(前端 1.52.7):只改 type/hidden/options.hidden 时,Nodes 2.0 的 DOM 不会立刻重渲染;
|
||
// 触发这个内部事件会让 Vue 侧重新抽取节点数据,可见性立即生效(其他图形事件同理)。
|
||
try {
|
||
node?.graph?.trigger?.("node:slot-label:changed", { nodeId: node.id, slotType: 2 });
|
||
} catch (_) { /* ignore */ }
|
||
try { node?.setDirtyCanvas?.(true, true); } catch (_) { /* ignore */ }
|
||
try { node?.graph?.setDirtyCanvas?.(true, true); } catch (_) { /* ignore */ }
|
||
}
|
||
|
||
/**
|
||
* 隐藏 / 恢复一个 widget(经典 + Nodes 2.0 双兼容,幂等)
|
||
* 经典模式:type="hidden" + computeSize=[0,-4]
|
||
* Nodes 2.0:hidden=true + options.hidden=true(官方 core 的 setWidgetHidden 同款)
|
||
*/
|
||
export function setWidgetHidden(node, w, hidden) {
|
||
const t = liveWidget(node, w);
|
||
if (!t) return;
|
||
if (hidden) {
|
||
if (!t.__xbHide) {
|
||
t.__xbHide = {
|
||
type: t.type,
|
||
hidden: t.hidden,
|
||
optHidden: t.options ? t.options.hidden : undefined,
|
||
computeSize: t.computeSize,
|
||
hadComputeSize: Object.prototype.hasOwnProperty.call(t, "computeSize"),
|
||
};
|
||
}
|
||
try { t.type = "hidden"; } catch (_) { /* ignore */ }
|
||
try { t.hidden = true; } catch (_) { /* ignore */ }
|
||
try { if (t.options) t.options.hidden = true; } catch (_) { /* ignore */ }
|
||
try { t.computeSize = () => [0, -4]; } catch (_) { /* ignore */ }
|
||
try { if (t.inputEl) t.inputEl.style.display = "none"; } catch (_) { /* ignore */ }
|
||
} else {
|
||
const s = t.__xbHide;
|
||
if (!s) return;
|
||
try { t.type = s.type; } catch (_) { /* ignore */ }
|
||
try { t.hidden = s.hidden; } catch (_) { /* ignore */ }
|
||
try { if (t.options) t.options.hidden = s.optHidden; } catch (_) { /* ignore */ }
|
||
try {
|
||
if (s.hadComputeSize) t.computeSize = s.computeSize;
|
||
else delete t.computeSize;
|
||
} catch (_) { /* ignore */ }
|
||
try { if (t.inputEl) t.inputEl.style.display = ""; } catch (_) { /* ignore */ }
|
||
try { delete t.__xbHide; } catch (_) { /* ignore */ }
|
||
}
|
||
// ⚠️ 关键:onNodeCreated 阶段前端还没把 widgets 包成响应式代理,改完必须刷一次才重渲染
|
||
refreshWidgets(node);
|
||
}
|
||
|
||
export const hideWidget = (node, w) => setWidgetHidden(node, w, true);
|
||
export const showWidget = (node, w) => setWidgetHidden(node, w, false);
|
||
|
||
/**
|
||
* 显示部件并按需覆盖类型 / 高度(模型加载器这类“槽位”场景使用)
|
||
* @param {object} opts.type 恢复后要设置的 widget 类型(combo / toggle / number …)
|
||
* @param {Function} opts.computeSize 恢复后要设置的 computeSize
|
||
*/
|
||
export function showWidgetAs(node, w, opts = {}) {
|
||
setWidgetHidden(node, w, false);
|
||
const t = liveWidget(node, w);
|
||
if (!t) return;
|
||
try { if (opts.type) t.type = opts.type; } catch (_) { /* ignore */ }
|
||
try { if (typeof opts.computeSize === "function") t.computeSize = opts.computeSize; } catch (_) { /* ignore */ }
|
||
}
|
||
|
||
/** 节点内“一行部件”的经典模式尺寸(Nodes 2.0 会按 DOM 内容自适应,不受影响) */
|
||
export const slotSize = (node, h = 26) => () => [Math.max(1, (node?.size?.[0] || 360) - 16), h];
|
||
|
||
/** 批量锁定一组 widget:names 为名字数组;opts.buttons=true 时把 button 型 widget 一并锁定 */
|
||
export function lockWidgets(node, names, locked, opts = {}) {
|
||
const list = findWidgets(node, names, opts);
|
||
for (const w of list) setWidgetHidden(node, w, locked);
|
||
refreshWidgets(node);
|
||
return list.length;
|
||
}
|
||
|
||
/**
|
||
* 灰化(禁用)一组 widget:仍可见但不可操作
|
||
* Nodes 2.0:widget.disabled = true → Vue 组件拿到 options.disabled 后渲染成 disabled(实测生效)
|
||
* 经典模式:同步写 options.disabled(部分前端版本不绘制灰态,但不影响数值使用)
|
||
* @param {object} node 节点
|
||
* @param {string[]} names widget 名
|
||
* @param {boolean} disabled 是否禁用
|
||
* @returns {boolean} 是否有变化
|
||
*/
|
||
export function setWidgetsDisabled(node, names, disabled) {
|
||
const list = findWidgets(node, names);
|
||
let changed = false;
|
||
for (const w of list) {
|
||
const t = liveWidget(node, w);
|
||
if (!t) continue;
|
||
if (!!t.disabled === !!disabled) {
|
||
if (t.options && !!t.options.disabled !== !!disabled) t.options.disabled = !!disabled;
|
||
continue;
|
||
}
|
||
t.disabled = !!disabled;
|
||
try { if (t.options) t.options.disabled = !!disabled; } catch (_) { /* ignore */ }
|
||
changed = true;
|
||
}
|
||
if (changed) refreshWidgets(node);
|
||
return changed;
|
||
}
|
||
|
||
/** LATENT 输出端口是否已连线(空latent 一组参数是否生效的前提) */
|
||
export function latentOutputLinked(node) {
|
||
try {
|
||
const outs = node?.outputs || [];
|
||
const slot = outs.find((o) => String(o?.type || "").toUpperCase() === "LATENT")
|
||
|| outs.find((o) => o?.name === "空latent");
|
||
if (!slot) return false;
|
||
return Array.isArray(slot.links) ? slot.links.length > 0 : slot.link != null;
|
||
} catch (_) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 「空latent 未连线 → 相关参数变灰不可操作」的守卫
|
||
* 生效时把 names 里的 widget 设为 disabled;接上「空latent」输出连线后自动恢复。
|
||
* @param {object} node 节点
|
||
* @param {string[]} [names] 默认 latent_kind / aspect_ratio / width / height / batch_size
|
||
* @returns {Function} 手动刷新函数(工作流加载后调用一次)
|
||
*/
|
||
export function installLatentGuard(node, names) {
|
||
if (!node) return () => {};
|
||
const list = names || ["latent_kind", "aspect_ratio", "width", "height", "batch_size"];
|
||
const apply = () => {
|
||
if (!node.widgets) return;
|
||
const locked = !latentOutputLinked(node);
|
||
setWidgetsDisabled(node, list, locked);
|
||
try { node.__xbLatentLocked = locked; } catch (_) { /* ignore */ }
|
||
};
|
||
node.__xbLatentGuardApply = apply;
|
||
if (!node.__xbLatentGuardHooked) {
|
||
node.__xbLatentGuardHooked = true;
|
||
const prev = node.onConnectionsChange;
|
||
node.onConnectionsChange = function (...args) {
|
||
const r = prev?.apply(this, args);
|
||
setTimeout(apply, 0);
|
||
return r;
|
||
};
|
||
}
|
||
setTimeout(apply, 0);
|
||
setTimeout(apply, 350);
|
||
return apply;
|
||
}
|
||
|
||
/**
|
||
* DOM widget 尺寸(Nodes 2.0 用 computeLayoutSize/options.getMinHeight,经典模式用 computeSize)
|
||
* @param {number} minH 期望高度(px)
|
||
*/
|
||
export function sizeDomWidget(node, w, minH) {
|
||
if (!w) return w;
|
||
const h = Math.max(1, Math.round(Number(minH) || 50));
|
||
try {
|
||
w.options = w.options || {};
|
||
w.options.getMinHeight = () => h;
|
||
} catch (_) { /* ignore */ }
|
||
try {
|
||
w.computeLayoutSize = () => ({ minHeight: h, maxHeight: undefined, minWidth: 0 });
|
||
} catch (_) { /* ignore */ }
|
||
try {
|
||
w.computeSize = () => [Math.max(1, (node?.size?.[0] || 400) - 16), h];
|
||
} catch (_) { /* ignore */ }
|
||
try {
|
||
w.element?.style?.setProperty?.("--comfy-widget-min-height", h + "px");
|
||
} catch (_) { /* ignore */ }
|
||
return w;
|
||
}
|
||
|
||
/** 统一样式设置:DOM widget(element)/ 经典 widget(inputEl)都能吃到,不存在则静默跳过 */
|
||
export function styleWidgetInput(w, css) {
|
||
try {
|
||
for (const el of [w?.inputEl, w?.element]) {
|
||
if (!el || !el.style) continue;
|
||
if (typeof css === "string") el.style.cssText += ";" + css;
|
||
else Object.assign(el.style, css);
|
||
}
|
||
} catch (_) { /* ignore */ }
|
||
}
|
||
|
||
/**
|
||
* 统一写 widget 值:DOM 面板 / Vue 部件 / 回调 三条通道都兜住
|
||
* (Nodes 2.0 里数字/下拉是 Vue 组件,没有 inputEl/element,只能靠 value + callback)
|
||
*/
|
||
export function setWidgetValue(w, val) {
|
||
if (!w) return;
|
||
const el = w.inputEl || w.element;
|
||
let handled = false;
|
||
try {
|
||
if (el && "value" in el) {
|
||
el.value = val;
|
||
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||
handled = true;
|
||
}
|
||
} catch (_) { /* ignore */ }
|
||
try {
|
||
if (w.value !== val) w.value = val;
|
||
} catch (_) { /* ignore */ }
|
||
if (!handled) {
|
||
try { if (typeof w.callback === "function") w.callback(val); } catch (_) { /* ignore */ }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 生成/复用“被接管”提示条(DOM widget,经典模式与 Nodes 2.0 都能显示)
|
||
* 用一个 DOM widget 替代原来画在画布上的 🔒 蒙版文案。
|
||
*/
|
||
export function ensureBanner(node, name, text, minH = 30) {
|
||
let w = (node?.widgets || []).find((x) => x.name === name);
|
||
if (w) {
|
||
try {
|
||
const el = w.element?.querySelector?.("[data-xb-banner]");
|
||
if (el && el.textContent !== text) el.textContent = text;
|
||
} catch (_) { /* ignore */ }
|
||
return w;
|
||
}
|
||
const el = document.createElement("div");
|
||
el.dataset.xbBanner = "1";
|
||
el.textContent = text;
|
||
el.style.cssText =
|
||
"padding:6px 8px;border-radius:6px;background:var(--xb-bg-raise);color:var(--xb-text);" +
|
||
"font-size:12px;line-height:1.4;text-align:center;user-select:none;";
|
||
w = node.addDOMWidget(name, name, el, { serialize: false, hideOnZoom: false, getMinHeight: () => minH });
|
||
sizeDomWidget(node, w, minH);
|
||
setWidgetHidden(node, w, true); // 默认收起
|
||
return w;
|
||
}
|
||
|
||
/**
|
||
* 接力点“端口被连线接管 → 锁住本地部件”的通用实现(替代原来画在画布上的蒙版 + onMouseDown 拦截)
|
||
* @param {object} node
|
||
* @param {object} opts
|
||
* opts.locked(node) -> boolean 是否处于锁定态
|
||
* opts.names string[] 需要锁定的 widget 名
|
||
* opts.text string 提示条文案
|
||
* opts.watch string[] 触发重新评估的 widget 名(开关型)
|
||
*/
|
||
export function installRelayLock(node, opts = {}) {
|
||
if (!node || node._xbRelayLock) return;
|
||
node._xbRelayLock = true;
|
||
|
||
const names = opts.names || ["image_upload", "upload"];
|
||
const watch = opts.watch || [];
|
||
const bannerName = opts.bannerName || "xb_relay_banner";
|
||
const text = opts.text || "🔒 已由连线接管";
|
||
const isLocked = () => {
|
||
try { return !!opts.locked?.(node); } catch (_) { return false; }
|
||
};
|
||
const targets = () =>
|
||
(node.widgets || []).filter(
|
||
(w) =>
|
||
w &&
|
||
w.name !== bannerName &&
|
||
(names.includes(w.name) || (w.type === "button" && !/randomize/i.test(w.name || "")))
|
||
);
|
||
|
||
const apply = () => {
|
||
if (!node.widgets) return;
|
||
const lock = isLocked();
|
||
for (const w of targets()) setWidgetHidden(node, w, lock);
|
||
setWidgetHidden(node, ensureBanner(node, bannerName, text), !lock);
|
||
if (opts.hideImgs) syncImgs(lock);
|
||
refreshWidgets(node);
|
||
};
|
||
|
||
// 预览图收走/归还(取代原先在 onDrawBackground 里临时置空 this.imgs 的写法)
|
||
const syncImgs = (lock) => {
|
||
try {
|
||
if (lock) {
|
||
if (node.imgs && !node.__xbImgs) {
|
||
node.__xbImgs = node.imgs;
|
||
node.imgs = undefined;
|
||
}
|
||
} else if (node.__xbImgs) {
|
||
node.imgs = node.__xbImgs;
|
||
delete node.__xbImgs;
|
||
}
|
||
} catch (_) { /* ignore */ }
|
||
};
|
||
if (opts.hideImgs) {
|
||
const origExec = node.onExecuted;
|
||
node.onExecuted = function () {
|
||
const r = origExec?.apply(this, arguments);
|
||
try { if (isLocked()) syncImgs(true); } catch (_) { /* ignore */ }
|
||
return r;
|
||
};
|
||
}
|
||
|
||
// 1) 连线变化 → 重新评估
|
||
const origConn = node.onConnectionsChange;
|
||
node.onConnectionsChange = function () {
|
||
const r = origConn?.apply(this, arguments);
|
||
setTimeout(apply, 0);
|
||
return r;
|
||
};
|
||
|
||
// 2) 开关型 widget 的值变化 → 重新评估
|
||
const bindWatch = () => {
|
||
for (const nm of watch) {
|
||
const w = (node.widgets || []).find((x) => x.name === nm);
|
||
if (!w || w._xbLockBound) continue;
|
||
w._xbLockBound = true;
|
||
const oc = w.callback;
|
||
w.callback = function (v, ...rest) {
|
||
const r = oc?.apply(this, [v, ...rest]);
|
||
setTimeout(apply, 0);
|
||
return r;
|
||
};
|
||
}
|
||
};
|
||
const boot = () => { bindWatch(); apply(); };
|
||
setTimeout(boot, 50);
|
||
setTimeout(boot, 300);
|
||
setTimeout(apply, 1200);
|
||
apply();
|
||
}
|
||
|
||
/**
|
||
* Nodes 2.0:让「面板内某个输入框」跟随节点拉伸而变高(经典模式下还原原样、零副作用)
|
||
* ============================================================
|
||
* 背景(实测):2.0 下节点高度由 DOM 内容决定,面板里 `flex:1 1 auto` 的框在容器
|
||
* 高度不确定时会一直停在 min-height(用户在节点上拖高,框不跟着变)。
|
||
* 而「按节点高度反推框高」会自激(写框高 → 撑高节点 → 又被当成拖拽增量),
|
||
* 所以这里采用已验证的做法:
|
||
* · 首次直接给默认高度(deflt);
|
||
* · 用户拖节点尺寸手柄时,按**指针位移 / 画布缩放**同步改框高(两个方向都跟手,
|
||
* 且不会因为"节点不允许小于内容"而拖不回去);
|
||
* · 切回经典模式 → 还原原样行内值,交回原有 flex 布局。
|
||
*
|
||
* @param {object} node LiteGraph 节点
|
||
* @param {HTMLElement} box 要被改高度的元素(面板里的输入框)
|
||
* @param {object} [opts] { min, max, deflt }
|
||
* @returns {() => void} 卸载函数
|
||
*/
|
||
export function installNodes2BoxResize(node, box, opts = {}) {
|
||
const MIN = Number(opts.min) || 120;
|
||
const MAX = Number(opts.max) || 4000;
|
||
const DEFLT = Number(opts.deflt) || 300;
|
||
const orig = {
|
||
height: box.style.height, minHeight: box.style.minHeight,
|
||
maxHeight: box.style.maxHeight, flex: box.style.flex,
|
||
};
|
||
let applied = false, drag = null, timer = 0;
|
||
|
||
const apply = (h) => {
|
||
applied = true;
|
||
box.style.flex = "0 0 auto";
|
||
box.style.height = h + "px";
|
||
box.style.minHeight = h + "px";
|
||
box.style.maxHeight = h + "px";
|
||
};
|
||
const restore = () => {
|
||
if (!applied) return;
|
||
applied = false;
|
||
try {
|
||
box.style.height = orig.height;
|
||
box.style.minHeight = orig.minHeight;
|
||
box.style.maxHeight = orig.maxHeight;
|
||
box.style.flex = orig.flex;
|
||
} catch (_) { /* ignore */ }
|
||
};
|
||
const sync = () => {
|
||
if (!isNodes2()) { restore(); return; }
|
||
if (drag) return; // 拖拽中由指针接管
|
||
if (!applied) apply(Math.max(MIN, Math.min(MAX, DEFLT)));
|
||
};
|
||
const onMove = (e) => {
|
||
if (!drag) return;
|
||
try {
|
||
const scale = Number(app?.canvas?.ds?.scale) || 1;
|
||
const d = ((e.clientY - drag.y) / scale) * drag.sign;
|
||
const h = Math.max(MIN, Math.min(MAX, Math.round(drag.box + d)));
|
||
if (Math.abs(box.offsetHeight - h) > 1) apply(h);
|
||
} catch (_) { /* ignore */ }
|
||
};
|
||
const onEnd = () => {
|
||
drag = null;
|
||
try {
|
||
window.removeEventListener("pointermove", onMove, true);
|
||
window.removeEventListener("pointerup", onEnd, true);
|
||
window.removeEventListener("pointercancel", onEnd, true);
|
||
} catch (_) { /* ignore */ }
|
||
};
|
||
const hookHandles = () => {
|
||
try {
|
||
const el = document.querySelector(`[data-node-id="${node.id}"]`);
|
||
if (!el || el.__xbBoxResizeHooked) return;
|
||
el.__xbBoxResizeHooked = true;
|
||
el.addEventListener("pointerdown", (e) => {
|
||
const hd = (e.target && e.target.closest) ? e.target.closest('[aria-label*="调整大小"]') : null;
|
||
if (!hd) return;
|
||
const corner = (hd.getAttribute("data-corner") || "").toUpperCase();
|
||
if (corner.indexOf("N") < 0 && corner.indexOf("S") < 0) return; // 只处理能改高度的角
|
||
drag = {
|
||
y: e.clientY,
|
||
box: box.offsetHeight || DEFLT,
|
||
sign: corner.indexOf("N") >= 0 ? -1 : 1, // 拖上边时指针向上 = 节点变高
|
||
};
|
||
window.addEventListener("pointermove", onMove, true);
|
||
window.addEventListener("pointerup", onEnd, true);
|
||
window.addEventListener("pointercancel", onEnd, true);
|
||
}, true);
|
||
} catch (_) { /* ignore */ }
|
||
};
|
||
|
||
// 初始化:几次延时兜住「DOM 挂载/工作流加载」时序;之后 1s 轮询应对运行时切换 2.0 开关
|
||
const boot = () => { hookHandles(); sync(); };
|
||
[0, 120, 400, 1000].forEach((d) => setTimeout(boot, d));
|
||
timer = setInterval(boot, 1000);
|
||
|
||
const uninstall = () => {
|
||
try { clearInterval(timer); } catch (_) { /* ignore */ }
|
||
onEnd();
|
||
restore();
|
||
};
|
||
try {
|
||
const origRemoved = node.onRemoved;
|
||
node.onRemoved = function () { uninstall(); return origRemoved?.apply(this, arguments); };
|
||
} catch (_) { /* ignore */ }
|
||
return uninstall;
|
||
}
|
||
|
||
/**
|
||
* 结构变化(切换 Nodes 2.0 时前端会整块卸载/重建 `.lg-node`)的统一监听。
|
||
* MutationObserver 的回调在**绘制前**执行 ⇒ 在它里面同步重算高度,用户看不到中间态
|
||
* (否则容器会先按"无上限"铺开一帧,再被压回,表现为节点像弹簧一样变长再缩回)。
|
||
*/
|
||
const XB_NODES2_APPLIERS = new Set();
|
||
let XB_NODES2_OBSERVER = null;
|
||
function watchNodes2Mount(apply) {
|
||
if (typeof MutationObserver !== "function") return;
|
||
XB_NODES2_APPLIERS.add(apply);
|
||
if (XB_NODES2_OBSERVER) return;
|
||
try {
|
||
XB_NODES2_OBSERVER = new MutationObserver(() => {
|
||
for (const fn of XB_NODES2_APPLIERS) { try { fn(); } catch (_) { /* ignore */ } }
|
||
});
|
||
XB_NODES2_OBSERVER.observe(document.body, { childList: true, subtree: true });
|
||
} catch (_) { XB_NODES2_OBSERVER = null; }
|
||
}
|
||
|
||
/**
|
||
* ============================================================
|
||
* Nodes 2.0:让一个「面板显示框」始终填满节点剩余空间(随节点高度自由伸缩)
|
||
* ============================================================
|
||
* 与 installNodes2BoxResize(按指针位移改固定 px)不同,这里把框高绑到前端自带的
|
||
* CSS 变量 `--node-height`(= 节点高度 + 固定余量),减去一次性标定出的 chrome:
|
||
* · 纯 CSS 跟随 ⇒ 拖大拖小都跟手,不会「只能变大不能变小」(写 px 会写回 node.size 自锁);
|
||
* · 与 JZL 导演台/查看器同一套做法,多节点共用一个实现。
|
||
*
|
||
* @param {object} node LiteGraph 节点
|
||
* @param {HTMLElement} box 要伸缩的显示框
|
||
* @param {object} [opts] { min }
|
||
* @returns {() => void} 卸载函数
|
||
*/
|
||
export function bindNodes2BoxFill(node, box, opts = {}) {
|
||
if (!node || !box) return () => {};
|
||
const MIN = Number(opts.min) || 120;
|
||
const PROBE = 3000;
|
||
const orig = { height: box.style.height, maxHeight: box.style.maxHeight, minHeight: box.style.minHeight, flex: box.style.flex };
|
||
let K = 0, applied = false, timer = 0;
|
||
|
||
const rootEl = () => (box.closest ? box.closest(".lg-node") : null);
|
||
// chrome = 节点根部高度 − 显示框高度:用固定 3000px 撑开量,与框内内容无关
|
||
const calibrate = () => {
|
||
const root = rootEl();
|
||
if (!root) return false;
|
||
const keep = { max: box.style.maxHeight, min: box.style.minHeight, flex: box.style.flex, h: box.style.height };
|
||
try {
|
||
box.style.maxHeight = "";
|
||
box.style.minHeight = "0px";
|
||
box.style.flex = "0 0 auto";
|
||
box.style.height = PROBE + "px";
|
||
const rh = root.clientHeight, bh = box.clientHeight;
|
||
if (!bh || rh <= bh) return false;
|
||
K = Math.max(40, Math.round(rh - bh));
|
||
return true;
|
||
} finally {
|
||
box.style.maxHeight = keep.max;
|
||
box.style.minHeight = keep.min;
|
||
box.style.flex = keep.flex;
|
||
box.style.height = keep.h;
|
||
}
|
||
};
|
||
const apply = () => {
|
||
const root = box.closest ? box.closest(".lg-node") : null;
|
||
if (!root) { restore(); return; }
|
||
// `--node-height` 是 2.0 渲染器写在 .lg-node 上的;拿不到就不写 calc(否则回落兜底值 → 弹簧闪动)
|
||
let vh = "";
|
||
try { vh = getComputedStyle(root).getPropertyValue("--node-height").trim(); } catch (_) { /* ignore */ }
|
||
if (!vh) return;
|
||
if (!K && !calibrate()) return;
|
||
const h = `calc(var(--node-height) - ${K}px)`;
|
||
applied = true;
|
||
box.style.flex = "0 0 auto";
|
||
if (box.style.height !== h) box.style.height = h;
|
||
if (box.style.maxHeight !== h) box.style.maxHeight = h;
|
||
if (box.style.minHeight !== MIN + "px") box.style.minHeight = MIN + "px";
|
||
};
|
||
const restore = () => {
|
||
if (!applied) return;
|
||
applied = false;
|
||
try {
|
||
box.style.height = orig.height;
|
||
box.style.maxHeight = orig.maxHeight;
|
||
box.style.minHeight = orig.minHeight;
|
||
box.style.flex = orig.flex;
|
||
} catch (_) { /* ignore */ }
|
||
};
|
||
const tick = () => {
|
||
try {
|
||
if (isNodes2()) apply();
|
||
else restore();
|
||
} catch (_) { /* ignore */ }
|
||
};
|
||
[0, 120, 400, 1000].forEach((d) => setTimeout(tick, d));
|
||
timer = setInterval(tick, 1000);
|
||
watchNodes2Mount(tick); // 切 2.0 时在绘制前同步重算,避免一帧的"无上限"铺开
|
||
|
||
const uninstall = () => {
|
||
try { clearInterval(timer); } catch (_) { /* ignore */ }
|
||
XB_NODES2_APPLIERS.delete(tick);
|
||
restore();
|
||
};
|
||
try {
|
||
const origRemoved = node.onRemoved;
|
||
node.onRemoved = function () { uninstall(); return origRemoved?.apply(this, arguments); };
|
||
} catch (_) { /* ignore */ }
|
||
return uninstall;
|
||
}
|
||
|
||
/**
|
||
* ============================================================
|
||
* Nodes 2.0:让「面板容器」始终填满节点剩余高度(贴住节点下边缘)
|
||
* ============================================================
|
||
* 与 installNodes2BoxResize(按指针位移写固定 px)不同:这里每拍按**当前实际可用高度**重算。
|
||
* · 可用高度 = 节点根部 clientHeight − 容器顶部偏移(标题 + 原生输入行) − 底部留白
|
||
* · 容器写死 height/maxHeight ⇒ 内部 `flex:1` 的输入框/显示窗自然吃掉剩余空间
|
||
* ⇒ 节点拉高时不会再在底部留一片空白,也不会出现"内容撑出面板"的滚动条。
|
||
* · 不写 node.setSize(节点尺寸完全由用户/前端决定)⇒ 没有自激抖动。
|
||
* · 经典模式(无 .lg-node)→ 立即还原行内原样。
|
||
*
|
||
* @param {object} node LiteGraph 节点
|
||
* @param {HTMLElement} box 要撑满的容器(DOM widget 内容根)
|
||
* @param {object} [opts] { min, bottomPad }
|
||
* @returns {() => void} 卸载函数
|
||
*/
|
||
export function bindNodes2NodeFill(node, box, opts = {}) {
|
||
if (!node || !box) return () => {};
|
||
const MIN = Number(opts.min) || 120;
|
||
const PAD = Number.isFinite(opts.bottomPad) ? Number(opts.bottomPad) : 12;
|
||
const orig = { height: box.style.height, maxHeight: box.style.maxHeight, minHeight: box.style.minHeight, flex: box.style.flex };
|
||
let applied = false, timer = 0, rafId = 0;
|
||
// ⚠️ Nodes 2.0 刚挂载时,上方原生 widget 行还在逐行铺开,量到的 top 会先小后大。
|
||
// 用偏小的 top 算出的容器高度比可用空间大 → 把整个节点撑高,
|
||
// 下一拍 top 变大再缩回 ⇒ 用户看到「进界面时节点长高一下又恢复」。
|
||
// 所以挂载后的 SETTLE_MS 内只测量、不写入,等布局稳定后再贴合。
|
||
const SETTLE_MS = 300;
|
||
let mountedAt = 0;
|
||
// 已确认的「容器顶部相对节点根部」偏移(布局像素)与待确认候选值
|
||
let stableTop = -1, candTop = -1, candCount = 0;
|
||
|
||
// ⚠️ 必须用 offsetTop 累加取偏移,不能用 getBoundingClientRect 相减再除以缩放比:
|
||
// 滚轮缩放时 `app.canvas.ds.scale` 立刻变成新值,而 DOM 的 transform 要下一帧才跟上,
|
||
// 两者不同步 ⇒ 量到的 chrome 比实际小(实测 234 vs 256)⇒ 容器被算高 22px、
|
||
// 节点被内容撑高 13px,下一拍又校正回来 = 用户看到的「跟弹簧一样变一下」。
|
||
// offsetTop/offsetParent 是布局像素,与画布缩放完全无关,可彻底避开这个不同步。
|
||
const layoutTop = (el, root) => {
|
||
let y = 0;
|
||
let cur = el;
|
||
while (cur && cur !== root) {
|
||
y += cur.offsetTop || 0;
|
||
cur = cur.offsetParent;
|
||
}
|
||
return cur === root ? y : null;
|
||
};
|
||
|
||
const restore = () => {
|
||
if (applied) {
|
||
try {
|
||
box.style.height = orig.height;
|
||
box.style.maxHeight = orig.maxHeight;
|
||
box.style.minHeight = orig.minHeight;
|
||
box.style.flex = orig.flex;
|
||
} catch (_) { /* ignore */ }
|
||
}
|
||
applied = false;
|
||
mountedAt = 0;
|
||
stableTop = -1; candTop = -1; candCount = 0;
|
||
};
|
||
|
||
const apply = () => {
|
||
const root = box.closest ? box.closest(".lg-node") : null;
|
||
if (!root) { restore(); return; } // 经典模式 / 还没挂到 DOM
|
||
try {
|
||
// `--node-height` 是 2.0 渲染器写在 .lg-node 上的;拿不到就不写 calc,
|
||
// 否则 calc 会回落到兜底值(3000px)⇒ 切换 2.0 的瞬间节点像弹簧一样变长再缩回。
|
||
let vh = "";
|
||
try { vh = getComputedStyle(root).getPropertyValue("--node-height").trim(); } catch (_) { /* ignore */ }
|
||
if (!vh) { restore(); return; }
|
||
if (!mountedAt) mountedAt = performance.now();
|
||
if (performance.now() - mountedAt < SETTLE_MS) return; // 布局还在长,写入会撑高节点
|
||
const rb = root.getBoundingClientRect();
|
||
const bb = box.getBoundingClientRect();
|
||
if (!(bb.width > 0 && bb.height > 0 && rb.height > 0)) return; // 还没真正布局
|
||
const rawTop = layoutTop(box, root);
|
||
if (rawTop == null) return; // 还不在节点 DOM 里
|
||
// 收缩方向(top 变小 ⇒ 容器会被撑高)必须连续两拍一致才采纳,
|
||
// 避免前端重排的中间帧把节点顶高一下再缩回。
|
||
if (stableTop < 0 || rawTop >= stableTop) {
|
||
stableTop = rawTop; candTop = -1; candCount = 0;
|
||
} else if (rawTop === candTop) {
|
||
if (++candCount >= 1) { stableTop = rawTop; candTop = -1; candCount = 0; }
|
||
} else {
|
||
candTop = rawTop; candCount = 0;
|
||
}
|
||
const top = Math.max(0, Math.round(stableTop));
|
||
const css = `calc(var(--node-height) - ${top + PAD}px)`;
|
||
applied = true;
|
||
if (box.style.flex !== "0 0 auto") box.style.flex = "0 0 auto";
|
||
if (box.style.minHeight !== MIN + "px") box.style.minHeight = MIN + "px";
|
||
if (box.style.maxHeight !== css) box.style.maxHeight = css;
|
||
if (box.style.height !== css) box.style.height = css;
|
||
} catch (_) { /* ignore */ }
|
||
};
|
||
|
||
const tick = () => { try { apply(); } catch (_) { /* ignore */ } };
|
||
// 首帧 + 之后 500ms 轮询(切 2.0 开关、面板重建、前端重排都能兜住)
|
||
[0, 60, 200, 600].forEach((d) => setTimeout(tick, d));
|
||
// 布局稳定后立刻贴合,不必等下一个 500ms 轮询
|
||
setTimeout(tick, SETTLE_MS + 20);
|
||
timer = setInterval(tick, 500);
|
||
// 结构变化(切 2.0 时 .lg-node 重建)→ MutationObserver 在**绘制前**同步重算,避免看到中间态
|
||
watchNodes2Mount(tick);
|
||
// 挂载初期用 rAF 连续测量,保证 SETTLE_MS 一到就能用最新的 top
|
||
let burst = 40;
|
||
const rafTick = () => { tick(); if (--burst > 0) rafId = requestAnimationFrame(rafTick); };
|
||
rafId = requestAnimationFrame(rafTick);
|
||
|
||
const uninstall = () => {
|
||
try { clearInterval(timer); } catch (_) { /* ignore */ }
|
||
try { cancelAnimationFrame(rafId); } catch (_) { /* ignore */ }
|
||
restore();
|
||
};
|
||
try {
|
||
const origRemoved = node.onRemoved;
|
||
node.onRemoved = function () { uninstall(); return origRemoved?.apply(this, arguments); };
|
||
} catch (_) { /* ignore */ }
|
||
return uninstall;
|
||
}
|
||
|
||
/**
|
||
* 遍历「根图 + 所有子图(含嵌套)」,返回全部节点。
|
||
* 放在子图里的节点不在 app.graph._nodes 里,轮询时必须用这个。
|
||
*/
|
||
export function allNodes() {
|
||
const out = [];
|
||
const seenGraphs = new Set();
|
||
const seenNodes = new Set();
|
||
const visit = (g) => {
|
||
if (!g || seenGraphs.has(g)) return;
|
||
seenGraphs.add(g);
|
||
for (const n of g._nodes || []) {
|
||
if (!seenNodes.has(n)) { seenNodes.add(n); out.push(n); }
|
||
if (n.subgraph) visit(n.subgraph);
|
||
}
|
||
};
|
||
visit(app?.graph?.rootGraph || app?.graph);
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* 找外层子图节点上对应同名的 widget。
|
||
* 节点被打包进子图且该输入被提升为子图输入时,外层子图节点上会有一个同名 widget,
|
||
* 但内外两边不会自动互相同步(运行时靠连线传值,widget.value 保持旧值)。
|
||
*/
|
||
export function ownerWidgets(node, name) {
|
||
const inner = node?.graph;
|
||
const root = inner?.rootGraph || app?.graph?.rootGraph;
|
||
const out = [];
|
||
if (!inner || !root || root === inner) return out;
|
||
const seen = new Set();
|
||
const visit = (g) => {
|
||
if (!g || seen.has(g)) return;
|
||
seen.add(g);
|
||
for (const n of g._nodes || []) {
|
||
const sub = n.subgraph;
|
||
if (!sub) continue;
|
||
if (sub === inner) {
|
||
const w = (n.widgets || []).find((x) => x.name === name);
|
||
if (w) out.push(w);
|
||
}
|
||
visit(sub);
|
||
}
|
||
};
|
||
visit(root);
|
||
return out;
|
||
}
|
||
|
||
/** 按 widget 类型把值规整一下;不合法返回 null */
|
||
function promoNormalize(w, v) {
|
||
const vals = w?.options?.values;
|
||
if (Array.isArray(vals) && vals.length) {
|
||
if (vals.includes(v)) return v;
|
||
if (vals.includes(String(v))) return String(v);
|
||
return null;
|
||
}
|
||
const numeric = typeof w?.value === "number" || /INT|FLOAT|number/i.test(String(w?.type || ""));
|
||
if (numeric) {
|
||
const n = Number(v);
|
||
return Number.isFinite(n) ? n : null;
|
||
}
|
||
if (typeof v === "string") return v;
|
||
if (typeof v === "number" || typeof v === "boolean") return v;
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 内外两侧哪个变了就同步到另一边:
|
||
* · 用户改了外层子图节点上的 widget → 拉回内层(ComfyUI 不会自动回写)
|
||
* · 用户改了内层 widget / 内层联动算出新值 → 推到外层(否则执行时用的还是外层旧值)
|
||
* 通过记住上一次两边的值来判定方向,避免互相覆盖。
|
||
*/
|
||
export function syncPromotedValues(node) {
|
||
let pulled = false;
|
||
for (const w of node?.widgets || []) {
|
||
const ext = ownerWidgets(node, w.name);
|
||
if (!ext.length) continue;
|
||
const ow = ext[0];
|
||
const st = w.__xbPromo || (w.__xbPromo = { i: w.value, o: ow.value });
|
||
const innerChanged = st.i !== w.value;
|
||
const outerChanged = st.o !== ow.value;
|
||
if (outerChanged && !innerChanged) {
|
||
const nv = promoNormalize(w, ow.value);
|
||
if (nv !== null && nv !== w.value) { w.value = nv; pulled = true; }
|
||
} else if (innerChanged && !outerChanged) {
|
||
const nv = promoNormalize(ow, w.value);
|
||
if (nv !== null && nv !== ow.value) ow.value = nv;
|
||
}
|
||
w.__xbPromo = { i: w.value, o: ow.value };
|
||
}
|
||
return pulled;
|
||
}
|
||
|
||
/** 把内层 widget 的值推到外层子图节点上(否则执行时用的还是外层的旧值) */
|
||
export function pushPromotedValue(node, name, val) {
|
||
let changed = false;
|
||
for (const w of ownerWidgets(node, name)) {
|
||
if (w.value !== val) { w.value = val; changed = true; }
|
||
}
|
||
return changed;
|
||
}
|
||
|
||
/** 供自动化/排查使用 */
|
||
export const XB_COMPAT = { version: 1 };
|