/**
* ComfyUI-MechaBaby-NodeLayout
* 节点布局辅助 - 在界面上帮助排列和对齐节点
* 不直接修改工作流,仅辅助前端界面操作
*/
import { app } from "../../../scripts/app.js";
const STORAGE_KEY = "mechababy.nodeLayout.shortcut";
const DEFAULT_SHORTCUT = "F8";
const STORAGE_KEY_EXPAND = "mechababy.nodeLayout.expandShortcut";
const DEFAULT_SHORTCUT_EXPAND = "F9";
const LAYOUT_ICON = "▣"; // 布局图标
const MARGIN = 50;
const MIN_NODE_WIDTH = 200;
const MIN_NODE_HEIGHT = 80;
const ABS_MIN_WIDTH = 40;
const ABS_MIN_HEIGHT = 30;
function getTargetNodes(graph) {
const selected = Object.values(app.canvas?.selected_nodes || {});
if (selected.length > 0) return selected;
return graph._nodes || [];
}
function ensureNodeSize(node) {
if (!node.size || node.size[0] < 10 || node.size[1] < 10) {
node.size = node.size || [MIN_NODE_WIDTH, MIN_NODE_HEIGHT];
node.size[0] = Math.max(node.size[0] || MIN_NODE_WIDTH, MIN_NODE_WIDTH);
node.size[1] = Math.max(node.size[1] || MIN_NODE_HEIGHT, MIN_NODE_HEIGHT);
}
}
function redrawCanvas() {
if (app.canvas) app.canvas.setDirty(true, true);
}
/** 收缩节点:隐藏节点内容,仅显示标题栏 */
function collapseNodes(graph, nodes) {
if (!nodes || nodes.length === 0) return;
nodes.forEach(node => {
if (!node) return;
node.flags = node.flags || {};
node.flags.collapsed = true;
if (typeof node.setDirtyRect === "function") node.setDirtyRect();
});
redrawCanvas();
}
/** 展开节点:显示节点完整内容 */
function expandNodes(graph, nodes) {
if (!nodes || nodes.length === 0) return;
nodes.forEach(node => {
if (!node) return;
node.flags = node.flags || {};
node.flags.collapsed = false;
if (typeof node.setDirtyRect === "function") node.setDirtyRect();
});
redrawCanvas();
}
// 快捷键配置
function getShortcut() {
try {
const s = localStorage.getItem(STORAGE_KEY);
if (s && s.length > 0) return s;
} catch (_) {}
return DEFAULT_SHORTCUT;
}
function setShortcut(key) {
try {
localStorage.setItem(STORAGE_KEY, key || DEFAULT_SHORTCUT);
} catch (_) {}
}
function getShortcutExpand() {
try {
const s = localStorage.getItem(STORAGE_KEY_EXPAND);
if (s && s.length > 0) return s;
} catch (_) {}
return DEFAULT_SHORTCUT_EXPAND;
}
function setShortcutExpand(key) {
try {
localStorage.setItem(STORAGE_KEY_EXPAND, key || DEFAULT_SHORTCUT_EXPAND);
} catch (_) {}
}
function showShortcutDialog(onSaved) {
window._mbnlShortcutDialogOpen = true;
const shortcut = getShortcut();
const wrap = document.createElement("div");
wrap.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:100000;";
const box = document.createElement("div");
box.style.cssText = "background:#2a2a2a;padding:20px;border-radius:8px;min-width:320px;box-shadow:0 4px 20px rgba(0,0,0,0.5);";
box.innerHTML = `
自定义快捷键
当前: ${shortcut}
`;
wrap.appendChild(box);
document.body.appendChild(wrap);
const input = box.querySelector("#mbnl-key-input");
const curSpan = box.querySelector("#mbnl-cur-key");
const close = () => {
window._mbnlShortcutDialogOpen = false;
document.body.removeChild(wrap);
document.removeEventListener("keydown", keyHandler, true);
};
let captured = null;
const keyHandler = (e) => {
e.preventDefault();
e.stopPropagation();
if (e.key === "Escape") {
close();
return;
}
const k = e.key.length === 1 ? e.key.toUpperCase() : e.key;
const parts = [];
if (e.ctrlKey || e.metaKey) parts.push("Ctrl");
if (e.altKey) parts.push("Alt");
if (e.shiftKey) parts.push("Shift");
parts.push(k);
captured = parts.join("+");
input.value = captured;
};
document.addEventListener("keydown", keyHandler, true);
setTimeout(() => input.focus(), 100);
box.querySelector("#mbnl-btn-reset").onclick = () => {
captured = DEFAULT_SHORTCUT;
setShortcut(DEFAULT_SHORTCUT);
curSpan.textContent = DEFAULT_SHORTCUT;
input.value = DEFAULT_SHORTCUT;
if (typeof onSaved === "function") onSaved();
};
box.querySelector("#mbnl-btn-ok").onclick = () => {
if (captured) {
setShortcut(captured);
curSpan.textContent = captured;
if (typeof onSaved === "function") onSaved();
}
close();
};
box.querySelector("#mbnl-btn-cancel").onclick = close;
}
function showShortcutDialogExpand(onSaved) {
window._mbnlExpandShortcutDialogOpen = true;
const shortcut = getShortcutExpand();
const wrap = document.createElement("div");
wrap.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:100000;";
const box = document.createElement("div");
box.style.cssText = "background:#2a2a2a;padding:20px;border-radius:8px;min-width:320px;box-shadow:0 4px 20px rgba(0,0,0,0.5);";
box.innerHTML = `
自定义展开/收缩菜单快捷键
当前: ${shortcut}
`;
wrap.appendChild(box);
document.body.appendChild(wrap);
const input = box.querySelector("#mbnl-expand-key-input");
const curSpan = box.querySelector("#mbnl-expand-cur-key");
const close = () => {
window._mbnlExpandShortcutDialogOpen = false;
document.body.removeChild(wrap);
document.removeEventListener("keydown", keyHandler, true);
};
let captured = null;
const keyHandler = (e) => {
e.preventDefault();
e.stopPropagation();
if (e.key === "Escape") {
close();
return;
}
const k = e.key.length === 1 ? e.key.toUpperCase() : e.key;
const parts = [];
if (e.ctrlKey || e.metaKey) parts.push("Ctrl");
if (e.altKey) parts.push("Alt");
if (e.shiftKey) parts.push("Shift");
parts.push(k);
captured = parts.join("+");
input.value = captured;
};
document.addEventListener("keydown", keyHandler, true);
setTimeout(() => input.focus(), 100);
box.querySelector("#mbnl-expand-btn-reset").onclick = () => {
captured = DEFAULT_SHORTCUT_EXPAND;
setShortcutExpand(DEFAULT_SHORTCUT_EXPAND);
curSpan.textContent = DEFAULT_SHORTCUT_EXPAND;
input.value = DEFAULT_SHORTCUT_EXPAND;
if (typeof onSaved === "function") onSaved();
};
box.querySelector("#mbnl-expand-btn-ok").onclick = () => {
if (captured) {
setShortcutExpand(captured);
curSpan.textContent = captured;
if (typeof onSaved === "function") onSaved();
}
close();
};
box.querySelector("#mbnl-expand-btn-cancel").onclick = close;
}
/** 显示排列方式选择菜单(F8 快捷键触发)- 使用自定义 DOM 菜单确保兼容性 */
function showArrangementMenu() {
if (document.getElementById("mbnl-arrangement-menu")) return;
const graph = app?.graph;
if (!graph) return;
const opts = [
{ content: "按类型分组", callback: () => arrangeByType(graph) },
{ content: "按执行顺序", callback: () => arrangeByExecutionOrder(graph) },
{ content: "蜘蛛网排列(选中/最后节点为中心)", callback: () => arrangeSpiderWeb(graph) },
{ content: "层级列排列(选中/最后节点为中心)", callback: () => arrangeLevelColumns(graph) },
{ content: "网格排列", callback: () => arrangeGrid(graph) },
{ content: "内置排列 (左)", callback: () => { if (graph.arrange) graph.arrange(); redrawCanvas(); } },
];
// 移除可能已存在的旧菜单
const old = document.getElementById("mbnl-arrangement-menu");
if (old) old.remove();
const menu = document.createElement("div");
menu.id = "mbnl-arrangement-menu";
menu.style.cssText = "position:fixed;z-index:100001;background:#2a2a2a;border:1px solid #444;border-radius:6px;box-shadow:0 4px 20px rgba(0,0,0,0.5);min-width:220px;padding:4px 0;font-size:13px;";
const rect = app.canvas?.canvas?.getBoundingClientRect?.();
const cx = rect ? rect.left + rect.width / 2 : window.innerWidth / 2;
const cy = rect ? rect.top + rect.height / 2 : window.innerHeight / 2;
menu.style.left = (cx - 110) + "px";
menu.style.top = (cy - 80) + "px";
opts.forEach((item) => {
const el = document.createElement("div");
el.style.cssText = "padding:8px 16px;cursor:pointer;color:#e0e0e0;white-space:nowrap;";
el.textContent = item.content;
el.onmouseenter = () => { el.style.background = "#3a3a3a"; };
el.onmouseleave = () => { el.style.background = ""; };
el.onclick = (e) => {
e.stopPropagation();
item.callback();
menu.remove();
};
menu.appendChild(el);
});
const close = () => menu.remove();
menu.addEventListener("click", (e) => e.stopPropagation());
document.addEventListener("click", close, { once: true });
document.addEventListener("contextmenu", close, { once: true });
document.addEventListener("keydown", (e) => { if (e.key === "Escape") close(); }, { once: true });
document.body.appendChild(menu);
}
/** 显示展开/收缩菜单(F9 快捷键触发)*/
function showExpandCollapseMenu() {
if (document.getElementById("mbnl-expand-menu")) return;
const graph = app?.graph;
if (!graph) return;
const nodes = graph._nodes || [];
const targetNodes = getTargetNodes(graph);
const opts = [
{ content: "收缩选中节点", callback: () => { if (targetNodes.length >= 1) collapseNodes(graph, targetNodes); } },
{ content: "展开选中节点", callback: () => { if (targetNodes.length >= 1) expandNodes(graph, targetNodes); } },
{ content: "收缩全部节点", callback: () => collapseNodes(graph, nodes) },
{ content: "展开全部节点", callback: () => expandNodes(graph, nodes) },
];
const old = document.getElementById("mbnl-expand-menu");
if (old) old.remove();
const menu = document.createElement("div");
menu.id = "mbnl-expand-menu";
menu.style.cssText = "position:fixed;z-index:100001;background:#2a2a2a;border:1px solid #444;border-radius:6px;box-shadow:0 4px 20px rgba(0,0,0,0.5);min-width:220px;padding:4px 0;font-size:13px;";
const rect = app.canvas?.canvas?.getBoundingClientRect?.();
const cx = rect ? rect.left + rect.width / 2 : window.innerWidth / 2;
const cy = rect ? rect.top + rect.height / 2 : window.innerHeight / 2;
menu.style.left = (cx - 110) + "px";
menu.style.top = (cy - 100) + "px";
opts.forEach((item) => {
if (!item) {
const sep = document.createElement("div");
sep.style.cssText = "height:1px;background:#444;margin:4px 0;";
menu.appendChild(sep);
return;
}
const el = document.createElement("div");
el.style.cssText = "padding:8px 16px;cursor:pointer;color:#e0e0e0;white-space:nowrap;";
el.textContent = item.content;
el.onmouseenter = () => { el.style.background = "#3a3a3a"; };
el.onmouseleave = () => { el.style.background = ""; };
el.onclick = (e) => {
e.stopPropagation();
item.callback();
menu.remove();
};
menu.appendChild(el);
});
const close = () => menu.remove();
menu.addEventListener("click", (e) => e.stopPropagation());
document.addEventListener("click", close, { once: true });
document.addEventListener("contextmenu", close, { once: true });
document.addEventListener("keydown", (e) => { if (e.key === "Escape") close(); }, { once: true });
document.body.appendChild(menu);
}
/**
* 获取节点的最小尺寸(由 computeSize 计算,基于节点 inputs/outputs/widgets)
* 返回 [width, height],确保节点上所有属性可完全显示
*/
function getNodeMinSize(node) {
let sz = null;
try {
if (typeof node.computeSize === "function") {
// 部分节点接受当前 size 作为参数(如 width 影响 widget 换行后的 height)
const curSize = node.size || [MIN_NODE_WIDTH, MIN_NODE_HEIGHT];
const arg = curSize[0] != null && curSize[1] != null ? [curSize[0], curSize[1]] : undefined;
sz = arg ? node.computeSize(arg) : node.computeSize();
}
} catch (e) {
/* ignore */
}
if (!sz || (Array.isArray(sz) && sz.length < 2)) {
// 无 computeSize 时按 inputs/outputs/widgets 估算
const slotH = (typeof LiteGraph !== "undefined" && LiteGraph.NODE_SLOT_HEIGHT) || 24;
const widgetH = (typeof LiteGraph !== "undefined" && LiteGraph.NODE_WIDGET_HEIGHT) || 20;
const inputRows = Math.max(node.inputs?.length || 0, node.outputs?.length || 0);
let h = slotH * inputRows + 20;
(node.widgets || []).forEach(w => {
if (w?.computeSize && typeof w.computeSize === "function") {
try {
const ws = w.computeSize(node.size?.[0] || MIN_NODE_WIDTH);
h += (Array.isArray(ws) ? ws[1] : ws?.height || widgetH) + 4;
} catch (_) {
h += widgetH + 4;
}
} else {
h += widgetH + 4;
}
});
sz = [Math.max(ABS_MIN_WIDTH, node.size?.[0] || MIN_NODE_WIDTH), Math.max(ABS_MIN_HEIGHT, h)];
}
const w = Array.isArray(sz) ? Number(sz[0]) : Number(sz?.width ?? sz?.[0]);
const h = Array.isArray(sz) ? Number(sz[1]) : Number(sz?.height ?? sz?.[1]);
return [
Math.max(ABS_MIN_WIDTH, w || ABS_MIN_WIDTH),
Math.max(ABS_MIN_HEIGHT, h || ABS_MIN_HEIGHT),
];
}
/** 缩小到最小高度(保持宽度,高度由节点内容决定) */
function shrinkToMinHeight(graph) {
const nodes = getTargetNodes(graph);
nodes.forEach(node => {
const [minW, minH] = getNodeMinSize(node);
const newH = minH;
const curW = node.size?.[0] ?? minW;
if (node.setSize) node.setSize([curW, newH]);
else {
node.size = node.size || [curW, newH];
node.size[1] = newH;
}
});
redrawCanvas();
}
/** 缩小到最小宽度(保持高度,宽度由节点内容决定) */
function shrinkToMinWidth(graph) {
const nodes = getTargetNodes(graph);
nodes.forEach(node => {
const [minW] = getNodeMinSize(node);
const curH = node.size?.[1] ?? MIN_NODE_HEIGHT;
if (node.setSize) node.setSize([minW, curH]);
else {
node.size = node.size || [minW, curH];
node.size[0] = minW;
}
});
redrawCanvas();
}
/** 缩小到最小尺寸(宽高均由节点内容决定,确保属性完全显示) */
function shrinkToMinSize(graph) {
const nodes = getTargetNodes(graph);
nodes.forEach(node => {
const [minW, minH] = getNodeMinSize(node);
if (node.setSize) node.setSize([minW, minH]);
else {
node.size = [minW, minH];
}
});
redrawCanvas();
}
// 按节点类型分组排列
function arrangeByType(graph) {
const nodes = getTargetNodes(graph);
if (nodes.length === 0) return;
const byType = {};
nodes.forEach(node => {
const type = node.type || "Unknown";
if (!byType[type]) byType[type] = [];
byType[type].push(node);
});
const types = Object.keys(byType).sort();
let x = MARGIN;
types.forEach(type => {
const group = byType[type];
group.sort((a, b) => (a.pos?.[1] ?? 0) - (b.pos?.[1] ?? 0));
let y = MARGIN;
let maxWidth = 0;
group.forEach(node => {
ensureNodeSize(node);
maxWidth = Math.max(maxWidth, node.size[0]);
});
group.forEach(node => {
node.pos[0] = x;
node.pos[1] = y;
y += node.size[1] + MARGIN;
});
// 同组内右对齐
group.forEach(node => {
node.pos[0] = x + maxWidth - node.size[0];
});
x += maxWidth + MARGIN;
});
redrawCanvas();
}
// 按执行顺序排列(从左到右)
function arrangeByExecutionOrder(graph) {
let nodes;
try {
nodes = graph.computeExecutionOrder ? graph.computeExecutionOrder(false, true) : graph._nodes || [];
} catch (e) {
nodes = graph._nodes || [];
}
const targetIds = new Set(getTargetNodes(graph).map(n => n.id));
nodes = nodes.filter(n => targetIds.has(n.id));
if (nodes.length === 0) return;
const columns = {};
nodes.forEach(node => {
const level = node._level ?? 0;
if (!columns[level]) columns[level] = [];
columns[level].push(node);
});
const levels = Object.keys(columns).map(Number).sort((a, b) => a - b);
let x = MARGIN;
levels.forEach(level => {
const column = columns[level];
column.sort((a, b) => {
const as = !(a.type === "SaveImage" || a.type === "PreviewImage");
const bs = !(b.type === "SaveImage" || b.type === "PreviewImage");
if (as !== bs) return as - bs;
if ((a.inputs?.length || 0) !== (b.inputs?.length || 0)) return (a.inputs?.length || 0) - (b.inputs?.length || 0);
return (a.outputs?.length || 0) - (b.outputs?.length || 0);
});
let maxWidth = 100;
let y = MARGIN;
column.forEach(node => {
ensureNodeSize(node);
maxWidth = Math.max(maxWidth, node.size[0]);
});
column.forEach((node, j) => {
node.pos[0] = x;
node.pos[1] = y;
y += node.size[1] + MARGIN + LiteGraph.NODE_TITLE_HEIGHT;
});
column.forEach(node => {
node.pos[0] += maxWidth - node.size[0];
});
x += maxWidth + MARGIN;
});
redrawCanvas();
}
/** 蜘蛛网/脑图排列:以选中节点为中心,递归向左右展开,每个分支独立垂直排列(上次修改前逻辑) */
function arrangeSpiderWeb(graph) {
const nodes = graph._nodes || [];
if (nodes.length === 0) return;
const selected = Object.values(app.canvas?.selected_nodes || {});
let center = selected.length === 1 ? selected[0] : null;
if (!center) {
const outputs = nodes.filter(n => (n.constructor?.nodeData?.output_node || n.type === "SaveImage" || n.type === "PreviewImage"));
center = outputs.length > 0 ? outputs[outputs.length - 1] : nodes[nodes.length - 1];
}
const links = graph.links || {};
const nodeById = (id) => nodes.find(n => String(n.id) === String(id));
nodes.forEach(n => ensureNodeSize(n));
const getInputNodesOrdered = (node) => {
const result = [];
const seen = new Set();
(node.inputs || []).forEach((inp, idx) => {
const linkId = typeof inp === "object" ? inp?.link : null;
if (linkId != null && links[linkId]) {
const src = nodeById(links[linkId].origin_id);
if (src && !seen.has(src.id)) {
seen.add(src.id);
result.push({ node: src, slotIndex: idx });
}
}
});
return result.sort((a, b) => a.slotIndex - b.slotIndex).map(x => x.node);
};
const getOutputNodesOrdered = (node) => {
const seen = new Set();
const result = [];
(node.outputs || []).forEach((out, idx) => {
const linkIds = typeof out === "object" ? out?.links || [] : [];
linkIds.forEach(linkId => {
if (links[linkId] && !seen.has(links[linkId].target_id)) {
seen.add(links[linkId].target_id);
const tgt = nodeById(links[linkId].target_id);
if (tgt) result.push({ node: tgt, slotIndex: idx });
}
});
});
return result.sort((a, b) => a.slotIndex - b.slotIndex).map(x => x.node);
};
const maxNodeW = Math.max(MIN_NODE_WIDTH, ...nodes.map(n => n.size?.[0] || MIN_NODE_WIDTH));
const H_GAP = maxNodeW + MARGIN;
const V_GAP = MARGIN + 15;
const placed = new Set();
function layoutLeft(node, x, y) {
if (!node || placed.has(node.id)) return { h: 0, w: 0 };
placed.add(node.id);
const w = node.size?.[0] || MIN_NODE_WIDTH;
const h = node.size?.[1] || MIN_NODE_HEIGHT;
const children = getInputNodesOrdered(node);
if (children.length === 0) {
node.pos[0] = x - w;
node.pos[1] = y;
return { h, w };
}
let curY = y;
let maxChildW = 0;
for (const c of children) {
const box = layoutLeft(c, x - w - H_GAP, curY);
maxChildW = Math.max(maxChildW, box.w);
curY += box.h + V_GAP;
}
const totalChildH = curY - y - V_GAP;
const myY = y + totalChildH / 2 - h / 2;
node.pos[0] = x - w;
node.pos[1] = Math.max(y, myY);
return { h: Math.max(totalChildH, h), w: w + H_GAP + maxChildW };
}
function layoutRight(node, x, y) {
if (!node || placed.has(node.id)) return { h: 0, w: 0 };
placed.add(node.id);
const w = node.size?.[0] || MIN_NODE_WIDTH;
const h = node.size?.[1] || MIN_NODE_HEIGHT;
const children = getOutputNodesOrdered(node);
if (children.length === 0) {
node.pos[0] = x;
node.pos[1] = y;
return { h, w };
}
let curY = y;
let maxChildW = 0;
for (const c of children) {
const box = layoutRight(c, x + w + H_GAP, curY);
maxChildW = Math.max(maxChildW, box.w);
curY += box.h + V_GAP;
}
const totalChildH = curY - y - V_GAP;
const myY = y + totalChildH / 2 - h / 2;
node.pos[0] = x;
node.pos[1] = Math.max(y, myY);
return { h: Math.max(totalChildH, h), w: w + H_GAP + maxChildW };
}
const centerW = center.size?.[0] || MIN_NODE_WIDTH;
const centerH = center.size?.[1] || MIN_NODE_HEIGHT;
let leftY = 0;
const leftInputs = getInputNodesOrdered(center);
for (const c of leftInputs) {
const box = layoutLeft(c, -H_GAP, leftY);
leftY += box.h + V_GAP;
}
const leftTotalH = leftY > 0 ? leftY - V_GAP : 0;
let rightY = 0;
const rightOutputs = getOutputNodesOrdered(center);
for (const c of rightOutputs) {
const box = layoutRight(c, centerW + H_GAP, rightY);
rightY += box.h + V_GAP;
}
const rightTotalH = rightY > 0 ? rightY - V_GAP : 0;
const totalH = Math.max(leftTotalH, centerH, rightTotalH);
center.pos[0] = 0;
center.pos[1] = totalH / 2 - centerH / 2;
placed.add(center.id);
const allPlaced = [...placed].map(id => nodeById(id)).filter(Boolean);
const minX = Math.min(...allPlaced.map(n => n.pos[0] ?? 0));
const minY = Math.min(...allPlaced.map(n => n.pos[1] ?? 0));
allPlaced.forEach(n => {
n.pos[0] = (n.pos[0] ?? 0) - minX + MARGIN;
n.pos[1] = (n.pos[1] ?? 0) - minY + MARGIN;
});
redrawCanvas();
}
/** 层级列排列:以选中节点为中心,按层级分列,左侧输入链、右侧输出链,同列节点上下留隙、不重叠 */
function arrangeLevelColumns(graph) {
const nodes = graph._nodes || [];
if (nodes.length === 0) return;
const selected = Object.values(app.canvas?.selected_nodes || {});
let center = selected.length === 1 ? selected[0] : null;
if (!center) {
const outputs = nodes.filter(n => (n.constructor?.nodeData?.output_node || n.type === "SaveImage" || n.type === "PreviewImage"));
center = outputs.length > 0 ? outputs[outputs.length - 1] : nodes[nodes.length - 1];
}
const links = graph.links || {};
const nodeById = (id) => nodes.find(n => String(n.id) === String(id));
nodes.forEach(n => ensureNodeSize(n));
const getInputNodesOrdered = (node) => {
const result = [];
const seen = new Set();
(node.inputs || []).forEach((inp, idx) => {
const linkId = typeof inp === "object" ? inp?.link : null;
if (linkId != null && links[linkId]) {
const srcId = links[linkId].origin_id;
const src = nodeById(srcId);
if (src && !seen.has(String(srcId))) {
seen.add(String(srcId));
result.push({ node: src, slotIndex: idx });
}
}
});
return result.sort((a, b) => a.slotIndex - b.slotIndex).map(x => x.node);
};
const getOutputNodesOrdered = (node) => {
const seen = new Set();
const result = [];
(node.outputs || []).forEach((out, idx) => {
const linkIds = typeof out === "object" ? out?.links || [] : [];
linkIds.forEach(linkId => {
if (links[linkId]) {
const tgtId = links[linkId].target_id;
if (!seen.has(String(tgtId))) {
seen.add(String(tgtId));
const tgt = nodeById(tgtId);
if (tgt) result.push({ node: tgt, slotIndex: idx });
}
}
});
});
return result.sort((a, b) => a.slotIndex - b.slotIndex).map(x => x.node);
};
const H_GAP = 70;
const V_GAP = 35;
const leftLevels = new Map();
const rightLevels = new Map();
function collectLeftLevels(node, level) {
if (!node) return;
const cur = leftLevels.get(String(node.id));
if (cur != null && cur <= level) return;
leftLevels.set(String(node.id), level);
getInputNodesOrdered(node).forEach(c => collectLeftLevels(c, level - 1));
}
function collectRightLevels(node, level) {
if (!node) return;
const cur = rightLevels.get(String(node.id));
if (cur != null && cur <= level) return;
rightLevels.set(String(node.id), level);
getOutputNodesOrdered(node).forEach(c => collectRightLevels(c, level + 1));
}
collectLeftLevels(center, 0);
collectRightLevels(center, 0);
const allInvolved = new Set([String(center.id)]);
leftLevels.forEach((_, id) => allInvolved.add(id));
rightLevels.forEach((_, id) => allInvolved.add(id));
const centerW = center.size?.[0] || MIN_NODE_WIDTH;
const centerH = center.size?.[1] || MIN_NODE_HEIGHT;
const maxNodeW = Math.max(centerW, ...nodes.filter(n => allInvolved.has(String(n.id))).map(n => n.size?.[0] || MIN_NODE_WIDTH));
const leftMinLevel = Math.min(...leftLevels.values(), 0);
const rightMaxLevel = Math.max(...rightLevels.values(), 0);
center.pos[0] = 0;
center.pos[1] = 0;
function getSlotInParentInput(parent, child) {
const inpNodes = getInputNodesOrdered(parent);
if (!inpNodes.includes(child)) return 0;
return inpNodes.indexOf(child);
}
function getSlotInParentOutput(parent, child) {
const outNodes = getOutputNodesOrdered(parent);
if (!outNodes.includes(child)) return 0;
return outNodes.indexOf(child);
}
function findParentLeft(child) {
for (const n of nodes) {
if (getInputNodesOrdered(n).some(x => String(x.id) === String(child.id))) return n;
}
return null;
}
function findParentRight(child) {
for (const n of nodes) {
if (getOutputNodesOrdered(n).some(x => String(x.id) === String(child.id))) return n;
}
return null;
}
for (let level = leftMinLevel; level < 0; level++) {
const levelNodesRaw = nodes.filter(n => leftLevels.get(String(n.id)) === level);
const levelNodes = [...new Map(levelNodesRaw.map(n => [String(n.id), n])).values()];
levelNodes.sort((a, b) => {
const pa = level === -1 ? center : findParentLeft(a);
const pb = level === -1 ? center : findParentLeft(b);
if (!pa || !pb) return 0;
if (String(pa.id) !== String(pb.id)) {
if (level === -1) return 0;
const paL = leftLevels.get(String(pa.id)), pbL = leftLevels.get(String(pb.id));
if (paL !== pbL) return paL - pbL;
const inpOrd = getInputNodesOrdered(center);
const paIdx = inpOrd.findIndex(x => String(x.id) === String(pa.id));
const pbIdx = inpOrd.findIndex(x => String(x.id) === String(pb.id));
if (paIdx >= 0 && pbIdx >= 0 && paIdx !== pbIdx) return paIdx - pbIdx;
return String(pa.id).localeCompare(String(pb.id));
}
return getSlotInParentInput(pa, a) - getSlotInParentInput(pa, b);
});
const colRightEdge = level * (maxNodeW + H_GAP);
let y = 0;
levelNodes.forEach(n => {
const w = Math.max(MIN_NODE_WIDTH, n.size?.[0] || MIN_NODE_WIDTH);
const h = Math.max(MIN_NODE_HEIGHT, n.size?.[1] || MIN_NODE_HEIGHT);
n.pos[0] = colRightEdge - w;
n.pos[1] = y;
y += h + V_GAP;
});
}
for (let level = 1; level <= rightMaxLevel; level++) {
const levelNodesRaw = nodes.filter(n => rightLevels.get(String(n.id)) === level && !leftLevels.has(String(n.id)));
const levelNodes = [...new Map(levelNodesRaw.map(n => [String(n.id), n])).values()];
levelNodes.sort((a, b) => {
const pa = level === 1 ? center : findParentRight(a);
const pb = level === 1 ? center : findParentRight(b);
if (!pa || !pb) return 0;
if (String(pa.id) !== String(pb.id)) {
if (level === 1) return 0;
const outOrd = getOutputNodesOrdered(center);
const paIdx = outOrd.findIndex(x => String(x.id) === String(pa.id));
const pbIdx = outOrd.findIndex(x => String(x.id) === String(pb.id));
if (paIdx >= 0 && pbIdx >= 0 && paIdx !== pbIdx) return paIdx - pbIdx;
return String(pa.id).localeCompare(String(pb.id));
}
return getSlotInParentOutput(pa, a) - getSlotInParentOutput(pa, b);
});
const colX = centerW + H_GAP + (level - 1) * (maxNodeW + H_GAP);
let y = 0;
levelNodes.forEach(n => {
const h = Math.max(MIN_NODE_HEIGHT, n.size?.[1] || MIN_NODE_HEIGHT);
n.pos[0] = colX;
n.pos[1] = y;
y += h + V_GAP;
});
}
const totalLeftH = Math.max(0, ...nodes.filter(n => leftLevels.has(String(n.id))).map(n => (n.pos?.[1] ?? 0) + (n.size?.[1] || MIN_NODE_HEIGHT)));
const totalRightH = Math.max(0, ...nodes.filter(n => rightLevels.has(String(n.id))).map(n => (n.pos?.[1] ?? 0) + (n.size?.[1] || MIN_NODE_HEIGHT)));
const totalH = Math.max(totalLeftH, centerH, totalRightH);
center.pos[1] = totalH / 2 - centerH / 2;
const minX = Math.min(0, ...nodes.filter(n => allInvolved.has(String(n.id))).map(n => n.pos[0] ?? 0));
const minY = Math.min(0, ...nodes.filter(n => allInvolved.has(String(n.id))).map(n => n.pos[1] ?? 0));
nodes.filter(n => allInvolved.has(String(n.id))).forEach(n => {
n.pos[0] = (n.pos[0] ?? 0) - minX + MARGIN;
n.pos[1] = (n.pos[1] ?? 0) - minY + MARGIN;
});
redrawCanvas();
}
// 网格布局 - 等间距排列
function arrangeGrid(graph) {
const nodes = getTargetNodes(graph);
if (nodes.length === 0) return;
nodes.forEach(ensureNodeSize);
const maxW = Math.max(...nodes.map(n => n.size[0]));
const maxH = Math.max(...nodes.map(n => n.size[1]));
const cols = Math.ceil(Math.sqrt(nodes.length));
let x = MARGIN, y = MARGIN;
let col = 0;
nodes.forEach(node => {
node.pos[0] = x;
node.pos[1] = y;
col++;
if (col >= cols) {
col = 0;
x = MARGIN;
y += maxH + MARGIN;
} else {
x += maxW + MARGIN;
}
});
redrawCanvas();
}
// 对齐与分布工具
const alignOps = {
alignLeft: (nodes) => {
const minX = Math.min(...nodes.map(n => n.pos[0]));
nodes.forEach(n => { n.pos[0] = minX; });
},
alignRight: (nodes) => {
const maxX = Math.max(...nodes.map(n => n.pos[0] + (n.size?.[0] || MIN_NODE_WIDTH)));
nodes.forEach(n => { n.pos[0] = maxX - (n.size?.[0] || MIN_NODE_WIDTH); });
},
alignTop: (nodes) => {
const minY = Math.min(...nodes.map(n => n.pos[1]));
nodes.forEach(n => { n.pos[1] = minY; });
},
alignBottom: (nodes) => {
const maxY = Math.max(...nodes.map(n => n.pos[1] + (n.size?.[1] || MIN_NODE_HEIGHT)));
nodes.forEach(n => { n.pos[1] = maxY - (n.size?.[1] || MIN_NODE_HEIGHT); });
},
alignCenterH: (nodes) => {
const minY = Math.min(...nodes.map(n => n.pos[1]));
const maxY = Math.max(...nodes.map(n => n.pos[1] + (n.size?.[1] || MIN_NODE_HEIGHT)));
const cy = (minY + maxY) / 2;
nodes.forEach(n => { n.pos[1] = cy - (n.size?.[1] || MIN_NODE_HEIGHT) / 2; });
},
alignCenterV: (nodes) => {
const minX = Math.min(...nodes.map(n => n.pos[0]));
const maxX = Math.max(...nodes.map(n => n.pos[0] + (n.size?.[0] || MIN_NODE_WIDTH)));
const cx = (minX + maxX) / 2;
nodes.forEach(n => { n.pos[0] = cx - (n.size?.[0] || MIN_NODE_WIDTH) / 2; });
},
equalWidth: (nodes) => {
if (nodes.length === 0) return;
const w = Math.max(...nodes.map(n => n.size?.[0] || MIN_NODE_WIDTH), MIN_NODE_WIDTH);
nodes.forEach(n => { n.size = n.size || [MIN_NODE_WIDTH, MIN_NODE_HEIGHT]; n.size[0] = w; });
},
equalHeight: (nodes) => {
if (nodes.length === 0) return;
const h = Math.max(...nodes.map(n => n.size?.[1] || MIN_NODE_HEIGHT), MIN_NODE_HEIGHT);
nodes.forEach(n => { n.size = n.size || [MIN_NODE_WIDTH, MIN_NODE_HEIGHT]; n.size[1] = h; });
},
distributeH: (nodes) => {
if (nodes.length < 2) return;
nodes.sort((a, b) => a.pos[0] - b.pos[0]);
const min = Math.min(...nodes.map(n => n.pos[0]));
const max = Math.max(...nodes.map(n => n.pos[0] + (n.size?.[0] || MIN_NODE_WIDTH)));
const total = nodes.reduce((s, n) => s + (n.size?.[0] || MIN_NODE_WIDTH), 0);
const gap = (max - min - total) / (nodes.length - 1);
let cur = min;
nodes.forEach(n => {
n.pos[0] = cur;
cur += (n.size?.[0] || MIN_NODE_WIDTH) + gap;
});
},
distributeV: (nodes) => {
if (nodes.length < 2) return;
nodes.sort((a, b) => a.pos[1] - b.pos[1]);
const min = Math.min(...nodes.map(n => n.pos[1]));
const max = Math.max(...nodes.map(n => n.pos[1] + (n.size?.[1] || MIN_NODE_HEIGHT)));
const total = nodes.reduce((s, n) => s + (n.size?.[1] || MIN_NODE_HEIGHT), 0);
const gap = (max - min - total) / (nodes.length - 1);
let cur = min;
nodes.forEach(n => {
n.pos[1] = cur;
cur += (n.size?.[1] || MIN_NODE_HEIGHT) + gap;
});
},
};
function checkShortcutMatch(e, shortcut) {
if (!shortcut) return false;
const parts = shortcut.split("+").map((p) => p.trim());
const key = parts.pop() || "";
const ctrlOk = parts.includes("Ctrl") ? (e.ctrlKey || e.metaKey) : !e.ctrlKey && !e.metaKey;
const altOk = parts.includes("Alt") ? e.altKey : !e.altKey;
const shiftOk = parts.includes("Shift") ? e.shiftKey : !e.shiftKey;
const keyOk = e.key === key || e.key === key.toLowerCase() || e.key === key.toUpperCase();
return ctrlOk && altOk && shiftOk && keyOk;
}
app.registerExtension({
name: "MechaBaby.NodeLayout",
setup(app) {
// 快捷键 - F8 排列菜单 / F9 展开收缩菜单(document + window 双监听确保捕获)
const keyHandler = (e) => {
if (window._mbnlShortcutDialogOpen || window._mbnlExpandShortcutDialogOpen) return;
const tag = document.activeElement?.tagName?.toUpperCase?.();
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
if (checkShortcutMatch(e, getShortcut())) {
e.preventDefault();
e.stopPropagation();
showArrangementMenu();
} else if (checkShortcutMatch(e, getShortcutExpand())) {
e.preventDefault();
e.stopPropagation();
showExpandCollapseMenu();
}
};
document.addEventListener("keydown", keyHandler, true);
window.addEventListener("keydown", keyHandler, true);
const origGetNodeMenuOptions = LGraphCanvas.prototype.getNodeMenuOptions;
if (origGetNodeMenuOptions) {
LGraphCanvas.prototype.getNodeMenuOptions = function (node) {
const opts = origGetNodeMenuOptions.apply(this, arguments) || [];
const graph = app?.graph;
if (!graph || !node) return opts;
const isCollapsed = !!(node.flags && node.flags.collapsed);
opts.push(null, {
content: isCollapsed ? "◀ 展开节点" : "▶ 收缩节点",
callback: () => {
if (isCollapsed) expandNodes(graph, [node]);
else collapseNodes(graph, [node]);
},
});
return opts;
};
}
const orig = LGraphCanvas.prototype.getCanvasMenuOptions;
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
const options = orig?.apply(this, arguments) || [];
const graph = app.graph;
if (!graph) return options;
const shortcutLabel = getShortcut();
const layoutMenu = {
content: `${LAYOUT_ICON} MechaBaby 节点布局`,
className: "mechababy-nodelayout-menu",
submenu: {
options: [
{ content: "─ 排列方式 ─", disabled: true },
{
content: "按类型分组",
callback: () => arrangeByType(graph),
},
{
content: "按执行顺序",
callback: () => arrangeByExecutionOrder(graph),
},
{
content: "蜘蛛网排列(选中/最后节点为中心)",
callback: () => arrangeSpiderWeb(graph),
},
{
content: "层级列排列(选中/最后节点为中心)",
callback: () => arrangeLevelColumns(graph),
},
{
content: "网格排列",
callback: () => arrangeGrid(graph),
},
{
content: "内置排列 (左)",
callback: () => { if (graph.arrange) graph.arrange(); redrawCanvas(); },
},
null,
{ content: "─ 对齐 (选中) ─", disabled: true },
{
content: "左对齐",
callback: () => { const n = getTargetNodes(graph); if (n.length >= 1) alignOps.alignLeft(n); redrawCanvas(); },
},
{
content: "右对齐",
callback: () => { const n = getTargetNodes(graph); if (n.length >= 1) alignOps.alignRight(n); redrawCanvas(); },
},
{
content: "顶部对齐",
callback: () => { const n = getTargetNodes(graph); if (n.length >= 1) alignOps.alignTop(n); redrawCanvas(); },
},
{
content: "底部对齐",
callback: () => { const n = getTargetNodes(graph); if (n.length >= 1) alignOps.alignBottom(n); redrawCanvas(); },
},
{
content: "水平居中",
callback: () => { const n = getTargetNodes(graph); if (n.length >= 1) alignOps.alignCenterH(n); redrawCanvas(); },
},
{
content: "垂直居中",
callback: () => { const n = getTargetNodes(graph); if (n.length >= 1) alignOps.alignCenterV(n); redrawCanvas(); },
},
null,
{ content: "─ 尺寸与分布 ─", disabled: true },
{
content: "缩小到最小高度",
callback: () => shrinkToMinHeight(graph),
},
{
content: "缩小到最小宽度",
callback: () => shrinkToMinWidth(graph),
},
{
content: "缩小到最小尺寸",
callback: () => shrinkToMinSize(graph),
},
null,
{ content: "─ 展开/收缩 ─", disabled: true },
{
content: "收缩选中节点",
callback: () => { const n = getTargetNodes(graph); if (n.length >= 1) collapseNodes(graph, n); },
},
{
content: "展开选中节点",
callback: () => { const n = getTargetNodes(graph); if (n.length >= 1) expandNodes(graph, n); },
},
{
content: "收缩全部节点",
callback: () => collapseNodes(graph, graph._nodes || []),
},
{
content: "展开全部节点",
callback: () => expandNodes(graph, graph._nodes || []),
},
null,
{
content: "等宽",
callback: () => { const n = getTargetNodes(graph); if (n.length >= 1) alignOps.equalWidth(n); redrawCanvas(); },
},
{
content: "等高",
callback: () => { const n = getTargetNodes(graph); if (n.length >= 1) alignOps.equalHeight(n); redrawCanvas(); },
},
{
content: "水平均匀分布",
callback: () => { const n = getTargetNodes(graph); if (n.length >= 2) alignOps.distributeH(n); redrawCanvas(); },
},
{
content: "垂直均匀分布",
callback: () => { const n = getTargetNodes(graph); if (n.length >= 2) alignOps.distributeV(n); redrawCanvas(); },
},
null,
{
content: `⚙ 排列菜单快捷键 (当前: ${shortcutLabel})`,
callback: () => showShortcutDialog(),
},
{
content: `⚙ 展开菜单快捷键 (当前: ${getShortcutExpand()})`,
callback: () => showShortcutDialogExpand(),
},
],
},
};
options.push(null, layoutMenu);
return options;
};
},
});