419 lines
16 KiB
JavaScript
419 lines
16 KiB
JavaScript
// APNext graph layout tools
|
|
//
|
|
// Canvas right-click → "APNext layout" (and the command palette):
|
|
// Auto-layout - dagre (vendored, MIT) lays the graph out left-to-right
|
|
// along the data flow; falls back to a built-in layered
|
|
// layout if dagre cannot load.
|
|
// Space out - resolves overlaps with minimal movement: nodes are pushed
|
|
// apart just enough to stop touching (margin configurable),
|
|
// the overall arrangement is kept. Pinned nodes stay put.
|
|
// Align - left / right / top / bottom / row (centre Y) / column
|
|
// (centre X) for the selected nodes.
|
|
// Distribute - even gaps horizontally or vertically (3+ nodes).
|
|
//
|
|
// A floating "DAG sort" button on the canvas triggers Auto-layout directly.
|
|
// Scope: 2+ selected nodes = the selection; otherwise the whole graph.
|
|
// Groups are re-fitted around the nodes they contained before a whole-graph
|
|
// operation. Spacing lives in Settings → APNext → Layout.
|
|
|
|
import { app } from "../../../scripts/app.js";
|
|
|
|
const S_NODESEP = "APNext.Layout.NodeSep";
|
|
const S_RANKSEP = "APNext.Layout.RankSep";
|
|
const S_MARGIN = "APNext.Layout.SpreadMargin";
|
|
|
|
function setting(id, fallback) {
|
|
const v = Number(app.ui?.settings?.getSettingValue?.(id));
|
|
return Number.isFinite(v) && v >= 0 ? v : fallback;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Geometry helpers - all in "full rect" space (title bar included)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function titleH() {
|
|
return (window.LiteGraph && window.LiteGraph.NODE_TITLE_HEIGHT) || 30;
|
|
}
|
|
|
|
function rectOf(node) {
|
|
try {
|
|
const b = node.getBounding?.();
|
|
if (b && b.length >= 4 && Number.isFinite(b[0])) return { x: b[0], y: b[1], w: b[2], h: b[3] };
|
|
} catch (e) { /* fall through */ }
|
|
const th = titleH();
|
|
return { x: node.pos[0], y: node.pos[1] - th, w: node.size[0], h: node.size[1] + th };
|
|
}
|
|
|
|
function moveTo(node, x, y) {
|
|
const r = rectOf(node);
|
|
node.pos[0] += x - r.x;
|
|
node.pos[1] += y - r.y;
|
|
}
|
|
|
|
function isPinned(node) {
|
|
return !!(node.flags?.pinned || node.pinned);
|
|
}
|
|
|
|
function scopeNodes() {
|
|
const sel = Object.values(app.canvas?.selected_nodes || {});
|
|
if (sel.length >= 2) return { nodes: sel, whole: false };
|
|
return { nodes: (app.graph?._nodes || []).slice(), whole: true };
|
|
}
|
|
|
|
function edgesAmong(nodes) {
|
|
const ids = new Set(nodes.map((n) => String(n.id)));
|
|
const out = [];
|
|
const table = app.graph?.links ?? app.graph?._links;
|
|
if (!table) return out;
|
|
const each = (cb) => {
|
|
if (typeof table.forEach === "function" && !(table instanceof Array)) table.forEach(cb);
|
|
else for (const k in table) cb(table[k]);
|
|
};
|
|
each((l) => {
|
|
if (!l) return;
|
|
const a = String(l.origin_id), b = String(l.target_id);
|
|
if (ids.has(a) && ids.has(b) && a !== b) out.push([a, b]);
|
|
});
|
|
return out;
|
|
}
|
|
|
|
// group membership is derived from node positions, so capture it BEFORE
|
|
// moving anything, and refit the group frames afterwards
|
|
function captureGroups(whole) {
|
|
if (!whole) return null;
|
|
const map = new Map();
|
|
for (const g of app.graph?._groups || []) {
|
|
try { g.recomputeInsideNodes?.(); } catch (e) { /* older frontend */ }
|
|
const members = (g._nodes || []).slice();
|
|
if (members.length) map.set(g, members);
|
|
}
|
|
return map;
|
|
}
|
|
|
|
function refitGroups(map) {
|
|
if (!map) return;
|
|
for (const [g, members] of map) {
|
|
let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity;
|
|
for (const n of members) {
|
|
const r = rectOf(n);
|
|
minx = Math.min(minx, r.x); miny = Math.min(miny, r.y);
|
|
maxx = Math.max(maxx, r.x + r.w); maxy = Math.max(maxy, r.y + r.h);
|
|
}
|
|
if (!Number.isFinite(minx)) continue;
|
|
g.pos = [minx - 16, miny - 44];
|
|
g.size = [maxx - minx + 32, maxy - miny + 60];
|
|
}
|
|
}
|
|
|
|
function commit() {
|
|
app.graph?.afterChange?.();
|
|
app.canvas?.setDirty?.(true, true);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Auto-layout (dagre, with a layered fallback)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let dagreLoading = null;
|
|
function ensureDagre() {
|
|
if (window.dagre) return Promise.resolve(window.dagre);
|
|
if (!dagreLoading) {
|
|
dagreLoading = new Promise((resolve) => {
|
|
const s = document.createElement("script");
|
|
s.src = new URL("./vendor/dagre.min.js", import.meta.url).href;
|
|
s.onload = () => resolve(window.dagre || null);
|
|
s.onerror = () => { console.warn("[APNext layout] dagre failed to load; using fallback"); resolve(null); };
|
|
document.head.appendChild(s);
|
|
});
|
|
}
|
|
return dagreLoading;
|
|
}
|
|
|
|
// fallback: longest-path layering, rows ordered by current y
|
|
function layeredLayout(nodes, edges, nodesep, ranksep) {
|
|
const byId = new Map(nodes.map((n) => [String(n.id), n]));
|
|
const rank = new Map(nodes.map((n) => [String(n.id), 0]));
|
|
for (let i = 0; i < nodes.length; i++) {
|
|
let changed = false;
|
|
for (const [a, b] of edges) {
|
|
if (rank.get(b) < rank.get(a) + 1) { rank.set(b, rank.get(a) + 1); changed = true; }
|
|
}
|
|
if (!changed) break;
|
|
}
|
|
const cols = new Map();
|
|
for (const [id, r] of rank) {
|
|
if (!cols.has(r)) cols.set(r, []);
|
|
cols.get(r).push(byId.get(id));
|
|
}
|
|
const out = new Map();
|
|
let x = 0;
|
|
for (const r of [...cols.keys()].sort((a, b) => a - b)) {
|
|
const col = cols.get(r).sort((a, b) => rectOf(a).y - rectOf(b).y);
|
|
let y = 0, maxw = 0;
|
|
for (const n of col) {
|
|
const rc = rectOf(n);
|
|
out.set(n, { x, y });
|
|
y += rc.h + nodesep;
|
|
maxw = Math.max(maxw, rc.w);
|
|
}
|
|
x += maxw + ranksep;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async function autoLayout() {
|
|
const { nodes, whole } = scopeNodes();
|
|
if (nodes.length < 2) return;
|
|
const groups = captureGroups(whole);
|
|
const edges = edgesAmong(nodes);
|
|
const nodesep = setting(S_NODESEP, 40);
|
|
const ranksep = setting(S_RANKSEP, 90);
|
|
// anchor: keep the arrangement where the graph already is
|
|
let ax = Infinity, ay = Infinity;
|
|
for (const n of nodes) { const r = rectOf(n); ax = Math.min(ax, r.x); ay = Math.min(ay, r.y); }
|
|
|
|
const dagre = await ensureDagre();
|
|
app.graph?.beforeChange?.();
|
|
let placed;
|
|
if (dagre) {
|
|
const g = new dagre.graphlib.Graph();
|
|
g.setGraph({ rankdir: "LR", nodesep, ranksep, marginx: 0, marginy: 0 });
|
|
g.setDefaultEdgeLabel(() => ({}));
|
|
for (const n of nodes) {
|
|
const r = rectOf(n);
|
|
g.setNode(String(n.id), { width: r.w, height: r.h });
|
|
}
|
|
for (const [a, b] of edges) g.setEdge(a, b);
|
|
dagre.layout(g);
|
|
placed = new Map();
|
|
for (const n of nodes) {
|
|
const p = g.node(String(n.id));
|
|
if (p) placed.set(n, { x: p.x - p.width / 2, y: p.y - p.height / 2 });
|
|
}
|
|
} else {
|
|
placed = layeredLayout(nodes, edges, nodesep, ranksep);
|
|
}
|
|
let minx = Infinity, miny = Infinity;
|
|
for (const p of placed.values()) { minx = Math.min(minx, p.x); miny = Math.min(miny, p.y); }
|
|
const dx = (Number.isFinite(ax) ? ax : 0) - (Number.isFinite(minx) ? minx : 0);
|
|
const dy = (Number.isFinite(ay) ? ay : 0) - (Number.isFinite(miny) ? miny : 0);
|
|
for (const [n, p] of placed) moveTo(n, p.x + dx, p.y + dy);
|
|
refitGroups(groups);
|
|
commit();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Space out - minimal-movement overlap resolution
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function spaceOut() {
|
|
const { nodes, whole } = scopeNodes();
|
|
if (nodes.length < 2) return;
|
|
const groups = captureGroups(whole);
|
|
const margin = setting(S_MARGIN, 24);
|
|
app.graph?.beforeChange?.();
|
|
const items = nodes.map((n) => {
|
|
const r = rectOf(n);
|
|
return { n, x: r.x, y: r.y, w: r.w, h: r.h, pinned: isPinned(n) };
|
|
});
|
|
for (let iter = 0; iter < 80; iter++) {
|
|
let any = false;
|
|
for (let i = 0; i < items.length; i++) {
|
|
for (let j = i + 1; j < items.length; j++) {
|
|
const a = items[i], b = items[j];
|
|
if (a.pinned && b.pinned) continue;
|
|
const acx = a.x + a.w / 2, acy = a.y + a.h / 2;
|
|
const bcx = b.x + b.w / 2, bcy = b.y + b.h / 2;
|
|
const px = (a.w + b.w) / 2 + margin - Math.abs(acx - bcx);
|
|
const py = (a.h + b.h) / 2 + margin - Math.abs(acy - bcy);
|
|
if (px <= 0 || py <= 0) continue;
|
|
any = true;
|
|
// push along the axis that needs the smaller correction
|
|
if (px <= py) {
|
|
const dir = acx <= bcx ? 1 : -1;
|
|
const jitter = acx === bcx ? 0.5 : 0;
|
|
if (a.pinned) { b.x += dir * px; }
|
|
else if (b.pinned) { a.x -= dir * px; }
|
|
else { a.x -= dir * (px / 2 + jitter); b.x += dir * (px / 2 + jitter); }
|
|
} else {
|
|
const dir = acy <= bcy ? 1 : -1;
|
|
const jitter = acy === bcy ? 0.5 : 0;
|
|
if (a.pinned) { b.y += dir * py; }
|
|
else if (b.pinned) { a.y -= dir * py; }
|
|
else { a.y -= dir * (py / 2 + jitter); b.y += dir * (py / 2 + jitter); }
|
|
}
|
|
}
|
|
}
|
|
if (!any) break;
|
|
}
|
|
for (const it of items) if (!it.pinned) moveTo(it.n, it.x, it.y);
|
|
refitGroups(groups);
|
|
commit();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Align / distribute (selection of 2+, distribute 3+)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function selected(min) {
|
|
const sel = Object.values(app.canvas?.selected_nodes || {});
|
|
if (sel.length < min) {
|
|
app.extensionManager?.toast?.add?.({
|
|
severity: "info", summary: "APNext layout",
|
|
detail: `Select at least ${min} nodes first.`, life: 2500,
|
|
});
|
|
return null;
|
|
}
|
|
return sel;
|
|
}
|
|
|
|
function align(edge) {
|
|
const sel = selected(2);
|
|
if (!sel) return;
|
|
app.graph?.beforeChange?.();
|
|
const rects = sel.map((n) => ({ n, ...rectOf(n) }));
|
|
const minx = Math.min(...rects.map((r) => r.x));
|
|
const maxx = Math.max(...rects.map((r) => r.x + r.w));
|
|
const miny = Math.min(...rects.map((r) => r.y));
|
|
const maxy = Math.max(...rects.map((r) => r.y + r.h));
|
|
for (const r of rects) {
|
|
if (edge === "left") moveTo(r.n, minx, r.y);
|
|
else if (edge === "right") moveTo(r.n, maxx - r.w, r.y);
|
|
else if (edge === "top") moveTo(r.n, r.x, miny);
|
|
else if (edge === "bottom") moveTo(r.n, r.x, maxy - r.h);
|
|
else if (edge === "row") moveTo(r.n, r.x, (miny + maxy) / 2 - r.h / 2);
|
|
else if (edge === "column") moveTo(r.n, (minx + maxx) / 2 - r.w / 2, r.y);
|
|
}
|
|
commit();
|
|
}
|
|
|
|
function distribute(axis) {
|
|
const sel = selected(3);
|
|
if (!sel) return;
|
|
app.graph?.beforeChange?.();
|
|
const rects = sel.map((n) => ({ n, ...rectOf(n) }));
|
|
const horizontal = axis === "h";
|
|
rects.sort((a, b) => (horizontal ? a.x - b.x : a.y - b.y));
|
|
const first = rects[0], last = rects[rects.length - 1];
|
|
const span = horizontal
|
|
? (last.x + last.w) - first.x
|
|
: (last.y + last.h) - first.y;
|
|
const total = rects.reduce((s, r) => s + (horizontal ? r.w : r.h), 0);
|
|
const gap = (span - total) / (rects.length - 1);
|
|
let cursor = horizontal ? first.x : first.y;
|
|
for (const r of rects) {
|
|
if (horizontal) { moveTo(r.n, cursor, r.y); cursor += r.w + gap; }
|
|
else { moveTo(r.n, r.x, cursor); cursor += r.h + gap; }
|
|
}
|
|
commit();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Floating "DAG sort" button on the canvas: one click = auto-layout
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function injectDagSortButton() {
|
|
if (document.getElementById("apnext-dag-sort-btn")) return;
|
|
const btn = document.createElement("button");
|
|
btn.id = "apnext-dag-sort-btn";
|
|
btn.type = "button";
|
|
btn.title = "DAG sort: auto-layout the graph along the data flow (selection only when 2+ nodes are selected)";
|
|
btn.innerHTML = '<i class="pi pi-sitemap" style="font-size:0.85em"></i><span>DAG sort</span>';
|
|
Object.assign(btn.style, {
|
|
position: "fixed",
|
|
right: "16px",
|
|
bottom: "72px",
|
|
zIndex: "999",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "6px",
|
|
padding: "6px 12px",
|
|
font: "500 12px/1 inherit",
|
|
color: "var(--fg-color, #ddd)",
|
|
background: "var(--comfy-menu-bg, #202020)",
|
|
border: "1px solid var(--border-color, #4e4e4e)",
|
|
borderRadius: "8px",
|
|
cursor: "pointer",
|
|
});
|
|
btn.addEventListener("mouseenter", () => { btn.style.borderColor = "var(--fg-color, #ddd)"; });
|
|
btn.addEventListener("mouseleave", () => { btn.style.borderColor = "var(--border-color, #4e4e4e)"; });
|
|
btn.addEventListener("click", () => autoLayout());
|
|
document.body.appendChild(btn);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Registration
|
|
// ---------------------------------------------------------------------------
|
|
|
|
app.registerExtension({
|
|
name: "apnext.layout",
|
|
|
|
settings: [
|
|
{
|
|
id: S_NODESEP,
|
|
category: ["APNext", "Layout", "Node spacing"],
|
|
name: "Auto-layout: spacing between nodes in a column",
|
|
type: "slider", attrs: { min: 10, max: 150, step: 5 }, defaultValue: 40,
|
|
},
|
|
{
|
|
id: S_RANKSEP,
|
|
category: ["APNext", "Layout", "Column spacing"],
|
|
name: "Auto-layout: spacing between columns (along the flow)",
|
|
type: "slider", attrs: { min: 20, max: 300, step: 5 }, defaultValue: 90,
|
|
},
|
|
{
|
|
id: S_MARGIN,
|
|
category: ["APNext", "Layout", "Space-out margin"],
|
|
name: "Space out: minimum gap between nodes",
|
|
type: "slider", attrs: { min: 4, max: 100, step: 2 }, defaultValue: 24,
|
|
},
|
|
],
|
|
|
|
commands: [
|
|
{ id: "APNext.Layout.Auto", label: "APNext: auto-layout graph (dagre)", icon: "pi pi-sitemap", function: () => autoLayout() },
|
|
{ id: "APNext.Layout.Spread", label: "APNext: space out nodes (de-overlap)", icon: "pi pi-arrows-alt", function: () => spaceOut() },
|
|
{ id: "APNext.Layout.AlignLeft", label: "APNext: align left", function: () => align("left") },
|
|
{ id: "APNext.Layout.AlignRight", label: "APNext: align right", function: () => align("right") },
|
|
{ id: "APNext.Layout.AlignTop", label: "APNext: align top", function: () => align("top") },
|
|
{ id: "APNext.Layout.AlignBottom", label: "APNext: align bottom", function: () => align("bottom") },
|
|
{ id: "APNext.Layout.AlignRow", label: "APNext: align as row (centre Y)", function: () => align("row") },
|
|
{ id: "APNext.Layout.AlignColumn", label: "APNext: align as column (centre X)", function: () => align("column") },
|
|
{ id: "APNext.Layout.DistributeH", label: "APNext: distribute horizontally", function: () => distribute("h") },
|
|
{ id: "APNext.Layout.DistributeV", label: "APNext: distribute vertically", function: () => distribute("v") },
|
|
],
|
|
menuCommands: [
|
|
{ path: ["APNext", "Layout"], commands: ["APNext.Layout.Auto", "APNext.Layout.Spread"] },
|
|
],
|
|
|
|
getCanvasMenuItems() {
|
|
return [
|
|
{
|
|
content: "APNext: layout",
|
|
has_submenu: true,
|
|
submenu: {
|
|
options: [
|
|
{ content: "Auto-layout (dagre, flow left→right)", callback: () => autoLayout() },
|
|
{ content: "Space out (de-overlap, keep arrangement)", callback: () => spaceOut() },
|
|
null,
|
|
{ content: "Align left", callback: () => align("left") },
|
|
{ content: "Align right", callback: () => align("right") },
|
|
{ content: "Align top", callback: () => align("top") },
|
|
{ content: "Align bottom", callback: () => align("bottom") },
|
|
{ content: "Align as row (centre Y)", callback: () => align("row") },
|
|
{ content: "Align as column (centre X)", callback: () => align("column") },
|
|
null,
|
|
{ content: "Distribute horizontally", callback: () => distribute("h") },
|
|
{ content: "Distribute vertically", callback: () => distribute("v") },
|
|
],
|
|
},
|
|
},
|
|
];
|
|
},
|
|
|
|
setup() {
|
|
injectDagSortButton();
|
|
// warm the dagre cache in the background so the first layout is instant
|
|
setTimeout(() => { ensureDagre(); }, 2000);
|
|
},
|
|
});
|