Files
ausboss 52c3353c69 Resize together: rows share height, grids share both
How the selection is laid out decides what it shares: nodes stacked in a
column get the grabbed node's width, nodes side by side in a row get its
height, and a grid gets both. A pull on the top edge keeps each bottom
edge in place.
2026-11-03 14:22:46 -06:00

529 lines
21 KiB
JavaScript

import { app } from "/scripts/app.js";
import { BRAND, notifyAusbossChange, showToast } from "../shared/index.mjs";
import { enoughFor, lineUp, matchWidth, sameSize, sharedSides, spreadEvenly } from "../shared/line_up.mjs";
import { outlineOf } from "../shared/outline.mjs";
// Line up: with two or more nodes or groups selected, ComfyUI's selection
// toolbar gets one button that pops out a small panel. The panel lines the
// selection up, gives it one width or height, or spaces it evenly. Every tool
// is also a command, so it can be found in the command palette and given a key.
//
// Resize together: with two or more nodes selected, pulling the edge or corner
// of one resizes them all, in both looks. A column shares the width, a row
// shares the height, and a grid shares both.
//
// A node inside a selected group moves with its group, the way a drag moves
// it. Pinned items count when the selection is measured but never move.
const PANEL = "AusBoss.LineUp.Panel";
const SETTING = "AusBoss.Canvas.LineUpButton";
const TOGETHER_SETTING = "AusBoss.Canvas.ResizeTogether";
const ICON_CLASS = "ausboss-line-up-icon";
const TOOLS = [
{ kind: "lineUp", how: "left", id: "AusBoss.LineUp.Left", label: "Line up left edges" },
{ kind: "lineUp", how: "middleX", id: "AusBoss.LineUp.MiddleX", label: "Line up middles in a column" },
{ kind: "lineUp", how: "right", id: "AusBoss.LineUp.Right", label: "Line up right edges" },
{ kind: "lineUp", how: "top", id: "AusBoss.LineUp.Top", label: "Line up top edges" },
{ kind: "lineUp", how: "middleY", id: "AusBoss.LineUp.MiddleY", label: "Line up middles in a row" },
{ kind: "lineUp", how: "bottom", id: "AusBoss.LineUp.Bottom", label: "Line up bottom edges" },
{ kind: "size", how: "widest", id: "AusBoss.LineUp.Widest", label: "Same width as the widest" },
{ kind: "size", how: "narrowest", id: "AusBoss.LineUp.Narrowest", label: "Same width as the narrowest" },
{ kind: "size", how: "tallest", id: "AusBoss.LineUp.Tallest", label: "Same height as the tallest" },
{ kind: "size", how: "shortest", id: "AusBoss.LineUp.Shortest", label: "Same height as the shortest" },
{ kind: "spacing", how: "across", id: "AusBoss.LineUp.SpaceAcross", label: "Even spacing across" },
{ kind: "spacing", how: "down", id: "AusBoss.LineUp.SpaceDown", label: "Even spacing down" },
];
const SECTIONS = [
{ title: "Line up", kind: "lineUp" },
{ title: "Same size", kind: "size" },
{ title: "Even spacing", kind: "spacing" },
];
// ---------------------------------------------------------------------------
// Icons: grey boxes in the text color, and the line or arrows they line up on
// in the pack's teal, the color of the snap lines.
// ---------------------------------------------------------------------------
const box = (x, y, w, h) => ({ x, y, w, h });
const turn = (parts) => ({
boxes: parts.boxes.map(({ x, y, w, h }) => box(y, x, h, w)),
lines: parts.lines.map(([x1, y1, x2, y2]) => [y1, x1, y2, x2]),
heads: parts.heads.map(([x, y, dir]) => [y, x, { l: "u", r: "d", u: "l", d: "r" }[dir]]),
});
const WIDEST = {
boxes: [box(3, 4, 18, 5), box(8, 15, 8, 5)],
lines: [[7, 12, 3.5, 12], [17, 12, 20.5, 12]],
heads: [[3.5, 12, "l"], [20.5, 12, "r"]],
};
const NARROWEST = {
boxes: [box(8, 4, 8, 5), box(3, 15, 18, 5)],
lines: [[3, 12, 7, 12], [21, 12, 17, 12]],
heads: [[7, 12, "r"], [17, 12, "l"]],
};
const ACROSS = {
boxes: [box(2, 7, 4.5, 10), box(9.75, 7, 4.5, 10), box(17.5, 7, 4.5, 10)],
lines: [[7.5, 12, 8.75, 12], [15.25, 12, 16.5, 12], [7.5, 9.5, 7.5, 14.5], [8.75, 9.5, 8.75, 14.5], [15.25, 9.5, 15.25, 14.5], [16.5, 9.5, 16.5, 14.5]],
heads: [],
};
const ICONS = {
left: { boxes: [box(7, 5, 13, 5), box(7, 14, 8, 5)], lines: [[4, 3, 4, 21]], heads: [] },
middleX: { boxes: [box(5, 5, 14, 5), box(8, 14, 8, 5)], lines: [[12, 3, 12, 21]], heads: [] },
right: { boxes: [box(4, 5, 13, 5), box(9, 14, 8, 5)], lines: [[20, 3, 20, 21]], heads: [] },
top: { boxes: [box(5, 7, 5, 13), box(14, 7, 5, 8)], lines: [[3, 4, 21, 4]], heads: [] },
middleY: { boxes: [box(5, 5, 5, 14), box(14, 8, 5, 8)], lines: [[3, 12, 21, 12]], heads: [] },
bottom: { boxes: [box(5, 4, 5, 13), box(14, 9, 5, 8)], lines: [[3, 20, 21, 20]], heads: [] },
widest: WIDEST,
narrowest: NARROWEST,
tallest: turn(WIDEST),
shortest: turn(NARROWEST),
across: ACROSS,
down: turn(ACROSS),
};
function headPath([x, y, dir]) {
const s = 2.6;
const points = {
l: [[x + s, y - s], [x, y], [x + s, y + s]],
r: [[x - s, y - s], [x, y], [x - s, y + s]],
u: [[x - s, y + s], [x, y], [x + s, y + s]],
d: [[x - s, y - s], [x, y], [x + s, y - s]],
}[dir];
return `M${points.map((p) => p.join(" ")).join(" L")}`;
}
function boxesSvg(parts, color, fillOpacity) {
return parts.boxes
.map(({ x, y, w, h }) => `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="1.4" fill="${color}" fill-opacity="${fillOpacity}" stroke="${color}" stroke-width="1.5"/>`)
.join("");
}
function marksSvg(parts, color) {
const lines = parts.lines.map(([x1, y1, x2, y2]) => `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}"/>`).join("");
const heads = parts.heads.map((head) => `<path d="${headPath(head)}" fill="none"/>`).join("");
return `<g stroke="${color}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${lines}${heads}</g>`;
}
function iconSvg(how) {
const parts = ICONS[how];
return `<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">${boxesSvg(parts, "currentColor", 0.2)}${marksSvg(parts, BRAND)}</svg>`;
}
// The toolbar button is an <i> with a class, so its icon is drawn in CSS:
// the boxes take the button's text color, the line stays teal.
function maskUrl(body) {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">${body}</svg>`;
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
}
function styleSheet() {
const parts = ICONS.left;
const boxes = maskUrl(boxesSvg(parts, "black", 0.25));
const marks = maskUrl(marksSvg(parts, "black"));
return `
.${ICON_CLASS} { position: relative; display: inline-block; width: 1rem; height: 1rem; }
.${ICON_CLASS}::before, .${ICON_CLASS}::after {
content: ""; position: absolute; inset: 0;
-webkit-mask: var(--ausboss-mask) center / contain no-repeat; mask: var(--ausboss-mask) center / contain no-repeat;
}
.${ICON_CLASS}::before { --ausboss-mask: ${boxes}; background: currentColor; }
.${ICON_CLASS}::after { --ausboss-mask: ${marks}; background: ${BRAND}; }
.ausboss-line-up {
position: fixed; z-index: 1000; padding: 6px 6px 4px; border-radius: 10px;
background: var(--interface-panel-surface, #1f2225); color: var(--base-foreground, #e6edf3);
border: 1px solid var(--interface-stroke, #3a3f44); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
font-size: 11px; line-height: 1.2; user-select: none;
}
.ausboss-line-up-title { margin: 2px 6px 3px; color: var(--muted-foreground, #8b949e); }
.ausboss-line-up-row { display: flex; gap: 2px; }
.ausboss-line-up-rule { height: 1px; margin: 5px 2px; background: var(--interface-stroke, #30363d); }
.ausboss-line-up button {
display: grid; place-items: center; width: 30px; height: 30px; padding: 0;
border: 0; border-radius: 6px; background: transparent; color: inherit; cursor: pointer;
}
.ausboss-line-up button:hover:not(:disabled) { background: color-mix(in srgb, ${BRAND} 18%, transparent); }
.ausboss-line-up button:focus-visible { outline: 1px solid ${BRAND}; }
.ausboss-line-up button:disabled { opacity: 0.35; cursor: default; }
`;
}
// ---------------------------------------------------------------------------
// The selection
// ---------------------------------------------------------------------------
function graphSets(graph) {
return {
nodes: new Set(graph?._nodes ?? graph?.nodes ?? []),
groups: new Set(graph?._groups ?? graph?.groups ?? []),
};
}
function pickedCount(canvas) {
const { nodes, groups } = graphSets(canvas?.graph);
let count = 0;
for (const item of canvas?.selectedItems ?? []) if (nodes.has(item) || groups.has(item)) count++;
return count;
}
// The selected nodes and groups that move on their own, each with the
// outline people see. A node or group inside a selected group is left out:
// it rides along with that group.
function selectionUnits(canvas) {
const { nodes, groups } = graphSets(canvas?.graph);
const picked = [...(canvas?.selectedItems ?? [])].filter((item) => nodes.has(item) || groups.has(item));
const carried = new Set();
for (const item of picked) {
if (!groups.has(item)) continue;
item.recomputeInsideNodes?.();
for (const child of item.children ?? item._children ?? []) carried.add(child);
}
const units = [];
for (const item of picked) {
if (carried.has(item)) continue;
const group = groups.has(item);
if (!group) item.updateArea?.(canvas.ctx); // fresh after a size change this frame
const rect = outlineOf(item, canvas, { node: !group });
if (rect) units.push({ item, group, rect });
}
return units;
}
function isPinned(item) {
return item.pinned === true || item.flags?.pinned === true;
}
function moveUnit({ item, group, rect }, { x, y }) {
const dx = x - rect.x;
const dy = y - rect.y;
if ((!dx && !dy) || isPinned(item)) return;
if (group) item.move(dx, dy);
else item.pos = [item.pos[0] + dx, item.pos[1] + dy];
}
function resizeUnit({ item, group, rect }, { w, h }) {
if (isPinned(item) || item.flags?.collapsed) return;
if (w === rect.w && h === rect.h) return;
if (group) {
item.resize(w, h);
return;
}
// A node's outline includes its title bar and any frame drawn around it;
// its size does not, so set the size that gives the outline asked for.
const extraW = rect.w - item.size[0];
const extraH = rect.h - item.size[1];
const least = item.computeSize?.() ?? [0, 0];
item.setSize([Math.max(w - extraW, least[0]), Math.max(h - extraH, least[1])]);
}
function run(tool) {
const canvas = app.canvas;
if (!canvas?.graph) return;
const units = selectionUnits(canvas);
if (!enoughFor(tool.kind, units.length)) {
showToast({ detail: `Select ${tool.kind === "spacing" ? "three" : "two"} or more nodes or groups first.`, life: 3000 });
return;
}
const rects = units.map((unit) => unit.rect);
if (tool.kind === "size") {
const sizes = sameSize(rects, tool.how);
units.forEach((unit, index) => resizeUnit(unit, sizes[index]));
} else {
const places = tool.kind === "lineUp" ? lineUp(rects, tool.how) : spreadEvenly(rects, tool.how);
units.forEach((unit, index) => moveUnit(unit, places[index]));
}
canvas.setDirty?.(true, true);
notifyAusbossChange();
refreshPanel(true);
}
// ---------------------------------------------------------------------------
// Resize together
// ---------------------------------------------------------------------------
//
// The frontend resizes only the node under the pointer: the canvas's own
// pointermove in the classic look, a pointermove on the window in Nodes 2.0.
// This listens on the window and catches up a frame later, so it always reads
// the size the frontend (and Snap into line) settled on. Which sizes are
// shared comes from how the selection was laid out when the button went down
// (sharedSides in js/shared/line_up.mjs). The grabbed node is
// the canvas's resizing node, or in Nodes 2.0 the node whose corner handle
// took the press. Every other selected node takes its size from where it
// stood when the button went down, so nothing builds up across steps.
const together = { enabled: true, before: null, grabbed: null, handleOf: null, sides: null, frame: 0 };
function resizableSelection(canvas) {
const { nodes } = graphSets(canvas?.graph);
return [...(canvas?.selectedItems ?? [])].filter(
(item) => nodes.has(item) && item.size && item.pos && !isPinned(item) && !item.flags?.collapsed,
);
}
function onTogetherDown(event) {
endTogether();
if (event.button !== 0 || !together.enabled) return;
const picked = resizableSelection(app.canvas);
if (picked.length < 2) return;
together.before = new Map(picked.map((node) => [node, { pos: [node.pos[0], node.pos[1]], size: [node.size[0], node.size[1]] }]));
// Nodes 2.0 draws a corner handle on each node (a div with data-corner).
const handle = event.target?.closest?.("[data-corner]");
together.handleOf = handle?.closest?.("[data-node-id]")?.dataset?.nodeId ?? null;
}
function grabbedNode(canvas) {
const classic = canvas?.resizing_node;
if (classic) return together.before.has(classic) ? classic : null;
if (together.handleOf == null) return null;
for (const node of together.before.keys()) if (String(node.id) === together.handleOf) return node;
return null;
}
function syncSizes() {
const canvas = app.canvas;
if (!together.before || !canvas?.graph) return;
together.grabbed ??= grabbedNode(canvas);
const grabbed = together.grabbed;
if (!grabbed) return;
const was = together.before.get(grabbed);
together.sides ??= sharedSides(
[...together.before.values()].map(({ pos, size }) => ({ x: pos[0], y: pos[1], w: size[0], h: size[1] })),
);
const others = [...together.before].filter(([node]) => node !== grabbed);
const least = others.map(([node]) => node.computeSize?.() ?? [0, 0]);
// Heights go through the same rule as widths, with y for x.
const widths = together.sides.width
? matchWidth(
grabbed.size[0],
others.map(([, start], i) => ({ x: start.pos[0], w: start.size[0], least: least[i][0] })),
{ fromLeft: grabbed.pos[0] !== was.pos[0] },
)
: null;
const heights = together.sides.height
? matchWidth(
grabbed.size[1],
others.map(([, start], i) => ({ x: start.pos[1], w: start.size[1], least: least[i][1] })),
{ fromLeft: grabbed.pos[1] !== was.pos[1] },
)
: null;
let moved = false;
others.forEach(([node], index) => {
const x = widths ? widths[index].x : node.pos[0];
const y = heights ? heights[index].x : node.pos[1];
const w = widths ? widths[index].w : node.size[0];
const h = heights ? heights[index].w : node.size[1];
if (node.pos[0] !== x || node.pos[1] !== y) {
node.pos = [x, y];
moved = true;
}
if (node.size[0] !== w || node.size[1] !== h) {
node.setSize([w, h]);
moved = true;
}
});
if (moved) canvas.setDirty?.(true, true);
}
function onTogetherMove(event) {
if (!together.before) return;
if (!(event.buttons & 1)) return endTogether();
if (together.frame) return;
together.frame = requestAnimationFrame(() => {
together.frame = 0;
syncSizes();
});
}
function onTogetherUp() {
if (together.before) syncSizes(); // the last step, before the frontend records the change
endTogether();
}
function endTogether() {
if (together.frame) cancelAnimationFrame(together.frame);
together.frame = 0;
together.before = null;
together.grabbed = null;
together.handleOf = null;
together.sides = null;
}
function installTogether() {
window.addEventListener("pointerdown", onTogetherDown, true);
window.addEventListener("pointermove", onTogetherMove);
// Capture phase: the classic canvas stops its pointerup from bubbling.
window.addEventListener("pointerup", onTogetherUp, true);
window.addEventListener("pointercancel", endTogether, true);
window.addEventListener("blur", endTogether);
}
// ---------------------------------------------------------------------------
// The panel
// ---------------------------------------------------------------------------
const panel = { el: null, frame: 0, buttons: [], count: -1 };
function anchorButton() {
return document.querySelector(`.selection-toolbox .${ICON_CLASS}`)?.closest("button") ?? null;
}
function labelAnchor() {
const button = anchorButton();
if (!button || button.title) return;
button.title = "Line up, same size, even spacing";
button.setAttribute("aria-label", button.title);
}
function buildPanel() {
const el = document.createElement("div");
el.className = "ausboss-line-up";
el.setAttribute("role", "toolbar");
el.setAttribute("aria-label", "Line up the selection");
panel.buttons = [];
SECTIONS.forEach((section, index) => {
if (index) el.append(Object.assign(document.createElement("div"), { className: "ausboss-line-up-rule" }));
el.append(Object.assign(document.createElement("div"), { className: "ausboss-line-up-title", textContent: section.title }));
const row = Object.assign(document.createElement("div"), { className: "ausboss-line-up-row" });
for (const tool of TOOLS.filter((t) => t.kind === section.kind)) {
const button = document.createElement("button");
button.type = "button";
button.title = tool.label;
button.setAttribute("aria-label", tool.label);
button.innerHTML = iconSvg(tool.how);
button.addEventListener("click", () => run(tool));
panel.buttons.push({ button, tool });
row.append(button);
}
el.append(row);
});
return el;
}
function refreshPanel(force = false) {
if (!panel.el) return;
const count = pickedCount(app.canvas);
if (!force && count === panel.count) return;
panel.count = count;
const units = selectionUnits(app.canvas).length;
for (const { button, tool } of panel.buttons) button.disabled = !enoughFor(tool.kind, units);
}
// Above the selection toolbar, or below it when there is no room above. The
// toolbar follows the selection, so the panel follows the toolbar.
function placePanel() {
const anchor = anchorButton();
if (!panel.el || !anchor) return closePanel();
const bar = (anchor.closest(".selection-toolbox") ?? anchor).getBoundingClientRect();
const at = anchor.getBoundingClientRect();
const size = panel.el.getBoundingClientRect();
const left = Math.min(Math.max(8, at.left + at.width / 2 - size.width / 2), window.innerWidth - size.width - 8);
let top = bar.top - size.height - 6;
if (top < 8) top = bar.bottom + 6;
panel.el.style.left = `${Math.round(left)}px`;
panel.el.style.top = `${Math.round(top)}px`;
refreshPanel();
}
function follow() {
panel.frame = 0;
if (!panel.el) return;
placePanel();
if (panel.el) panel.frame = requestAnimationFrame(follow);
}
function openPanel() {
if (panel.el || !anchorButton()) return;
panel.el = buildPanel();
panel.count = -1;
document.body.append(panel.el);
window.addEventListener("pointerdown", onOutside, true);
window.addEventListener("keydown", onKey, true);
follow();
}
function closePanel() {
if (!panel.el) return;
panel.el.remove();
panel.el = null;
panel.buttons = [];
if (panel.frame) cancelAnimationFrame(panel.frame);
panel.frame = 0;
window.removeEventListener("pointerdown", onOutside, true);
window.removeEventListener("keydown", onKey, true);
}
function onOutside(event) {
if (!panel.el || panel.el.contains(event.target)) return;
if (anchorButton()?.contains(event.target)) return; // its own click closes the panel
closePanel();
}
function onKey(event) {
if (event.key === "Escape") closePanel();
}
function togglePanel() {
if (panel.el) closePanel();
else openPanel();
}
function buttonOn() {
return app.ui?.settings?.getSettingValue?.(SETTING) !== false;
}
app.registerExtension({
name: "AusBoss.LineUp",
commands: [
{ id: PANEL, label: "AusBoss: Line up, same size, even spacing", icon: ICON_CLASS, function: togglePanel },
...TOOLS.map((tool) => ({ id: tool.id, label: `AusBoss: ${tool.label}`, function: () => run(tool) })),
],
getSelectionToolboxCommands() {
if (!buttonOn() || pickedCount(app.canvas) < 2) return [];
// The toolbar names extension buttons from its own translations, which
// have no entry for this one; name it once it is on the page.
requestAnimationFrame(labelAnchor);
return [PANEL];
},
settings: [
{
id: SETTING,
name: "Line-up button on the selection toolbar",
type: "boolean",
defaultValue: true,
category: ["🆎 AusBoss", "Canvas", "Line-up button"],
tooltip:
"With two or more nodes or groups selected, the toolbar over them gets a button " +
"that lines them up, gives them one size or spaces them evenly.",
onChange(value) {
if (value === false) closePanel();
},
},
{
id: TOGETHER_SETTING,
name: "Resize selected nodes together",
type: "boolean",
defaultValue: true,
category: ["🆎 AusBoss", "Canvas", "Resize together"],
tooltip:
"With two or more nodes selected, pulling the edge or corner of one resizes them all: " +
"a column gets one width, a row one height, a grid both.",
onChange(value) {
together.enabled = value !== false;
if (!together.enabled) endTogether();
},
},
],
setup() {
const style = document.createElement("style");
style.id = "ausboss-line-up-style";
style.textContent = styleSheet();
document.head.append(style);
// onChange only fires on later edits on some frontends, so read the
// stored value here.
together.enabled = app.ui?.settings?.getSettingValue?.(TOGETHER_SETTING) !== false;
installTogether();
},
});