Files
2026-11-10 03:00:46 +08:00

394 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.
/**
* XB 引用任意 (Reference Any)
* =============================
* - 动态输入槽:接线自动扩展,端口名显示上游节点名
* - 节点上一个正常的下拉框「选择」(原生 combo widget),列出当前已接入的上游节点,
* 选中哪个就只激活它,其余静音;选「全部关闭」= 上游全部静音,本节点输出空
* - 下拉框左侧自带输入端口(widget input),类型放宽成 * 所以可以随便接线
* - 打包进子图(子工作流)后,把「选择」提升为子图输入,子图节点表面就是这个下拉框;
* 在子图节点上换选哪一项,内部就切到哪一个(外层值是准,内层同步)
* - 上游接进来的字符串不会再改写下拉框的选中项(不是字符串控制)
*
* 实现要点(Nodes 2.0 / 经典模式通用):
* (1) 在 beforeRegisterNodeDef 里把「选择」从 STRING 改成 COMBO,
* 这样前端会直接建出原生下拉框(不是文本框),左侧也自带可接线端口。
* (2) Nodes 2.0 的节点组件在创建时就把 widget.options 快照进 store,
* 之后改 options.values 不会重渲染。所以候选值必须是 Vue 的响应式数组,
* 并在节点创建(onNodeCreated)时挂上去,后续用 splice 原地更新才会实时刷新。
* 响应式函数从已加载的 vendor-vue-core 模块里按行为探测取得(不写死哈希文件名)。
*/
import { app } from "../../scripts/app.js";
const NODE_TYPE = "XB_ReferenceAny";
const WNAME = "选择";
const ALL_OFF = "全部关闭";
const MODE_ACTIVE = 0;
const MODE_MUTE = 2;
// ── Vue 响应式数组(拿不到就退化成普通数组:只是不会实时刷新,功能不受影响)──
let xbReactive = null;
let xbReactiveReady = null;
function loadReactive() {
if (xbReactiveReady) return xbReactiveReady;
xbReactiveReady = (async () => {
try {
const urls = performance.getEntriesByType("resource").map((e) => e.name);
const url = urls.find((n) => /vendor-vue-core-.*\.js$/.test(n));
if (!url) return null;
const mod = await import(url);
for (const k of Object.keys(mod)) {
const f = mod[k];
if (typeof f !== "function") continue;
try {
const probe = { __xbProbe: 1 };
const r = f(probe);
if (r && r.__v_isReactive === true && r.__v_raw === probe && r.__v_isShallow !== true) {
xbReactive = f;
break;
}
} catch (_) { /* 不是响应式函数 */ }
}
} catch (_) { /* ignore */ }
return xbReactive;
})();
return xbReactiveReady;
}
loadReactive();
const makeReactive = (arr) => (xbReactive ? xbReactive(arr) : arr);
// ── helpers ────────────────────────────────────────────────────────────
/** 顺着连线(含 Reroute)找真正的上游节点 */
function getUpstreamNode(self, inputIndex) {
const graph = self.graph;
if (!graph) return null;
let node = self;
let slot = inputIndex;
const seen = new Set();
for (let i = 0; i < 20; i++) {
const inp = node.inputs?.[slot];
if (inp?.link == null) return null;
const link = graph.links?.[inp.link];
if (!link) return null;
const src = graph.getNodeById?.(link.origin_id);
if (!src) return null;
if (String(src.type || "").includes("Reroute") && !seen.has(src.id)) {
seen.add(src.id);
node = src;
slot = 0;
continue;
}
return src;
}
return null;
}
/** 「选择」是控制端口(widget 自己占的槽位) */
function isValuePort(inp) {
return !inp || inp.widget || inp.name === WNAME;
}
/** 所有已连线的上游节点:[{ index, node, title }] */
function getLinkedNodes(self) {
const list = [];
for (let i = 0; i < (self.inputs?.length || 0); i++) {
const inp = self.inputs[i];
if (!inp || inp.link == null || isValuePort(inp)) continue;
const up = getUpstreamNode(self, i);
if (up) list.push({ index: i, node: up, title: up.title || up.type || `输入${i + 1}` });
}
return list;
}
/** 数据槽(排除「选择」控制槽与 widget 槽) */
function dataSlots(self) {
return (self.inputs || [])
.map((inp, i) => ({ inp, i }))
.filter((x) => x.inp && !isValuePort(x.inp));
}
/** 把「选择」声明成下拉框(必须在节点类型注册时就改,否则前端建出来的是文本框) */
function forceComboDef(nodeData) {
const spec = nodeData?.input?.required?.[WNAME] || nodeData?.input?.optional?.[WNAME];
if (!Array.isArray(spec)) return;
spec[0] = [ALL_OFF];
const opts = spec[1] && typeof spec[1] === "object" ? spec[1] : {};
opts.default = ALL_OFF;
spec[1] = opts;
}
/** 打包进子图后,外层子图节点上的「选择」是候选值的副本,这里把它们同步过去 */
function propagateToSubgraphNodes(self, names) {
const inner = self.graph;
if (!inner) return;
const root = inner.rootGraph || app.rootGraph;
if (!root || root === inner) return;
const visit = (graph) => {
for (const n of graph._nodes || []) {
const sub = n.subgraph;
if (!sub) continue;
if (sub === inner) {
const w = (n.widgets || []).find((x) => x.name === WNAME);
const arr = w?.options?.values;
if (arr) {
const same = arr.length === names.length && names.every((v, i) => arr[i] === v);
if (!same) arr.splice(0, arr.length, ...names);
if (!names.includes(String(w.value ?? ALL_OFF))) w.value = ALL_OFF;
}
}
visit(sub);
}
};
visit(root);
}
/** 节点刚创建时把候选值换成响应式数组(Nodes 2.0 的 store 会快照它) */
function initWidgetValues(node) {
if (node.__xbValues) return;
const w = (node.widgets || []).find((x) => x.name === WNAME);
if (!w) return;
const arr = makeReactive([ALL_OFF]);
w.options = w.options || {};
w.options.values = arr;
node.__xbValues = arr;
if (!arr.includes(w.value)) w.value = ALL_OFF;
}
// ── extension ──────────────────────────────────────────────────────────
app.registerExtension({
name: "XB_ToolBox.ReferenceAny",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name !== NODE_TYPE) return;
await loadReactive(); // 必须在建节点前拿到响应式函数
forceComboDef(nodeData);
const orig = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = orig?.apply(this, arguments);
initWidgetValues(this);
setTimeout(() => patchInstance(this), 0);
return r;
};
},
async loadedGraphNode(node) {
if (node?.type === NODE_TYPE) setTimeout(() => patchInstance(node), 150);
},
});
// ── instance patching ──────────────────────────────────────────────────
function patchInstance(self) {
if (self.__xbRefReady) return;
self.__xbRefReady = true;
const valueWidget = () => (self.widgets || []).find((w) => w.name === WNAME);
initWidgetValues(self);
// combo 的端口默认类型是 COMBO,别的类型接不进来;
// 改成 * 才能像普通输入端口那样随便接线(是否生效只看下拉框的手动选择)
const widenValueSlot = () => {
const inp = (this?.inputs || self.inputs || []).find((i) => i?.widget && i.name === WNAME);
if (inp && inp.type !== "*") { inp.type = "*"; return true; }
return false;
};
widenValueSlot();
if (!dataSlots(self).length) self.addInput("", "*");
// ── 端口维护:接线扩展 / 收空槽 / 端口名跟随上游 ──────────────────
self.stabilizeInputsOutputs = function () {
let changed = false;
let slots = dataSlots(this);
if (!slots.length) {
this.addInput("", "*");
return true;
}
const last = slots[slots.length - 1];
if (last.inp.link != null) {
this.addInput("", "*");
changed = true;
slots = dataSlots(this);
}
for (let k = slots.length - 2; k >= 0; k--) {
const { inp, i } = slots[k];
if (inp && inp.link == null) {
this.removeInput(i);
changed = true;
}
}
for (const { inp, i } of dataSlots(this)) {
if (inp.link == null) {
if (inp.name !== "") { inp.name = ""; inp.label = ""; changed = true; }
continue;
}
const up = getUpstreamNode(this, i);
const nm = up?.title || up?.type || "";
if (inp.name !== nm) { inp.name = nm; changed = true; }
if ((inp.label || "") !== nm) { inp.label = nm; changed = true; }
}
return changed;
};
// ── 候选值:只认连线(不看 mode,全静音时也照常列出)───────────────
self.syncOptions = function () {
const w = valueWidget();
const arr = this.__xbValues;
if (!w || !arr) return false;
const linked = getLinkedNodes(this);
const names = [ALL_OFF];
for (const { title } of linked) {
if (title && !names.includes(title)) names.push(title);
}
const same = arr.length === names.length && names.every((v, i) => arr[i] === v);
if (!same) arr.splice(0, arr.length, ...names);
const key = names.join("\u0001");
if (this.__xbPropKey !== key) {
this.__xbPropKey = key;
propagateToSubgraphNodes(this, names);
}
const cur = String(w.value ?? ALL_OFF);
if (!names.includes(cur)) {
const still = linked.find((x) => x.node?.id === this.__xbSelectedId);
const keep = still ? still.title : ALL_OFF;
if (w.value !== keep) { w.value = keep; w.callback?.(keep); }
}
return !same;
};
// ── 子图:外层子图节点上的「选择」就是这个节点的选择 ────────────────
const ownerWidgets = () => {
const inner = self.graph;
const root = inner?.rootGraph || app.rootGraph;
const out = [];
if (!inner || !root || root === inner) return out;
const visit = (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 === WNAME);
if (w) out.push(w);
}
visit(sub);
}
};
visit(root);
return out;
};
// 被打包进子图时以外层的值为准;外层值不合法(比如刚删了上游)才用自己的
const effectiveValue = () => {
const own = String(valueWidget()?.value ?? ALL_OFF);
const names = Array.from(self.__xbValues || []);
for (const w of ownerWidgets()) {
const v = String(w.value ?? ALL_OFF);
if (names.includes(v)) return v;
}
return own;
};
// ── 应用选择:选中的激活,其余静音;「全部关闭」= 全静音 ────────────
self.applySelection = function (value) {
const linked = getLinkedNodes(this);
let targetId = null;
if (value && value !== ALL_OFF) {
const hit = linked.find((x) => x.title === value);
if (hit) targetId = hit.node.id;
}
this.__xbSelectedId = targetId;
let changed = false;
for (const { node } of linked) {
if (!node) continue;
const want = (node.id === targetId) ? MODE_ACTIVE : MODE_MUTE;
if (node.mode !== want) { node.mode = want; changed = true; }
}
return changed;
};
// 手动改下拉框 → 立即生效(在子图里也要把外层子图节点的值同步过去)
const w0 = valueWidget();
if (w0 && !w0.__xbComboHooked) {
w0.__xbComboHooked = true;
const orig = w0.callback;
w0.callback = function (v) {
const r = orig?.apply(this, arguments);
const val = String(v ?? ALL_OFF);
self.__xbAppliedValue = val;
self.applySelection?.(val);
for (const w of ownerWidgets()) {
if (String(w.value) !== val) w.value = val;
}
self.graph?.setDirtyCanvas?.(true, true);
return r;
};
}
// ── 周期稳定 ──────────────────────────────────────────────────────
self.doStablization = function () {
if (!this.graph || this.__xbRefRemoved) return;
let dirty = false;
try {
dirty = widenValueSlot.call(this) || dirty;
dirty = this.stabilizeInputsOutputs() || dirty;
dirty = this.syncOptions() || dirty;
const cur = effectiveValue();
if (cur !== this.__xbAppliedValue) {
this.__xbAppliedValue = cur;
dirty = this.applySelection(cur) || dirty;
}
} catch (_) { /* ignore */ }
if (dirty) this.graph.setDirtyCanvas(true, true);
if (!this.__xbRefRemoved) {
this._xbTimer = setTimeout(() => {
this._xbTimer = null;
this.doStablization?.();
}, 300);
}
};
self.scheduleStabilizeWidgets = function (ms = 80) {
if (this._xbScheduled) return;
this._xbScheduled = true;
if (this._xbTimer) clearTimeout(this._xbTimer);
this._xbTimer = setTimeout(() => {
this._xbScheduled = false;
this._xbTimer = null;
this.doStablization?.();
}, ms);
};
// ── hooks ─────────────────────────────────────────────────────────
const origConn = self.onConnectionsChange;
self.onConnectionsChange = function () {
if (origConn) origConn.apply(this, arguments);
this.scheduleStabilizeWidgets?.(64);
};
const origConfigure = self.onConfigure;
self.onConfigure = function () {
const r = origConfigure?.apply(this, arguments);
this.scheduleStabilizeWidgets?.(64);
return r;
};
const origRem = self.onRemoved;
self.onRemoved = function () {
this.__xbRefRemoved = true;
if (this._xbTimer) { clearTimeout(this._xbTimer); this._xbTimer = null; }
if (origRem) origRem.apply(this, arguments);
};
// ── 启动 ──────────────────────────────────────────────────────────
setTimeout(() => {
// 首次不预设已应用值,让第一轮稳定逻辑把选中状态强制落一次
self.doStablization?.();
}, 200);
}