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.
190 lines
8.6 KiB
JavaScript
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;
|
|
}
|