- Remove all console.log debug statements - Rename "Linear (Left to Right)" to "Linear" since direction is separate - Add "Add Prettifier Node" option in right-click context menu - Bump to v1.0.5 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1104 lines
41 KiB
JavaScript
1104 lines
41 KiB
JavaScript
/**
|
|
* ComfyUI Workflow Prettier
|
|
*
|
|
* Auto-arrange workflow nodes using graph layout algorithms.
|
|
* Provides 4 layout modes (Layered/Sugiyama, Linear, Compact, Sort by Type),
|
|
* group-aware positioning, direction transforms, alignment tools, and undo.
|
|
*
|
|
* @see https://github.com/Comfy-Org/comfyui-workflow-prettier
|
|
* @license MIT
|
|
*/
|
|
|
|
import { app } from "../../scripts/app.js";
|
|
|
|
// Set node position via the pos setter so Vue's reactivity detects the change.
|
|
function setPos(node, x, y) {
|
|
node.pos = [x, y];
|
|
}
|
|
|
|
// Force the canvas to repaint after layout changes.
|
|
function refreshCanvas(graph) {
|
|
graph?.setDirtyCanvas?.(true, true);
|
|
app.canvas?.setDirty?.(true, true);
|
|
graph?.change?.();
|
|
app.canvas?.draw?.(true, true);
|
|
}
|
|
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
// UNDO STACK (10 deep)
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
|
|
const undoStack = [];
|
|
const MAX_UNDO = 10;
|
|
|
|
function pushUndo(graph) {
|
|
const state = { nodes: new Map(), groups: [] };
|
|
for (const node of graph._nodes)
|
|
state.nodes.set(node.id, [node.pos[0], node.pos[1]]);
|
|
for (const group of graph._groups ?? []) {
|
|
const b = group._bounding ?? group.bounding;
|
|
state.groups.push(b ? [b[0], b[1], b[2], b[3]] : null);
|
|
}
|
|
undoStack.push(state);
|
|
if (undoStack.length > MAX_UNDO) undoStack.shift();
|
|
}
|
|
|
|
function popUndo(graph) {
|
|
const state = undoStack.pop();
|
|
if (!state) return false;
|
|
for (const node of graph._nodes) {
|
|
const pos = state.nodes.get(node.id);
|
|
if (pos) setPos(node, pos[0], pos[1]);
|
|
}
|
|
const groups = graph._groups ?? [];
|
|
for (let i = 0; i < groups.length && i < state.groups.length; i++) {
|
|
const saved = state.groups[i];
|
|
if (!saved) continue;
|
|
const b = groups[i]._bounding ?? groups[i].bounding;
|
|
if (b) { b[0] = saved[0]; b[1] = saved[1]; b[2] = saved[2]; b[3] = saved[3]; }
|
|
}
|
|
refreshCanvas(graph);
|
|
return true;
|
|
}
|
|
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
// GRAPH PRIMITIVES
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
|
|
function getWorkflowNodes(graph) {
|
|
return graph._nodes.filter((n) => n.type !== "WorkflowPrettifier");
|
|
}
|
|
|
|
function iterLinks(graph) {
|
|
const links = graph.links;
|
|
if (!links) return [];
|
|
if (links instanceof Map) return [...links.values()].filter(Boolean);
|
|
if (Array.isArray(links)) return links.filter(Boolean);
|
|
return Object.values(links).filter(Boolean);
|
|
}
|
|
|
|
function buildDAG(graph, nodeSet) {
|
|
const nodes = nodeSet ?? getWorkflowNodes(graph);
|
|
const nodeMap = new Map();
|
|
const adj = new Map();
|
|
const revAdj = new Map();
|
|
const ids = new Set();
|
|
|
|
for (const node of nodes) {
|
|
nodeMap.set(node.id, node);
|
|
adj.set(node.id, []);
|
|
revAdj.set(node.id, []);
|
|
ids.add(node.id);
|
|
}
|
|
|
|
for (const link of iterLinks(graph)) {
|
|
const src = link[1] ?? link.origin_id;
|
|
const tgt = link[3] ?? link.target_id;
|
|
if (src != null && tgt != null && ids.has(src) && ids.has(tgt)) {
|
|
adj.get(src).push(tgt);
|
|
revAdj.get(tgt).push(src);
|
|
}
|
|
}
|
|
|
|
return { adj, revAdj, nodeMap };
|
|
}
|
|
|
|
function topoSort(nodeMap, adj, revAdj) {
|
|
const inDeg = new Map();
|
|
for (const id of nodeMap.keys()) inDeg.set(id, revAdj.get(id)?.length ?? 0);
|
|
|
|
const queue = [];
|
|
for (const [id, d] of inDeg) if (d === 0) queue.push(id);
|
|
|
|
queue.sort((a, b) => {
|
|
const na = nodeMap.get(a), nb = nodeMap.get(b);
|
|
const ay = na.pos?.[1] ?? 0, by = nb.pos?.[1] ?? 0;
|
|
return ay !== by ? ay - by : (na.pos?.[0] ?? 0) - (nb.pos?.[0] ?? 0);
|
|
});
|
|
|
|
const sorted = [];
|
|
const visited = new Set();
|
|
while (queue.length > 0) {
|
|
const id = queue.shift();
|
|
sorted.push(id);
|
|
visited.add(id);
|
|
for (const tgt of adj.get(id) ?? []) {
|
|
const nd = inDeg.get(tgt) - 1;
|
|
inDeg.set(tgt, nd);
|
|
if (nd === 0) queue.push(tgt);
|
|
}
|
|
}
|
|
for (const id of nodeMap.keys()) if (!visited.has(id)) sorted.push(id);
|
|
return sorted;
|
|
}
|
|
|
|
function assignLayers(topo, adj) {
|
|
const layer = new Map();
|
|
for (const id of topo) layer.set(id, 0);
|
|
for (const id of topo) {
|
|
const cur = layer.get(id);
|
|
for (const tgt of adj.get(id) ?? [])
|
|
if (cur + 1 > layer.get(tgt)) layer.set(tgt, cur + 1);
|
|
}
|
|
return layer;
|
|
}
|
|
|
|
function buildLayerArrays(layerMap) {
|
|
const max = Math.max(...layerMap.values(), 0);
|
|
const layers = [];
|
|
for (let i = 0; i <= max; i++) layers.push([]);
|
|
for (const [id, l] of layerMap) layers[l].push(id);
|
|
return layers;
|
|
}
|
|
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
// CROSSING MINIMIZATION (Barycenter, bidirectional)
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
|
|
function bary(nodeId, neighborAdj, neighborLayer, posMap) {
|
|
const nbrs = (neighborAdj.get(nodeId) ?? []).filter((n) => neighborLayer.includes(n));
|
|
if (nbrs.length === 0) return null;
|
|
let sum = 0;
|
|
for (const n of nbrs) sum += posMap.get(n) ?? 0;
|
|
return sum / nbrs.length;
|
|
}
|
|
|
|
function minimizeCrossings(rawLayers, adj, revAdj, nodeMap) {
|
|
const layers = rawLayers.map((l) => [...l]);
|
|
for (const layer of layers)
|
|
layer.sort((a, b) => (nodeMap.get(a)?.pos?.[1] ?? 0) - (nodeMap.get(b)?.pos?.[1] ?? 0));
|
|
|
|
for (let iter = 0; iter < 6; iter++) {
|
|
// Forward
|
|
for (let l = 1; l < layers.length; l++) {
|
|
const pm = new Map();
|
|
layers[l - 1].forEach((id, i) => pm.set(id, i));
|
|
const sc = layers[l].map((id, oi) => ({ id, bc: bary(id, revAdj, layers[l - 1], pm), oi }));
|
|
sc.sort((a, b) => {
|
|
if (a.bc == null && b.bc == null) return a.oi - b.oi;
|
|
if (a.bc == null) return 1; if (b.bc == null) return -1;
|
|
return a.bc - b.bc;
|
|
});
|
|
layers[l] = sc.map((s) => s.id);
|
|
}
|
|
// Backward
|
|
for (let l = layers.length - 2; l >= 0; l--) {
|
|
const pm = new Map();
|
|
layers[l + 1].forEach((id, i) => pm.set(id, i));
|
|
const sc = layers[l].map((id, oi) => ({ id, bc: bary(id, adj, layers[l + 1], pm), oi }));
|
|
sc.sort((a, b) => {
|
|
if (a.bc == null && b.bc == null) return a.oi - b.oi;
|
|
if (a.bc == null) return 1; if (b.bc == null) return -1;
|
|
return a.bc - b.bc;
|
|
});
|
|
layers[l] = sc.map((s) => s.id);
|
|
}
|
|
}
|
|
return layers;
|
|
}
|
|
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
// COORDINATE ASSIGNMENT (Median alignment)
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
|
|
function assignCoordinates(layers, adj, revAdj, nodeMap, vSpacing) {
|
|
const yPos = new Map();
|
|
if (layers.length === 0) return yPos;
|
|
|
|
let anchorIdx = 0;
|
|
for (let i = 1; i < layers.length; i++)
|
|
if (layers[i].length > layers[anchorIdx].length) anchorIdx = i;
|
|
|
|
// Stack anchor
|
|
let y = 0;
|
|
for (const id of layers[anchorIdx]) {
|
|
yPos.set(id, y);
|
|
y += (nodeMap.get(id)?.size?.[1] ?? 100) + vSpacing;
|
|
}
|
|
|
|
// Propagate outward
|
|
for (let l = anchorIdx + 1; l < layers.length; l++)
|
|
alignLayer(layers[l], revAdj, layers[l - 1], yPos, nodeMap, vSpacing);
|
|
for (let l = anchorIdx - 1; l >= 0; l--)
|
|
alignLayer(layers[l], adj, layers[l + 1], yPos, nodeMap, vSpacing);
|
|
|
|
// Normalize to y=0
|
|
let minY = Infinity;
|
|
for (const v of yPos.values()) if (v < minY) minY = v;
|
|
if (isFinite(minY) && minY !== 0)
|
|
for (const [id, v] of yPos) yPos.set(id, v - minY);
|
|
|
|
return yPos;
|
|
}
|
|
|
|
function alignLayer(layer, neighborAdj, refLayer, yPos, nodeMap, vSpacing) {
|
|
const refSet = new Set(refLayer);
|
|
const positioned = [], unpositioned = [];
|
|
|
|
for (const id of layer) {
|
|
const nbrs = (neighborAdj.get(id) ?? []).filter((n) => refSet.has(n));
|
|
if (nbrs.length > 0) {
|
|
const centers = nbrs
|
|
.map((n) => (yPos.get(n) ?? 0) + (nodeMap.get(n)?.size?.[1] ?? 100) / 2)
|
|
.sort((a, b) => a - b);
|
|
const mid = Math.floor(centers.length / 2);
|
|
const median = centers.length % 2 === 0
|
|
? (centers[mid - 1] + centers[mid]) / 2 : centers[mid];
|
|
yPos.set(id, median - (nodeMap.get(id)?.size?.[1] ?? 100) / 2);
|
|
positioned.push(id);
|
|
} else {
|
|
unpositioned.push(id);
|
|
}
|
|
}
|
|
|
|
if (unpositioned.length > 0) {
|
|
let lastBot = 0;
|
|
for (const id of positioned) {
|
|
const bot = (yPos.get(id) ?? 0) + (nodeMap.get(id)?.size?.[1] ?? 100);
|
|
if (bot > lastBot) lastBot = bot;
|
|
}
|
|
for (const id of unpositioned) {
|
|
yPos.set(id, lastBot + vSpacing);
|
|
lastBot += (nodeMap.get(id)?.size?.[1] ?? 100) + vSpacing;
|
|
}
|
|
}
|
|
|
|
// Resolve overlaps
|
|
const sorted = [...layer].sort((a, b) => (yPos.get(a) ?? 0) - (yPos.get(b) ?? 0));
|
|
for (let i = 1; i < sorted.length; i++) {
|
|
const prevBot = (yPos.get(sorted[i - 1]) ?? 0) + (nodeMap.get(sorted[i - 1])?.size?.[1] ?? 100);
|
|
if ((yPos.get(sorted[i]) ?? 0) < prevBot + vSpacing)
|
|
yPos.set(sorted[i], prevBot + vSpacing);
|
|
}
|
|
}
|
|
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
// LAYOUT ALGORITHMS
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
|
|
// ─── Layered (Sugiyama) ──────────────────────────────────────────────────────
|
|
|
|
function layoutLayered(graph, nodes, startX, startY, opts = {}) {
|
|
if (nodes.length === 0) return { width: 0, height: 0 };
|
|
if (nodes.length === 1) {
|
|
setPos(nodes[0], startX, startY);
|
|
return { width: nodes[0].size?.[0] ?? 200, height: nodes[0].size?.[1] ?? 100 };
|
|
}
|
|
|
|
const hSp = opts.horizontalSpacing ?? 100;
|
|
const vSp = opts.verticalSpacing ?? 100;
|
|
|
|
const { adj, revAdj, nodeMap } = buildDAG(graph, nodes);
|
|
const topo = topoSort(nodeMap, adj, revAdj);
|
|
const layerMap = assignLayers(topo, adj);
|
|
const rawLayers = buildLayerArrays(layerMap);
|
|
const layers = minimizeCrossings(rawLayers, adj, revAdj, nodeMap);
|
|
const yPos = assignCoordinates(layers, adj, revAdj, nodeMap, vSp);
|
|
|
|
const colWidths = layers.map((col) => {
|
|
let mx = 0;
|
|
for (const id of col) { const w = nodeMap.get(id)?.size?.[0] ?? 200; if (w > mx) mx = w; }
|
|
return mx || 200;
|
|
});
|
|
|
|
let totalW = 0, totalH = 0, cx = startX;
|
|
for (let c = 0; c < layers.length; c++) {
|
|
for (const id of layers[c]) {
|
|
const node = nodeMap.get(id);
|
|
if (!node) continue;
|
|
setPos(node, cx, startY + (yPos.get(id) ?? 0));
|
|
const bot = node.pos[1] + (node.size?.[1] ?? 100) - startY;
|
|
if (bot > totalH) totalH = bot;
|
|
}
|
|
cx += colWidths[c] + hSp;
|
|
totalW = cx - startX - hSp;
|
|
}
|
|
return { width: totalW, height: totalH };
|
|
}
|
|
|
|
// ─── Linear ──────────────────────────────────────────────────────────────────
|
|
|
|
function layoutLinear(graph, nodes, startX, startY, opts = {}) {
|
|
if (nodes.length === 0) return { width: 0, height: 0 };
|
|
const hSp = opts.horizontalSpacing ?? 100;
|
|
const { adj, revAdj, nodeMap } = buildDAG(graph, nodes);
|
|
const topo = topoSort(nodeMap, adj, revAdj);
|
|
let cx = startX, maxH = 0;
|
|
for (const id of topo) {
|
|
const node = nodeMap.get(id);
|
|
if (!node) continue;
|
|
setPos(node, cx, startY);
|
|
cx += (node.size?.[0] ?? 200) + hSp;
|
|
const h = node.size?.[1] ?? 100;
|
|
if (h > maxH) maxH = h;
|
|
}
|
|
return { width: cx - startX - hSp, height: maxH };
|
|
}
|
|
|
|
// ─── Compact (Rectangle packing) ────────────────────────────────────────────
|
|
|
|
function layoutCompact(graph, nodes, startX, startY, opts = {}) {
|
|
if (nodes.length === 0) return { width: 0, height: 0 };
|
|
const hSp = opts.horizontalSpacing ?? 100;
|
|
const vSp = opts.verticalSpacing ?? 100;
|
|
|
|
// Sort by height descending (tall nodes first for better packing)
|
|
const sorted = [...nodes].sort((a, b) => (b.size?.[1] ?? 100) - (a.size?.[1] ?? 100));
|
|
|
|
// Target width: sqrt of total area, aiming for roughly square
|
|
let totalArea = 0;
|
|
for (const n of sorted) totalArea += ((n.size?.[0] ?? 200) + hSp) * ((n.size?.[1] ?? 100) + vSp);
|
|
const targetWidth = Math.sqrt(totalArea) * 1.1;
|
|
|
|
// Shelf-based packing
|
|
let shelfX = startX, shelfY = startY, shelfHeight = 0;
|
|
let maxX = startX, maxY = startY;
|
|
|
|
for (const node of sorted) {
|
|
const w = node.size?.[0] ?? 200;
|
|
const h = node.size?.[1] ?? 100;
|
|
|
|
if (shelfX + w - startX > targetWidth && shelfX > startX) {
|
|
shelfY += shelfHeight + vSp;
|
|
shelfX = startX;
|
|
shelfHeight = 0;
|
|
}
|
|
|
|
setPos(node, shelfX, shelfY);
|
|
shelfX += w + hSp;
|
|
if (h > shelfHeight) shelfHeight = h;
|
|
|
|
if (shelfX > maxX) maxX = shelfX;
|
|
if (shelfY + h > maxY) maxY = shelfY + h;
|
|
}
|
|
|
|
return { width: maxX - startX - hSp, height: maxY - startY };
|
|
}
|
|
|
|
// ─── Sort by Type ────────────────────────────────────────────────────────────
|
|
|
|
function layoutSortByType(graph, nodes, startX, startY, opts = {}) {
|
|
if (nodes.length === 0) return { width: 0, height: 0 };
|
|
const hSp = opts.horizontalSpacing ?? 100;
|
|
const vSp = opts.verticalSpacing ?? 100;
|
|
|
|
// Group by type
|
|
const typeGroups = new Map();
|
|
for (const node of nodes) {
|
|
const type = node.type ?? "Unknown";
|
|
if (!typeGroups.has(type)) typeGroups.set(type, []);
|
|
typeGroups.get(type).push(node);
|
|
}
|
|
|
|
// Order type groups by topological depth (average layer of their nodes)
|
|
const { adj, revAdj, nodeMap } = buildDAG(graph, nodes);
|
|
const topo = topoSort(nodeMap, adj, revAdj);
|
|
const layerMap = assignLayers(topo, adj);
|
|
|
|
const typeAvgLayer = new Map();
|
|
for (const [type, group] of typeGroups) {
|
|
let sum = 0;
|
|
for (const n of group) sum += layerMap.get(n.id) ?? 0;
|
|
typeAvgLayer.set(type, sum / group.length);
|
|
}
|
|
|
|
const sortedTypes = [...typeGroups.keys()].sort(
|
|
(a, b) => (typeAvgLayer.get(a) ?? 0) - (typeAvgLayer.get(b) ?? 0)
|
|
);
|
|
|
|
// Place each type group as a column
|
|
let cx = startX, totalH = 0;
|
|
for (const type of sortedTypes) {
|
|
const group = typeGroups.get(type);
|
|
let cy = startY;
|
|
let colWidth = 0;
|
|
for (const node of group) {
|
|
setPos(node, cx, cy);
|
|
const w = node.size?.[0] ?? 200;
|
|
const h = node.size?.[1] ?? 100;
|
|
if (w > colWidth) colWidth = w;
|
|
cy += h + vSp;
|
|
}
|
|
const colH = cy - startY - vSp;
|
|
if (colH > totalH) totalH = colH;
|
|
cx += colWidth + hSp;
|
|
}
|
|
|
|
return { width: cx - startX - hSp, height: totalH };
|
|
}
|
|
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
// DIRECTION TRANSFORMS
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
|
|
function applyDirection(graph, nodes, direction, opts = {}) {
|
|
if (direction === "Left to Right") return; // default, no transform
|
|
|
|
// Find bounding box of laid-out nodes
|
|
let minX = Infinity, minY = Infinity;
|
|
for (const n of nodes) {
|
|
if (n.pos[0] < minX) minX = n.pos[0];
|
|
if (n.pos[1] < minY) minY = n.pos[1];
|
|
}
|
|
|
|
if (direction === "Top to Bottom") {
|
|
// Rotate 90: swap (x,y) relative to origin
|
|
for (const n of nodes) {
|
|
const rx = n.pos[0] - minX;
|
|
const ry = n.pos[1] - minY;
|
|
setPos(n, minX + ry, minY + rx);
|
|
}
|
|
// Also need to handle group bounding boxes
|
|
for (const group of graph._groups ?? []) {
|
|
const b = group._bounding ?? group.bounding;
|
|
if (!b) continue;
|
|
const rx = b[0] - minX;
|
|
const ry = b[1] - minY;
|
|
b[0] = minX + ry;
|
|
b[1] = minY + rx;
|
|
const tmpW = b[2];
|
|
b[2] = b[3];
|
|
b[3] = tmpW;
|
|
}
|
|
// After rotation, node widths/heights stay the same but positions are swapped.
|
|
// Resolve any overlaps that result from the rotation.
|
|
resolveOverlaps(nodes, opts.horizontalSpacing ?? 100, opts.verticalSpacing ?? 100);
|
|
} else if (direction === "Right to Left") {
|
|
// Mirror X
|
|
let maxRight = 0;
|
|
for (const n of nodes) {
|
|
const right = n.pos[0] + (n.size?.[0] ?? 200);
|
|
if (right > maxRight) maxRight = right;
|
|
}
|
|
for (const n of nodes) {
|
|
setPos(n, maxRight - n.pos[0] - (n.size?.[0] ?? 200), n.pos[1]);
|
|
}
|
|
// Mirror group bounding boxes
|
|
for (const group of graph._groups ?? []) {
|
|
const b = group._bounding ?? group.bounding;
|
|
if (!b) continue;
|
|
b[0] = maxRight - b[0] - b[2];
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Post-direction overlap resolution ──────────────────────────────────────
|
|
// After rotating (Top to Bottom), nodes may overlap because their widths/heights
|
|
// didn't swap. This scans rows and columns and pushes nodes apart.
|
|
|
|
function resolveOverlaps(nodes, hSp, vSp) {
|
|
if (nodes.length < 2) return;
|
|
|
|
// Group nodes into approximate rows (by Y position)
|
|
const sorted = [...nodes].sort((a, b) => a.pos[1] - b.pos[1]);
|
|
const rows = [];
|
|
let currentRow = [sorted[0]];
|
|
|
|
for (let i = 1; i < sorted.length; i++) {
|
|
const n = sorted[i];
|
|
const prevBot = currentRow[0].pos[1] + (currentRow[0].size?.[1] ?? 100);
|
|
// If this node's top is within the row's vertical range, same row
|
|
if (n.pos[1] < prevBot - 10) {
|
|
currentRow.push(n);
|
|
} else {
|
|
rows.push(currentRow);
|
|
currentRow = [n];
|
|
}
|
|
}
|
|
rows.push(currentRow);
|
|
|
|
// Within each row, sort by X and push apart any horizontal overlaps
|
|
for (const row of rows) {
|
|
row.sort((a, b) => a.pos[0] - b.pos[0]);
|
|
for (let i = 1; i < row.length; i++) {
|
|
const prevRight = row[i - 1].pos[0] + (row[i - 1].size?.[0] ?? 200);
|
|
if (row[i].pos[0] < prevRight + hSp) {
|
|
setPos(row[i], prevRight + hSp, row[i].pos[1]);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Between rows, ensure no vertical overlap
|
|
for (let r = 1; r < rows.length; r++) {
|
|
// Find the max bottom of previous row
|
|
let prevMaxBot = 0;
|
|
for (const n of rows[r - 1]) {
|
|
const bot = n.pos[1] + (n.size?.[1] ?? 100);
|
|
if (bot > prevMaxBot) prevMaxBot = bot;
|
|
}
|
|
// Find the min top of current row
|
|
let curMinTop = Infinity;
|
|
for (const n of rows[r]) {
|
|
if (n.pos[1] < curMinTop) curMinTop = n.pos[1];
|
|
}
|
|
// Push down if overlapping
|
|
if (curMinTop < prevMaxBot + vSp) {
|
|
const shift = prevMaxBot + vSp - curMinTop;
|
|
for (const n of rows[r]) setPos(n, n.pos[0], n.pos[1] + shift);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
// GROUP DETECTION & GROUP-AWARE LAYOUT
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
|
|
function partitionByGroup(graph) {
|
|
const groups = graph._groups ?? [];
|
|
const groupNodes = new Map();
|
|
const assigned = new Set();
|
|
|
|
for (let gi = 0; gi < groups.length; gi++) groupNodes.set(gi, []);
|
|
|
|
for (const node of getWorkflowNodes(graph)) {
|
|
const cx = node.pos[0] + (node.size?.[0] ?? 200) / 2;
|
|
const cy = node.pos[1] + (node.size?.[1] ?? 100) / 2;
|
|
let best = -1, bestArea = Infinity;
|
|
|
|
for (let gi = 0; gi < groups.length; gi++) {
|
|
const b = groups[gi]._bounding ?? groups[gi].bounding;
|
|
if (!b) continue;
|
|
if (cx >= b[0] && cx <= b[0] + b[2] && cy >= b[1] && cy <= b[1] + b[3]) {
|
|
const area = b[2] * b[3];
|
|
if (area < bestArea) { bestArea = area; best = gi; }
|
|
}
|
|
}
|
|
|
|
if (best >= 0) { groupNodes.get(best).push(node); assigned.add(node.id); }
|
|
}
|
|
|
|
const ungrouped = getWorkflowNodes(graph).filter((n) => !assigned.has(n.id));
|
|
return { groupNodes, ungrouped, groups };
|
|
}
|
|
|
|
function buildSuperDAG(graph, groupNodes, ungrouped) {
|
|
const n2s = new Map();
|
|
for (const [gi, nodes] of groupNodes) for (const n of nodes) n2s.set(n.id, `g${gi}`);
|
|
for (const n of ungrouped) n2s.set(n.id, n.id);
|
|
|
|
const sids = new Set();
|
|
for (const gi of groupNodes.keys()) if (groupNodes.get(gi).length > 0) sids.add(`g${gi}`);
|
|
for (const n of ungrouped) sids.add(n.id);
|
|
|
|
const sAdj = new Map(), sRev = new Map();
|
|
for (const sid of sids) { sAdj.set(sid, []); sRev.set(sid, []); }
|
|
|
|
const seen = new Set();
|
|
for (const link of iterLinks(graph)) {
|
|
const src = link[1] ?? link.origin_id, tgt = link[3] ?? link.target_id;
|
|
if (src == null || tgt == null) continue;
|
|
const ss = n2s.get(src), ts = n2s.get(tgt);
|
|
if (ss != null && ts != null && ss !== ts && sids.has(ss) && sids.has(ts)) {
|
|
const key = `${ss}->${ts}`;
|
|
if (!seen.has(key)) { seen.add(key); sAdj.get(ss).push(ts); sRev.get(ts).push(ss); }
|
|
}
|
|
}
|
|
return { sids, sAdj, sRev };
|
|
}
|
|
|
|
function layoutWithGroups(graph, layoutFn, opts = {}) {
|
|
const pad = opts.groupPadding ?? 100;
|
|
const titleH = 34;
|
|
const gSp = opts.groupSpacing ?? 100;
|
|
|
|
const { groupNodes, ungrouped, groups } = partitionByGroup(graph);
|
|
const hasGroups = [...groupNodes.values()].some((n) => n.length > 0);
|
|
if (!hasGroups) {
|
|
layoutFn(graph, getWorkflowNodes(graph), 100, 100, opts);
|
|
return;
|
|
}
|
|
|
|
// Layout within each group at origin
|
|
const gSizes = new Map();
|
|
for (const [gi, nodes] of groupNodes) {
|
|
if (nodes.length === 0) { gSizes.set(gi, { width: 0, height: 0 }); continue; }
|
|
gSizes.set(gi, layoutFn(graph, nodes, 0, 0, opts));
|
|
}
|
|
|
|
// Build virtual super-nodes for groups + ungrouped
|
|
const { sids, sAdj, sRev } = buildSuperDAG(graph, groupNodes, ungrouped);
|
|
const vNodes = [];
|
|
const vMap = new Map();
|
|
for (const sid of sids) {
|
|
let w, h;
|
|
if (typeof sid === "string" && sid.startsWith("g")) {
|
|
const gs = gSizes.get(parseInt(sid.slice(1)));
|
|
w = (gs?.width ?? 200) + pad * 2; h = (gs?.height ?? 100) + pad * 2 + titleH;
|
|
} else {
|
|
const node = graph._nodes.find((n) => n.id === sid);
|
|
w = node?.size?.[0] ?? 200; h = node?.size?.[1] ?? 100;
|
|
}
|
|
const vNode = { id: sid, pos: [0, 0], size: [w, h] };
|
|
vNodes.push(vNode);
|
|
vMap.set(sid, vNode);
|
|
}
|
|
|
|
// Use the SAME layout function for super-node positioning
|
|
// Create a fake graph context for the super-nodes
|
|
const fakeGraph = { _nodes: vNodes, _groups: [], links: [] };
|
|
|
|
// Build link array for super-node edges so layoutFn can use buildDAG
|
|
let fakeLinkId = 1;
|
|
for (const [src, targets] of sAdj) {
|
|
for (const tgt of targets) {
|
|
fakeGraph.links.push([fakeLinkId++, src, 0, tgt, 0, "*"]);
|
|
}
|
|
}
|
|
|
|
layoutFn(fakeGraph, vNodes, 100, 100, {
|
|
...opts,
|
|
horizontalSpacing: gSp,
|
|
verticalSpacing: gSp,
|
|
});
|
|
|
|
// Apply positions
|
|
for (const [gi, nodes] of groupNodes) {
|
|
if (nodes.length === 0) continue;
|
|
const v = vMap.get(`g${gi}`);
|
|
if (!v) continue;
|
|
const ox = v.pos[0] + pad, oy = v.pos[1] + pad + titleH;
|
|
for (const n of nodes) setPos(n, n.pos[0] + ox, n.pos[1] + oy);
|
|
const group = groups[gi];
|
|
if (group) {
|
|
const b = group._bounding ?? group.bounding;
|
|
if (b) { b[0] = v.pos[0]; b[1] = v.pos[1]; b[2] = v.size[0]; b[3] = v.size[1]; }
|
|
}
|
|
}
|
|
|
|
for (const n of ungrouped) {
|
|
const v = vMap.get(n.id);
|
|
if (v) setPos(n, v.pos[0], v.pos[1]);
|
|
}
|
|
}
|
|
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
// ALIGNMENT & DISTRIBUTION (for selected nodes)
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
|
|
function getSelectedNodes(graph) {
|
|
return (graph._nodes ?? []).filter(
|
|
(n) => n.is_selected && n.type !== "WorkflowPrettifier"
|
|
);
|
|
}
|
|
|
|
function alignNodes(graph, mode) {
|
|
const sel = getSelectedNodes(graph);
|
|
if (sel.length < 2) return;
|
|
pushUndo(graph);
|
|
|
|
const getW = (n) => n.size?.[0] ?? 200;
|
|
const getH = (n) => n.size?.[1] ?? 100;
|
|
|
|
switch (mode) {
|
|
case "left":
|
|
{ const minX = Math.min(...sel.map((n) => n.pos[0]));
|
|
for (const n of sel) setPos(n, minX, n.pos[1]); }
|
|
break;
|
|
case "right":
|
|
{ const maxR = Math.max(...sel.map((n) => n.pos[0] + getW(n)));
|
|
for (const n of sel) setPos(n, maxR - getW(n), n.pos[1]); }
|
|
break;
|
|
case "top":
|
|
{ const minY = Math.min(...sel.map((n) => n.pos[1]));
|
|
for (const n of sel) setPos(n, n.pos[0], minY); }
|
|
break;
|
|
case "bottom":
|
|
{ const maxB = Math.max(...sel.map((n) => n.pos[1] + getH(n)));
|
|
for (const n of sel) setPos(n, n.pos[0], maxB - getH(n)); }
|
|
break;
|
|
case "centerH":
|
|
{ const avgX = sel.reduce((s, n) => s + n.pos[0] + getW(n) / 2, 0) / sel.length;
|
|
for (const n of sel) setPos(n, avgX - getW(n) / 2, n.pos[1]); }
|
|
break;
|
|
case "centerV":
|
|
{ const avgY = sel.reduce((s, n) => s + n.pos[1] + getH(n) / 2, 0) / sel.length;
|
|
for (const n of sel) setPos(n, n.pos[0], avgY - getH(n) / 2); }
|
|
break;
|
|
case "distributeH":
|
|
{ if (sel.length < 3) break;
|
|
const sorted = [...sel].sort((a, b) => a.pos[0] - b.pos[0]);
|
|
const first = sorted[0].pos[0];
|
|
const last = sorted[sorted.length - 1].pos[0] + getW(sorted[sorted.length - 1]);
|
|
const totalNodeW = sorted.reduce((s, n) => s + getW(n), 0);
|
|
const gap = (last - first - totalNodeW) / (sorted.length - 1);
|
|
let x = first;
|
|
for (const n of sorted) { setPos(n, x, n.pos[1]); x += getW(n) + gap; } }
|
|
break;
|
|
case "distributeV":
|
|
{ if (sel.length < 3) break;
|
|
const sorted = [...sel].sort((a, b) => a.pos[1] - b.pos[1]);
|
|
const first = sorted[0].pos[1];
|
|
const last = sorted[sorted.length - 1].pos[1] + getH(sorted[sorted.length - 1]);
|
|
const totalNodeH = sorted.reduce((s, n) => s + getH(n), 0);
|
|
const gap = (last - first - totalNodeH) / (sorted.length - 1);
|
|
let y = first;
|
|
for (const n of sorted) { setPos(n, n.pos[0], y); y += getH(n) + gap; } }
|
|
break;
|
|
}
|
|
refreshCanvas(graph);
|
|
}
|
|
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
// EQUALIZE SPACING
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
|
|
function equalizeSpacing(graph, opts = {}) {
|
|
const nodes = getWorkflowNodes(graph);
|
|
if (nodes.length < 2) return;
|
|
pushUndo(graph);
|
|
|
|
const hSp = opts.horizontalSpacing ?? 100;
|
|
const vSp = opts.verticalSpacing ?? 100;
|
|
|
|
// Find columns: group nodes by approximate X position
|
|
const sorted = [...nodes].sort((a, b) => a.pos[0] - b.pos[0]);
|
|
const columns = [];
|
|
let currentCol = [sorted[0]];
|
|
|
|
for (let i = 1; i < sorted.length; i++) {
|
|
const prev = currentCol[currentCol.length - 1];
|
|
const prevRight = prev.pos[0] + (prev.size?.[0] ?? 200);
|
|
// If this node starts before the previous ends + some threshold, same column
|
|
if (sorted[i].pos[0] < prevRight + hSp * 0.3) {
|
|
currentCol.push(sorted[i]);
|
|
} else {
|
|
columns.push(currentCol);
|
|
currentCol = [sorted[i]];
|
|
}
|
|
}
|
|
columns.push(currentCol);
|
|
|
|
// Within each column, equalize vertical spacing
|
|
for (const col of columns) {
|
|
col.sort((a, b) => a.pos[1] - b.pos[1]);
|
|
if (col.length < 2) continue;
|
|
let cy = col[0].pos[1];
|
|
for (const n of col) {
|
|
setPos(n, n.pos[0], cy);
|
|
cy += (n.size?.[1] ?? 100) + vSp;
|
|
}
|
|
}
|
|
|
|
// Equalize horizontal spacing between columns
|
|
let cx = columns[0][0].pos[0];
|
|
for (const col of columns) {
|
|
let colWidth = 0;
|
|
for (const n of col) {
|
|
setPos(n, cx, n.pos[1]);
|
|
const w = n.size?.[0] ?? 200;
|
|
if (w > colWidth) colWidth = w;
|
|
}
|
|
cx += colWidth + hSp;
|
|
}
|
|
|
|
refreshCanvas(graph);
|
|
}
|
|
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
// MAIN ENTRY POINT
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
|
|
const LAYOUT_FNS = {
|
|
"Layered (Vertical Stacks)": layoutLayered,
|
|
"Linear": layoutLinear,
|
|
"Compact (Tight Rectangle)": layoutCompact,
|
|
"Sort by Type": layoutSortByType,
|
|
};
|
|
|
|
function runPrettify(graph, opts) {
|
|
const layoutFn = LAYOUT_FNS[opts.layout] ?? layoutLayered;
|
|
const groupMode = opts.groupHandling ?? "Auto (Respect Groups)";
|
|
const allNodes = getWorkflowNodes(graph);
|
|
|
|
if (groupMode === "Ignore Groups") {
|
|
layoutFn(graph, allNodes, 100, 100, opts);
|
|
} else if (groupMode === "Respect Groups" || groupMode === "Auto (Respect Groups)") {
|
|
const hasGroups = (graph._groups ?? []).length > 0 &&
|
|
[...partitionByGroup(graph).groupNodes.values()].some((n) => n.length > 0);
|
|
if (hasGroups) {
|
|
layoutWithGroups(graph, layoutFn, opts);
|
|
} else {
|
|
layoutFn(graph, allNodes, 100, 100, opts);
|
|
}
|
|
}
|
|
|
|
// Apply direction transform
|
|
if (opts.direction && opts.direction !== "Left to Right") {
|
|
applyDirection(graph, allNodes, opts.direction, opts);
|
|
}
|
|
}
|
|
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
// EXTENSION REGISTRATION
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
|
|
function readNodeOpts(node) {
|
|
const get = (name) => node.widgets?.find((w) => w.name === name)?.value;
|
|
return {
|
|
layout: get("layout") ?? "Layered (Vertical Stacks)",
|
|
direction: get("direction") ?? "Left to Right",
|
|
groupHandling: get("group_handling") ?? "Auto (Respect Groups)",
|
|
horizontalSpacing: get("horizontal_spacing") ?? 100,
|
|
verticalSpacing: get("vertical_spacing") ?? 100,
|
|
groupPadding: get("group_padding") ?? 100,
|
|
};
|
|
}
|
|
|
|
app.registerExtension({
|
|
name: "comfyui.workflow.prettier",
|
|
|
|
init() {
|
|
// ── Canvas right-click: Prettify + quick layouts ──
|
|
const origCanvas = LGraphCanvas.prototype.getCanvasMenuOptions;
|
|
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
|
|
const options = origCanvas.apply(this, arguments);
|
|
options.push(null);
|
|
|
|
const defaultOpts = { horizontalSpacing: 100, verticalSpacing: 100, groupPadding: 100 };
|
|
const doLayout = (fn) => {
|
|
const g = app.graph;
|
|
if (!g?._nodes?.length && !g?.nodes?.length) return;
|
|
pushUndo(g);
|
|
fn(g);
|
|
refreshCanvas(g);
|
|
};
|
|
|
|
options.push({
|
|
content: "Prettify Workflow",
|
|
has_submenu: true,
|
|
callback: () => {},
|
|
submenu: {
|
|
options: [
|
|
{ content: "Layered (Vertical Stacks)", callback: () =>
|
|
doLayout((g) => runPrettify(g, { ...defaultOpts, layout: "Layered (Vertical Stacks)", groupHandling: "Auto (Respect Groups)", direction: "Left to Right" })) },
|
|
{ content: "Linear", callback: () =>
|
|
doLayout((g) => runPrettify(g, { ...defaultOpts, layout: "Linear", groupHandling: "Auto (Respect Groups)", direction: "Left to Right" })) },
|
|
{ content: "Compact (Tight Rectangle)", callback: () =>
|
|
doLayout((g) => runPrettify(g, { ...defaultOpts, layout: "Compact (Tight Rectangle)", groupHandling: "Auto (Respect Groups)", direction: "Left to Right" })) },
|
|
{ content: "Sort by Type", callback: () =>
|
|
doLayout((g) => runPrettify(g, { ...defaultOpts, layout: "Sort by Type", groupHandling: "Auto (Respect Groups)", direction: "Left to Right" })) },
|
|
null,
|
|
{ content: "Equalize Spacing", callback: () => {
|
|
const g = app.graph;
|
|
if (!g?._nodes?.length) return;
|
|
equalizeSpacing(g, defaultOpts);
|
|
}},
|
|
null,
|
|
{ content: `Undo (${undoStack.length})`, callback: () => {
|
|
if (app.graph) popUndo(app.graph);
|
|
}},
|
|
null,
|
|
{ content: "Add Prettifier Node", callback: () => {
|
|
const g = app.graph;
|
|
if (!g) return;
|
|
const node = LiteGraph.createNode("WorkflowPrettifier");
|
|
if (node) {
|
|
node.pos = [100, 100];
|
|
g.add(node);
|
|
refreshCanvas(g);
|
|
}
|
|
}},
|
|
],
|
|
},
|
|
});
|
|
|
|
return options;
|
|
};
|
|
|
|
// ── Node right-click: Alignment tools (when nodes selected) ──
|
|
const origNode = LGraphCanvas.prototype.getNodeMenuOptions;
|
|
if (origNode) {
|
|
LGraphCanvas.prototype.getNodeMenuOptions = function (node) {
|
|
const options = origNode.apply(this, arguments);
|
|
const sel = getSelectedNodes(app.graph);
|
|
if (sel.length >= 2) {
|
|
options.push(null);
|
|
options.push({
|
|
content: "Align / Distribute",
|
|
has_submenu: true,
|
|
callback: () => {},
|
|
submenu: {
|
|
options: [
|
|
{ content: "Align Left", callback: () => alignNodes(app.graph, "left") },
|
|
{ content: "Align Right", callback: () => alignNodes(app.graph, "right") },
|
|
{ content: "Align Top", callback: () => alignNodes(app.graph, "top") },
|
|
{ content: "Align Bottom", callback: () => alignNodes(app.graph, "bottom") },
|
|
null,
|
|
{ content: "Center Horizontally", callback: () => alignNodes(app.graph, "centerH") },
|
|
{ content: "Center Vertically", callback: () => alignNodes(app.graph, "centerV") },
|
|
null,
|
|
{ content: "Distribute Horizontally", callback: () => alignNodes(app.graph, "distributeH") },
|
|
{ content: "Distribute Vertically", callback: () => alignNodes(app.graph, "distributeV") },
|
|
],
|
|
},
|
|
});
|
|
}
|
|
return options;
|
|
};
|
|
}
|
|
},
|
|
|
|
nodeCreated(node) {
|
|
if (node.comfyClass !== "WorkflowPrettifier") return;
|
|
|
|
node.color = "#2a363b";
|
|
node.bgcolor = "#1a252a";
|
|
|
|
// Prettify button
|
|
const prettifyBtn = node.addWidget("button", "Prettify!", "prettify");
|
|
prettifyBtn.serialize = false;
|
|
prettifyBtn.callback = () => {
|
|
const graph = app.graph;
|
|
if (!graph?._nodes?.length && !graph?.nodes?.length) return;
|
|
pushUndo(graph);
|
|
runPrettify(graph, readNodeOpts(node));
|
|
refreshCanvas(graph);
|
|
};
|
|
|
|
// Equalize Spacing button
|
|
const eqBtn = node.addWidget("button", "Equalize Spacing", "equalize");
|
|
eqBtn.serialize = false;
|
|
eqBtn.callback = () => {
|
|
const graph = app.graph;
|
|
if (!graph?._nodes?.length && !graph?.nodes?.length) return;
|
|
equalizeSpacing(graph, readNodeOpts(node));
|
|
};
|
|
|
|
// Undo button
|
|
const undoBtn = node.addWidget("button", "Undo", "undo");
|
|
undoBtn.serialize = false;
|
|
undoBtn.callback = () => {
|
|
if (app.graph) popUndo(app.graph);
|
|
};
|
|
|
|
node.size[0] = Math.max(node.size[0], 340);
|
|
|
|
// ── Collapsible description panel via toggle button + custom draw widget ──
|
|
let detailsOpen = false;
|
|
|
|
const LAYOUT_HINTS = {
|
|
"Layered (Vertical Stacks)": "DAG columns — nodes grouped by depth, stacked vertically. Best for standard workflows with branching pipelines.",
|
|
"Linear": "Single row in topological execution order. Good for simple linear pipelines with few branches.",
|
|
"Compact (Tight Rectangle)": "Packs nodes into the smallest rectangle possible. Ignores connections — pure space optimization.",
|
|
"Sort by Type": "Groups identical node types into columns, ordered by pipeline depth. Great for spotting patterns and duplicates.",
|
|
};
|
|
|
|
const DIR_HINTS = {
|
|
"Left to Right": "Standard left-to-right data flow.",
|
|
"Top to Bottom": "Vertical top-to-bottom flow — good for tall monitors.",
|
|
"Right to Left": "Reversed right-to-left flow.",
|
|
};
|
|
|
|
const GROUP_HINTS = {
|
|
"Auto (Respect Groups)": "Detects groups automatically. Nodes inside groups are arranged within the group, then groups are positioned as blocks.",
|
|
"Respect Groups": "Forces group-aware layout even if detection is ambiguous.",
|
|
"Ignore Groups": "Treats all nodes as ungrouped — flat layout across the entire canvas.",
|
|
};
|
|
|
|
function wrapText(ctx, text, maxWidth) {
|
|
const words = text.split(" ");
|
|
const lines = [];
|
|
let current = "";
|
|
for (const word of words) {
|
|
const test = current ? current + " " + word : word;
|
|
if (ctx.measureText(test).width > maxWidth && current) {
|
|
lines.push(current);
|
|
current = word;
|
|
} else {
|
|
current = test;
|
|
}
|
|
}
|
|
if (current) lines.push(current);
|
|
return lines;
|
|
}
|
|
|
|
// Toggle button
|
|
const toggleBtn = node.addWidget("button", "▸ Details", "details");
|
|
toggleBtn.serialize = false;
|
|
toggleBtn.callback = () => {
|
|
detailsOpen = !detailsOpen;
|
|
toggleBtn.name = detailsOpen ? "▾ Hide Details" : "▸ Details";
|
|
if (detailsOpen) {
|
|
node._collapsedHeight = node.size[1];
|
|
node.size[1] += detailsWidget._panelHeight || 120;
|
|
} else {
|
|
node.size[1] = node._collapsedHeight || node.size[1];
|
|
}
|
|
refreshCanvas(app.graph);
|
|
};
|
|
|
|
// Custom draw widget that renders the description panel
|
|
const detailsWidget = node.addCustomWidget({
|
|
name: "details_panel",
|
|
type: "custom",
|
|
value: "",
|
|
serialize: false,
|
|
_panelHeight: 0,
|
|
|
|
draw(ctx, _node, widgetWidth, y, widgetHeight) {
|
|
if (!detailsOpen) {
|
|
this._panelHeight = 0;
|
|
return;
|
|
}
|
|
|
|
const pad = 8;
|
|
const w = widgetWidth - pad * 2;
|
|
const lineH = 14;
|
|
const font = "11px sans-serif";
|
|
const boldFont = "bold 11px sans-serif";
|
|
|
|
const layout = _node.widgets?.find((wg) => wg.name === "layout")?.value;
|
|
const dir = _node.widgets?.find((wg) => wg.name === "direction")?.value;
|
|
const grp = _node.widgets?.find((wg) => wg.name === "group_handling")?.value;
|
|
|
|
// Pre-calculate all lines
|
|
ctx.font = font;
|
|
const layoutLines = wrapText(ctx, LAYOUT_HINTS[layout] ?? "", w - 8);
|
|
const dirLines = wrapText(ctx, DIR_HINTS[dir] ?? "", w - 8);
|
|
const grpLines = wrapText(ctx, GROUP_HINTS[grp] ?? "", w - 8);
|
|
|
|
const totalLines = 3 + layoutLines.length + dirLines.length + grpLines.length + 2;
|
|
const panelH = totalLines * lineH + 20;
|
|
this._panelHeight = panelH;
|
|
|
|
// Background
|
|
ctx.fillStyle = "rgba(0,0,0,0.3)";
|
|
ctx.beginPath();
|
|
ctx.roundRect(pad, y + 2, w, panelH, 5);
|
|
ctx.fill();
|
|
|
|
let cy = y + lineH + 6;
|
|
|
|
const drawSection = (title, lines, color) => {
|
|
ctx.font = boldFont;
|
|
ctx.fillStyle = color;
|
|
ctx.fillText(title, pad + 6, cy);
|
|
cy += lineH;
|
|
|
|
ctx.font = font;
|
|
ctx.fillStyle = "#bbb";
|
|
for (const line of lines) {
|
|
ctx.fillText(line, pad + 10, cy);
|
|
cy += lineH;
|
|
}
|
|
cy += 6;
|
|
};
|
|
|
|
drawSection("Layout", layoutLines, "#8ac");
|
|
drawSection("Direction", dirLines, "#8ca");
|
|
drawSection("Groups", grpLines, "#a8c");
|
|
|
|
// Footer
|
|
ctx.fillStyle = "#777";
|
|
ctx.font = font;
|
|
ctx.fillText(`Undo: ${undoStack.length}/${MAX_UNDO} · Select 2+ nodes → right-click → Align`, pad + 6, cy);
|
|
},
|
|
|
|
computeSize() {
|
|
if (!detailsOpen) return [0, 0];
|
|
return [340, this._panelHeight || 120];
|
|
},
|
|
});
|
|
},
|
|
});
|