Files
ausboss-ComfyUI-AusBoss/js/shared/panel_layout.mjs
T
ausboss 5a4674f5f6 Hold each node's minimum width
Every node stops at the width it was built for when it is dragged narrower:
the growing panels and the fixed cards. Load Video's IN and OUT boxes stay
whole in a narrow node.

The frontend adds 104 pixels to a growing panel's minimum width and leaves a
fixed-height card's minimum out of its width sum, so a card fell to a 210
pixel default. Panels pass exactMinWidth to fillNodeHeight or call
holdNodeMinWidth, and tests/panel_guards.test.mjs fails a new panel that does
neither.
2026-10-05 09:35:15 -05:00

190 lines
8.6 KiB
JavaScript

// How a DOM panel claims its share of a node's height. No DOM and no
// ComfyUI imports in here, so it stays testable under node:test.
//
// The frontend arranges a node's widgets in one pass
// (LGraphNode._arrangeWidgets):
//
// if (w.computeSize) -> fixed height, kept OUT of the split
// else if (w.computeLayoutSize) -> joins distributeSpace(freeSpace, ...)
// else -> one standard widget row
//
// It is an else-if, so a widget declaring BOTH is pinned by computeSize and
// its computeLayoutSize is never called. Every stage, player and filmstrip in
// this pack derived that fixed height from the node's WIDTH, which is why
// dragging a node taller only added dead space underneath: the panel had
// already been given a height and excluded from the leftover-space split.
//
// The frontend mounts a DOM widget's element inside a frame: DomWidgets.vue
// insets it by `options.margin` per side (default 10), so the element gets
// 20 fewer CSS pixels of height than the layout hands the widget. Any floor
// meant to guarantee room for fixed-height content must add this allowance,
// or the panel's bottom edge renders clipped flat - which is how the LoRA
// stack's rounded bottom border once went missing.
export const WIDGET_FRAME = 20;
// The frontend pads a DOM widget's minWidth before it becomes the node's
// floor: LGraphNode.computeSize adds the room a number widget's value box
// takes (BaseWidget.minValueWidth plus both arrows and margins - 104px on
// frontend 1.53), and a corner drag never goes below computeSize. So a
// panel declaring 320 cannot be dragged under 424, and a node that opened
// at 340 jumps to 444 the moment its corner is touched. This measures that
// padding from the frontend itself instead of copying its constants: a
// probe minWidth goes in, and whatever computeSize adds on top comes out.
// null when it cannot be measured right now (the widget is hidden, or the
// node cannot size itself), so the caller tries again later.
const PROBE_WIDTH = 100000;
let probing = false;
export function measureLayoutWidthPadding(node, widget) {
if (probing || typeof node?.computeSize !== "function" || !widget) return null;
const own = widget.computeLayoutSize;
probing = true;
widget.computeLayoutSize = () => ({ minWidth: PROBE_WIDTH, minHeight: 0 });
let width = NaN;
try {
width = Number(node.computeSize()?.[0]);
} catch {
// A node that cannot size itself right now is measured next time.
} finally {
widget.computeLayoutSize = own;
probing = false;
}
return Number.isFinite(width) && width >= PROBE_WIDTH ? width - PROBE_WIDTH : null;
}
// The padding is the frontend's, the same for every node: measured once.
let layoutWidthPadding = null;
const resolveWidth = (value) => {
const resolved = Number(typeof value === "function" ? value() : value);
return Number.isFinite(resolved) ? Math.max(0, resolved) : 0;
};
// A node can carry several panels (a card and a video viewer), each with its
// own minimum, and its floor is the widest of them. Every panel registers
// its number here, keyed by the widget, and the same tally feeds both
// renderers, so the two never fight over one node.
const FLOORS = Symbol("ausboss.widthFloors");
const WATCHED = Symbol("ausboss.floorWatched");
function nodeWidthFloor(node) {
let floor = 0;
for (const value of node?.[FLOORS]?.values() ?? []) floor = Math.max(floor, resolveWidth(value));
return floor;
}
// The classic renderer's corner drag never goes below node.computeSize(),
// and computeSize skips a widget's computeLayoutSize whenever the widget also
// pins its own computeSize (the fixed-height cards do): their declared
// minimum was never a floor at all, and such a node dragged down to the
// frontend's 210px default with the card's buttons cut off. Wrapping the
// node's own computeSize makes the tally the floor for every panel alike.
function holdClassicFloor(node) {
const inherited = node.computeSize;
if (typeof inherited !== "function") return;
node.computeSize = function (out) {
const size = inherited.call(this, out);
const floor = nodeWidthFloor(this);
if (size && size[0] < floor) size[0] = floor;
return size;
};
}
// Make this panel's minimum part of its node's floor, in both renderers.
// minWidth is a number or a function, for a floor that follows state. The
// node's width is then never below it: not by a corner drag, and, for a
// pinned card, not by the frontend's own default either. exactMinWidth on
// fillNodeHeight calls this too; a panel that pins its height (Seed, Save
// Image, the fixed widget cards) calls it directly.
export function holdNodeMinWidth(widget, minWidth) {
const node = widget?.node;
if (!node) return widget;
if (!node[FLOORS]) {
node[FLOORS] = new Map();
holdClassicFloor(node);
}
node[FLOORS].set(widget, minWidth);
const panel = widget.element;
if (panel && !panel[WATCHED] && typeof ResizeObserver === "function") {
panel[WATCHED] = true;
// The first size arrives once the panel is laid out inside the node,
// which is when a Nodes 2.0 node element exists to carry the minimum.
new ResizeObserver(() => holdVueNodeMinWidth(panel, nodeWidthFloor(node))).observe(panel);
}
return widget;
}
// distributeSpace reads a missing maxSize as Infinity, so declaring a floor
// with no ceiling means "take whatever is left" - which is exactly "fill the
// node". minWidth/minHeight accept a number or a function, for panels whose
// floor depends on state (the frame chooser is shorter until it has frames).
// exactMinWidth: true makes minWidth the node's real floor (see above), in
// Nodes 2.0 as well; without it the frontend's padding comes on top, as it
// always has. Every panel in the pack passes it (tests/panel_guards.test.mjs).
export function fillNodeHeight(widget, { minWidth = 0, minHeight = 0, minNodeSize, exactMinWidth = false } = {}) {
if (!widget) return widget;
const floor = resolveWidth;
const padding = (node) => {
if (!exactMinWidth) return 0;
if (layoutWidthPadding === null) {
layoutWidthPadding = measureLayoutWidthPadding(node ?? widget.node, widget);
}
return layoutWidthPadding ?? 0;
};
// Deleted, not overwritten: any own computeSize would win the else-if above.
delete widget.computeSize;
widget.computeLayoutSize = (node) => ({
minWidth: Math.max(0, floor(minWidth) - padding(node)),
minHeight: floor(minHeight),
});
widget.options ??= {};
if (minNodeSize) widget.options.minNodeSize = minNodeSize;
if (exactMinWidth) holdNodeMinWidth(widget, minWidth);
return widget;
}
// Nodes 2.0 (the Vue renderer) has no layout API for a node's minimum
// width: its corner drag stops at the node element's inline min-width, and
// at 225px when there is none. Lend it the panel's floor whenever the panel
// is laid out inside a Vue node. The classic renderer mounts panels outside
// any [data-node-id] element, so there this does nothing.
export function holdVueNodeMinWidth(panel, width) {
const host = panel?.closest?.("[data-node-id]");
const value = Number(width);
if (!host?.style || !Number.isFinite(value) || value <= 0) return false;
const css = `${Math.round(value)}px`;
if (host.style.minWidth === css) return false;
host.style.minWidth = css;
return true;
}
// Grow a node to the height its widgets ask for. A workflow saved before a
// panel or card existed carries the node's old size, and the frontend keeps
// that size on load - the new panel is then squeezed under its floor and
// clipped flat. Returns true when the node was resized.
export function ensureNodeMinHeight(node) {
const min = Number(node?.computeSize?.()?.[1]);
const current = Number(node?.size?.[1]);
if (!Number.isFinite(min) || !Number.isFinite(current) || current >= min) return false;
node.setSize?.([node.size[0], min]);
return true;
}
// Lift a node's computeSize() height to a floor the panel measures, such as
// the height a card's text needs. A corner drag stops at computeSize(), so
// the node cannot be dragged shorter than that. The floor is read on every
// call; anything but a finite number leaves the frontend's own height.
// This wraps the node's own method, not the prototype and not the widget,
// so the panel still takes all the height above its floor.
export function holdNodeMinHeight(node, floor) {
const own = node?.computeSize;
if (typeof own !== "function" || typeof floor !== "function") return node;
node.computeSize = function (...args) {
const size = own.apply(this, args);
const min = Number(floor());
if (size && Number.isFinite(min) && min > size[1]) size[1] = min;
return size;
};
return node;
}