Files
ausboss-ComfyUI-AusBoss/tests/fill_node_height.test.mjs
ausboss 2e06f01c97 Panels: keep their minimum height in Nodes 2.0
In Nodes 2.0 a node with a picture panel could be dragged shorter than the
panel's minimum. A corner drag took Mask Refine from 356 to 218 px and left
its picture a 2 px line, Save Image lost its saved picture, and Latent Size
lost its whole stage. The classic renderer stops at the panel's floor.

The classic floor is the minHeight a panel declares through fillNodeHeight.
Nodes 2.0 lays a node out by its content and does not use that number, so a
panel whose content has no height of its own had nothing to stop at.
fillNodeHeight now also writes the floor, less the DOM widget frame, to the
panel's element as a CSS min-height while the element sits inside a Nodes
2.0 node. It is written whenever the floor is read and when the panel is
first laid out, so a floor that follows state is followed: with the preview
switched off the node still shrinks to the bar.

The preview panel's stage also carries its own floor inside a Nodes 2.0
node. That panel's declared floor holds less frame than the shared number
takes off, and without the rule its nodes stopped 10 px under the height
ComfyUI lifts them to on the next load.

The classic renderer is unchanged.
2026-10-06 14:48:50 -05:00

164 lines
7.5 KiB
JavaScript

