Dragging a node taller only ever widened its stage. The frontend arranges widgets in one pass: 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 here derived that fixed height from the node's WIDTH, so the extra height became dead space underneath. Compare was fixed by hand; the other six had the same defect. Adds js/shared/panel_layout.mjs with one fillNodeHeight() that deletes computeSize and declares a floor with no ceiling - distributeSpace reads a missing maxSize as Infinity, so the panel takes whatever is left. Applied to Compare, Save Video, Load Video, Frame Chooser, Load Image + Pad, Image Crop+Rotate+Pad, and the shared transform panel that Video Crop+Rotate+Pad mounts. Removing computeSize also removed where those nodes got their opening height, so Save Video, Load Video and Load Image + Pad now state a default; a saved size still wins through onConfigure. Measured in the browser: all seven grow 1:1 with the node and clamp at their own floor. The panel audit now enforces it - a DOM panel is either a stage that calls fillNodeHeight and never assigns computeSize, or is listed as fixed by design - so a new panel cannot quietly reintroduce this.
170 lines
7.1 KiB
JavaScript
170 lines
7.1 KiB
JavaScript
// fillNodeHeight is the one place that knows how the frontend splits a
|
|
// node's leftover height between its widgets. The rule it encodes, from
|
|
// LGraphNode._arrangeWidgets:
|
|
//
|
|
// if (w.computeSize) -> fixed height, kept OUT of the split
|
|
// else if (w.computeLayoutSize) -> joins distributeSpace(freeSpace, ...)
|
|
//
|
|
// It is an else-if. A panel declaring both is pinned by computeSize and its
|
|
// computeLayoutSize is never consulted - the bug where dragging a node
|
|
// taller only added dead space under a width-sized stage.
|
|
|
|
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import { fillNodeHeight } from "../js/shared/panel_layout.mjs";
|
|
|
|
// distributeSpace, transcribed from the frontend bundle. maxSize ?? Infinity
|
|
// is the line that makes "a minimum with no maximum" mean "take the rest".
|
|
function distributeSpace(available, specs) {
|
|
if (specs.length === 0) return [];
|
|
const floor = specs.reduce((sum, spec) => sum + spec.minSize, 0);
|
|
if (available < floor) return specs.map((spec) => spec.minSize);
|
|
let rows = specs.map((spec) => ({
|
|
computedSize: spec.minSize,
|
|
remaining: (spec.maxSize ?? Infinity) - spec.minSize,
|
|
}));
|
|
let left = available - floor;
|
|
while (left > 0 && rows.some((row) => row.remaining > 0)) {
|
|
const hungry = rows.filter((row) => row.remaining > 0).length;
|
|
if (hungry === 0) break;
|
|
const share = left / hungry;
|
|
let handed = 0;
|
|
rows = rows.map((row) => {
|
|
if (row.remaining <= 0) return row;
|
|
const take = Math.min(share, row.remaining);
|
|
handed += take;
|
|
return { computedSize: row.computedSize + take, remaining: row.remaining - take };
|
|
});
|
|
left -= handed;
|
|
if (handed === 0) break;
|
|
}
|
|
return rows.map((row) => row.computedSize);
|
|
}
|
|
|
|
// The widget walk from _arrangeWidgets. Returns each widget's computed height.
|
|
function arrangeWidgets(widgets, freeHeight) {
|
|
let fixed = 0;
|
|
const flexible = [];
|
|
const heights = new Map();
|
|
for (const widget of widgets) {
|
|
if (widget.computeSize) {
|
|
const height = widget.computeSize()[1] + 4;
|
|
heights.set(widget, height);
|
|
fixed += height;
|
|
} else if (widget.computeLayoutSize) {
|
|
const { minHeight, maxHeight } = widget.computeLayoutSize({ size: [0, 0] });
|
|
flexible.push({ widget, minHeight, maxHeight });
|
|
} else {
|
|
heights.set(widget, 24);
|
|
fixed += 24;
|
|
}
|
|
}
|
|
const sizes = distributeSpace(
|
|
Math.max(0, freeHeight - fixed),
|
|
flexible.map((entry) => ({ minSize: entry.minHeight, maxSize: entry.maxHeight })),
|
|
);
|
|
flexible.forEach((entry, index) => heights.set(entry.widget, sizes[index]));
|
|
return heights;
|
|
}
|
|
|
|
test("a panel with computeSize is pinned - the bug this helper exists to stop", () => {
|
|
const pinned = {
|
|
computeSize: () => [400, 200],
|
|
computeLayoutSize: () => ({ minWidth: 320, minHeight: 144 }),
|
|
};
|
|
const short = arrangeWidgets([pinned], 400).get(pinned);
|
|
const tall = arrangeWidgets([pinned], 900).get(pinned);
|
|
assert.equal(short, tall, "computeSize should win, proving the else-if ordering");
|
|
assert.equal(tall, 204);
|
|
});
|
|
|
|
test("fillNodeHeight makes the same panel absorb the node's leftover height", () => {
|
|
const panel = {
|
|
computeSize: () => [400, 200],
|
|
computeLayoutSize: () => ({ minWidth: 320, minHeight: 144 }),
|
|
};
|
|
fillNodeHeight(panel, { minWidth: 320, minHeight: 144 });
|
|
assert.equal(panel.computeSize, undefined, "computeSize must be gone, not overwritten");
|
|
assert.equal(arrangeWidgets([panel], 400).get(panel), 400);
|
|
assert.equal(arrangeWidgets([panel], 900).get(panel), 900);
|
|
});
|
|
|
|
test("the declared minimum is a floor the split cannot go under", () => {
|
|
const panel = fillNodeHeight({}, { minWidth: 320, minHeight: 144 });
|
|
assert.equal(arrangeWidgets([panel], 40).get(panel), 144);
|
|
assert.equal(arrangeWidgets([panel], 0).get(panel), 144);
|
|
});
|
|
|
|
test("no maxHeight is declared, so nothing caps the panel", () => {
|
|
const panel = fillNodeHeight({}, { minHeight: 100 });
|
|
assert.equal(panel.computeLayoutSize({}).maxHeight, undefined);
|
|
});
|
|
|
|
test("a panel shares leftover height with the plain widgets above it", () => {
|
|
const rowA = {};
|
|
const rowB = {};
|
|
const panel = fillNodeHeight({}, { minHeight: 144 });
|
|
const heights = arrangeWidgets([rowA, rowB, panel], 500);
|
|
assert.equal(heights.get(rowA), 24);
|
|
assert.equal(heights.get(rowB), 24);
|
|
assert.equal(heights.get(panel), 500 - 48, "the panel takes exactly what the rows left");
|
|
});
|
|
|
|
test("two growing panels split the remainder evenly", () => {
|
|
const first = fillNodeHeight({}, { minHeight: 100 });
|
|
const second = fillNodeHeight({}, { minHeight: 100 });
|
|
const heights = arrangeWidgets([first, second], 600);
|
|
assert.equal(heights.get(first), 300);
|
|
assert.equal(heights.get(second), 300);
|
|
});
|
|
|
|
test("minimums may be functions, for panels whose floor depends on state", () => {
|
|
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, "the floor is read per call");
|
|
assert.equal(panel.computeLayoutSize({}).minWidth, 300);
|
|
});
|
|
|
|
test("minNodeSize is passed through for frontends that clamp with it", () => {
|
|
const panel = fillNodeHeight({}, { minHeight: 144, minNodeSize: [320, 220] });
|
|
assert.deepEqual(panel.options.minNodeSize, [320, 220]);
|
|
const bare = fillNodeHeight({}, { minHeight: 144 });
|
|
assert.equal(bare.options.minNodeSize, undefined);
|
|
const kept = fillNodeHeight({ options: { minNodeSize: [1, 2], hideOnZoom: true } }, {});
|
|
assert.equal(kept.options.hideOnZoom, true, "existing options survive");
|
|
});
|
|
|
|
test("bad or missing minimums degrade to zero rather than NaN", () => {
|
|
const panel = fillNodeHeight({}, { minWidth: "wide", minHeight: () => undefined });
|
|
assert.deepEqual(panel.computeLayoutSize({}), { minWidth: 0, minHeight: 0 });
|
|
const negative = fillNodeHeight({}, { minHeight: -50 });
|
|
assert.equal(negative.computeLayoutSize({}).minHeight, 0);
|
|
assert.deepEqual(fillNodeHeight({}).computeLayoutSize({}), { minWidth: 0, minHeight: 0 });
|
|
});
|
|
|
|
test("a missing widget is tolerated", () => {
|
|
assert.equal(fillNodeHeight(null, { minHeight: 10 }), null);
|
|
assert.equal(fillNodeHeight(undefined), undefined);
|
|
});
|
|
|
|
test("hiding a filled panel still collapses it, and showing restores the fill", () => {
|
|
// widget_visibility stashes and restores both sizing hooks. With
|
|
// computeSize deleted it stashes undefined - the restore must put the
|
|
// panel back in the split, not leave a truthy computeSize behind.
|
|
const panel = fillNodeHeight({}, { minHeight: 144 });
|
|
const stash = { computeSize: panel.computeSize, computeLayoutSize: panel.computeLayoutSize };
|
|
panel.computeSize = () => [0, -4];
|
|
panel.computeLayoutSize = () => ({ minWidth: 0, minHeight: 0 });
|
|
assert.equal(arrangeWidgets([panel], 500).get(panel), 0, "hidden collapses to nothing");
|
|
panel.computeSize = stash.computeSize;
|
|
panel.computeLayoutSize = stash.computeLayoutSize;
|
|
assert.equal(panel.computeSize, undefined);
|
|
assert.equal(arrangeWidgets([panel], 500).get(panel), 500, "shown fills again");
|
|
});
|