Files

1133 lines
46 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.
/**
* 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 = `
<div style="color:#e0e0e0;margin-bottom:12px;font-size:14px;">自定义快捷键</div>
<div style="color:#888;font-size:12px;margin-bottom:8px;">当前: <span id="mbnl-cur-key" style="color:#4a9eff;">${shortcut}</span></div>
<input type="text" id="mbnl-key-input" readonly placeholder="按下要设置的按键..."
style="width:100%;box-sizing:border-box;padding:10px;background:#1a1a1a;border:1px solid #444;color:#fff;border-radius:4px;margin-bottom:12px;font-size:14px;">
<div style="display:flex;gap:8px;justify-content:flex-end;">
<button id="mbnl-btn-reset" style="padding:8px 16px;background:#444;color:#fff;border:none;border-radius:4px;cursor:pointer;">恢复默认</button>
<button id="mbnl-btn-ok" style="padding:8px 16px;background:#4a9eff;color:#fff;border:none;border-radius:4px;cursor:pointer;">确定</button>
<button id="mbnl-btn-cancel" style="padding:8px 16px;background:#555;color:#fff;border:none;border-radius:4px;cursor:pointer;">取消</button>
</div>
`;
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 = `
<div style="color:#e0e0e0;margin-bottom:12px;font-size:14px;">自定义展开/收缩菜单快捷键</div>
<div style="color:#888;font-size:12px;margin-bottom:8px;">当前: <span id="mbnl-expand-cur-key" style="color:#4a9eff;">${shortcut}</span></div>
<input type="text" id="mbnl-expand-key-input" readonly placeholder="按下要设置的按键..."
style="width:100%;box-sizing:border-box;padding:10px;background:#1a1a1a;border:1px solid #444;color:#fff;border-radius:4px;margin-bottom:12px;font-size:14px;">
<div style="display:flex;gap:8px;justify-content:flex-end;">
<button id="mbnl-expand-btn-reset" style="padding:8px 16px;background:#444;color:#fff;border:none;border-radius:4px;cursor:pointer;">恢复默认</button>
<button id="mbnl-expand-btn-ok" style="padding:8px 16px;background:#4a9eff;color:#fff;border:none;border-radius:4px;cursor:pointer;">确定</button>
<button id="mbnl-expand-btn-cancel" style="padding:8px 16px;background:#555;color:#fff;border:none;border-radius:4px;cursor:pointer;">取消</button>
</div>
`;
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;
};
},
});