// What fillNodeHeight promises the frontend, checked on our own code.
//
// The frontend lays out a node's widgets in one pass. A widget that reports
// its own size (computeSize) gets exactly that height and takes no part in
// sharing out the rest. A widget without it, that instead describes a
// minimum (computeLayoutSize), shares the height left over, and with no
// maximum given it may take all of it. So a panel that should follow the
// node's height must end up with no computeSize and a floor with no ceiling,
// whatever it had before. The live proof is on the canvas (see
// docs/live_testing.md); these tests hold the helper to that contract.
import assert from "node:assert/strict";
import test from "node:test";
import { WIDGET_FRAME, fillNodeHeight } from "../js/shared/panel_layout.mjs";
import { hideWidget, showWidget } from "../js/shared/widget_visibility.mjs";
const pinned = () => ({
computeSize: () => [400, 200],
computeLayoutSize: () => ({ minWidth: 320, minHeight: 144 }),
});
test("a panel that sized itself stops doing so", () => {
const panel = fillNodeHeight(pinned(), { minWidth: 320, minHeight: 144 });
assert.equal(Object.hasOwn(panel, "computeSize"), false, "deleted, not replaced with another size");
assert.equal(panel.computeSize, undefined);
});
test("the panel declares a floor and never a ceiling", () => {
const panel = fillNodeHeight(pinned(), { minWidth: 320, minHeight: 144 });
const layout = panel.computeLayoutSize({});
assert.deepEqual(layout, { minWidth: 320, minHeight: 144 });
assert.equal("maxHeight" in layout, false, "a maximum would stop it filling a taller node");
assert.equal("maxWidth" in layout, false);
});
test("a floor given as a function is read every time the layout asks", () => {
const state = { populated: false };
const panel = fillNodeHeight({}, { minWidth: 300, minHeight: () => (state.populated ? 420 : 132) });
assert.equal(panel.computeLayoutSize({}).minHeight, 132);
state.populated = true;
assert.equal(panel.computeLayoutSize({}).minHeight, 420);
assert.equal(panel.computeLayoutSize({}).minWidth, 300);
});
test("a floor that is not a usable number becomes zero", () => {
assert.deepEqual(fillNodeHeight({}, { minWidth: "wide", minHeight: () => undefined }).computeLayoutSize({}), { minWidth: 0, minHeight: 0 });
assert.equal(fillNodeHeight({}, { minHeight: -50 }).computeLayoutSize({}).minHeight, 0);
assert.equal(fillNodeHeight({}, { minHeight: Number.NaN }).computeLayoutSize({}).minHeight, 0);
assert.deepEqual(fillNodeHeight({}).computeLayoutSize({}), { minWidth: 0, minHeight: 0 });
});
test("minNodeSize is handed on, and the widget's other options survive", () => {
assert.deepEqual(fillNodeHeight({}, { minHeight: 144, minNodeSize: [320, 220] }).options.minNodeSize, [320, 220]);
assert.equal(fillNodeHeight({}, { minHeight: 144 }).options.minNodeSize, undefined);
const kept = fillNodeHeight({ options: { minNodeSize: [1, 2], hideOnZoom: true } }, {});
assert.equal(kept.options.hideOnZoom, true);
assert.deepEqual(kept.options.minNodeSize, [1, 2], "no new size given: the old one stays");
});
test("no widget, no work", () => {
assert.equal(fillNodeHeight(null, { minHeight: 10 }), null);
assert.equal(fillNodeHeight(undefined), undefined);
});
test("hiding a filled panel folds it away, and showing it fills again", () => {
const panel = fillNodeHeight(pinned(), { minWidth: 320, minHeight: 144 });
hideWidget(panel);
assert.equal(typeof panel.computeSize, "function", "hidden: a fixed size of nothing");
assert.deepEqual(panel.computeLayoutSize({}), { minWidth: 0, minHeight: 0 });
showWidget(panel);
assert.equal(panel.computeSize, undefined, "shown: no fixed size comes back with it");
assert.deepEqual(panel.computeLayoutSize({}), { minWidth: 320, minHeight: 144 });
});
// Nodes 2.0 does not size a node by computeLayoutSize: it lays a node out
// by its content, and a corner drag stops where the content cannot get any
// shorter. So the same floor also has to reach the panel's element as CSS,
// or a panel whose content has no height of its own is dragged flat.
// A panel's element, as the helper sees it: inside a Nodes 2.0 node when
// `host` is an element, in the classic renderer's own layer when it is null.
function panelElement(host) {
return { style: { minHeight: "" }, closest: (selector) => (selector === "[data-node-id]" ? host : null) };
}
test("inside a Nodes 2.0 node the element carries the floor, less the frame", () => {
const element = panelElement({});
const panel = fillNodeHeight({ element }, { minWidth: 200, minHeight: 180 });
assert.equal(panel.computeLayoutSize({}).minHeight, 180, "what the classic layout is told has not moved");
assert.equal(element.style.minHeight, `${180 - WIDGET_FRAME}px`);
});
test("the CSS floor follows a floor that changes with state", () => {
// The preview panel: its stage is gone while the preview is off, so the
// floor drops to the bar and the node can shrink to it.
const state = { preview: true };
const element = panelElement({});
const panel = fillNodeHeight({ element }, { minHeight: () => (state.preview ? 180 : 36) });
panel.computeLayoutSize({});
assert.equal(element.style.minHeight, "160px");
state.preview = false;
panel.computeLayoutSize({});
assert.equal(element.style.minHeight, "16px");
state.preview = true;
panel.computeLayoutSize({});
assert.equal(element.style.minHeight, "160px");
});
test("a floor no taller than the frame leaves the element without one", () => {
const element = panelElement({});
const panel = fillNodeHeight({ element }, { minHeight: WIDGET_FRAME });
panel.computeLayoutSize({});
assert.equal(element.style.minHeight, "");
});
test("in the classic renderer the element carries no CSS floor", () => {
const element = panelElement(null);
const panel = fillNodeHeight({ element }, { minHeight: 180 });
assert.equal(panel.computeLayoutSize({}).minHeight, 180);
assert.equal(element.style.minHeight, "");
// Switched from Nodes 2.0 to classic with the page open: the same element
// moves out of the node, and the next layout pass takes the floor off.
element.style.minHeight = "160px";
panel.computeLayoutSize({});
assert.equal(element.style.minHeight, "");
});
test("the floor is set when the panel is first laid out, before anything asks", () => {
const observers = [];
const saved = globalThis.ResizeObserver;
globalThis.ResizeObserver = class {
constructor(callback) { this.callback = callback; observers.push(this); }
observe(target) { this.target = target; }
};
try {
// Built before the node is on screen: no node element around it yet.
let host = null;
const element = { style: { minHeight: "" }, closest: () => host };
fillNodeHeight({ element }, { minHeight: () => 180 });
assert.equal(element.style.minHeight, "");
assert.equal(observers.length, 1);
assert.equal(observers[0].target, element);
// Nodes 2.0 mounts the element into its node: its first size arrives.
host = {};
observers[0].callback();
assert.equal(element.style.minHeight, "160px");
} finally {
globalThis.ResizeObserver = saved;
}
});
test("a hidden panel asks for nothing and gets its floor back when shown", () => {
const element = panelElement({});
const panel = fillNodeHeight({ element }, { minHeight: 180 });
panel.computeLayoutSize({});
hideWidget(panel);
assert.deepEqual(panel.computeLayoutSize({}), { minWidth: 0, minHeight: 0 });
showWidget(panel);
element.style.minHeight = "";
assert.equal(panel.computeLayoutSize({}).minHeight, 180);
assert.equal(element.style.minHeight, "160px");
});