Merge pull request #3048 from lightpanda-io/scroll-with-content

derive scrollWidth/scrollHeight from element content
This commit is contained in:
Karl Seguin
2026-07-25 09:34:08 +08:00
committed by GitHub
2 changed files with 292 additions and 5 deletions

View File

@@ -24,12 +24,206 @@
{
const test1 = $('#test1');
// In dummy layout, scroll dimensions equal client dimensions (no overflow)
// Both scroll dimensions are approximated from the content, so neither falls
// below the element's own box but either may exceed it.
testing.expectTrue(test1.scrollWidth >= test1.clientWidth);
testing.expectTrue(test1.scrollHeight >= test1.clientHeight);
// test1 holds only a text node, and text is not measured on either axis, so
// both still match the element's own box.
testing.expectEqual(test1.clientWidth, test1.scrollWidth);
testing.expectEqual(test1.clientHeight, test1.scrollHeight);
}
</script>
<script id="scrollWidthFromContent">
{
// An empty element has no content to overflow its box.
const empty = document.createElement('div');
document.body.appendChild(empty);
testing.expectEqual(empty.clientWidth, empty.scrollWidth);
// Each added child strictly increases scrollWidth, and enough of them push
// it past the element's own box.
const box = document.createElement('div');
document.body.appendChild(box);
box.appendChild(document.createElement('span'));
const oneChild = box.scrollWidth;
testing.expectTrue(oneChild >= box.clientWidth);
box.appendChild(document.createElement('span'));
testing.expectTrue(box.scrollWidth > oneChild);
testing.expectTrue(box.scrollWidth > box.clientWidth);
// Text contributes nothing, however long. Estimating a run from its length
// would need a per-character advance that tracks font-size, and would report
// overflow for practically every element containing text.
const short = document.createElement('div');
const long = document.createElement('div');
short.textContent = 'ab';
long.textContent = 'abcdefghijklmnop';
document.body.appendChild(short);
document.body.appendChild(long);
testing.expectEqual(short.clientWidth, short.scrollWidth);
testing.expectEqual(long.clientWidth, long.scrollWidth);
testing.expectEqual(short.scrollWidth, long.scrollWidth);
// Comments are not rendered, so they contribute nothing.
const commented = document.createElement('div');
commented.innerHTML = '<!-- a very long comment that renders as nothing -->';
document.body.appendChild(commented);
testing.expectEqual(commented.clientWidth, commented.scrollWidth);
// A display:none child takes up no space.
const withHidden = document.createElement('div');
const hiddenChild = document.createElement('span');
hiddenChild.style.display = 'none';
withHidden.appendChild(hiddenChild);
document.body.appendChild(withHidden);
testing.expectEqual(withHidden.clientWidth, withHidden.scrollWidth);
// A hidden element reports 0, matching clientWidth/offsetWidth.
const hidden = document.createElement('div');
hidden.style.display = 'none';
hidden.appendChild(document.createElement('span'));
document.body.appendChild(hidden);
testing.expectEqual(0, hidden.scrollWidth);
// A visible width:0 box (an off-screen measurement container) is not
// hidden: its content still overflows it.
const zeroWidth = document.createElement('div');
zeroWidth.style.width = '0';
zeroWidth.appendChild(document.createElement('span'));
document.body.appendChild(zeroWidth);
testing.expectEqual(0, zeroWidth.clientWidth);
testing.expectTrue(zeroWidth.scrollWidth > 0);
// The root containers keep their synthetic size rather than summing
// children, so horizontal page-overflow checks stay stable.
testing.expectEqual(document.body.clientWidth, document.body.scrollWidth);
testing.expectEqual(
document.documentElement.clientWidth,
document.documentElement.scrollWidth,
);
}
</script>
<script id="scrollWidthMarqueeLoop">
{
// Regression: the infinite-marquee idiom duplicates content until it is
// twice the width of its container. scrollWidth has to respond to the
// insertion or the loop never terminates.
const track = document.createElement('div');
const lane = document.createElement('div');
lane.innerHTML = '<span>item</span>';
track.appendChild(lane);
document.body.appendChild(track);
// Doubling the markup doubles the measured content width.
const before = lane.scrollWidth;
lane.innerHTML += lane.innerHTML;
testing.expectEqual(before * 2, lane.scrollWidth);
let guard = 0;
while (lane.scrollWidth < track.offsetWidth * 2 && guard < 50) {
lane.innerHTML += lane.innerHTML;
guard++;
}
testing.expectTrue(guard < 50);
}
</script>
<script id="scrollHeightFromContent">
{
// An empty element has no content to overflow its box.
const empty = document.createElement('div');
document.body.appendChild(empty);
testing.expectEqual(empty.clientHeight, empty.scrollHeight);
// Children stack, so each one strictly increases scrollHeight.
const box = document.createElement('div');
document.body.appendChild(box);
box.appendChild(document.createElement('div'));
const oneChild = box.scrollHeight;
testing.expectTrue(oneChild >= box.clientHeight);
box.appendChild(document.createElement('div'));
testing.expectTrue(box.scrollHeight > oneChild);
testing.expectTrue(box.scrollHeight > box.clientHeight);
// Text contributes no height, as it contributes no width: a text child's
// line box is what the element's own size already stands in for.
const short = document.createElement('div');
const long = document.createElement('div');
short.textContent = 'ab';
long.textContent = 'abcdefghijklmnop';
document.body.appendChild(short);
document.body.appendChild(long);
testing.expectEqual(short.clientHeight, short.scrollHeight);
testing.expectEqual(long.clientHeight, long.scrollHeight);
testing.expectEqual(short.scrollHeight, long.scrollHeight);
// A display:none child takes up no space.
const withHidden = document.createElement('div');
const hiddenChild = document.createElement('div');
hiddenChild.style.display = 'none';
withHidden.appendChild(hiddenChild);
document.body.appendChild(withHidden);
testing.expectEqual(withHidden.clientHeight, withHidden.scrollHeight);
// A hidden element reports 0, matching clientHeight/offsetHeight.
const hidden = document.createElement('div');
hidden.style.display = 'none';
hidden.appendChild(document.createElement('div'));
document.body.appendChild(hidden);
testing.expectEqual(0, hidden.scrollHeight);
// The collapse/expand idiom: scrollHeight is read while the panel is
// collapsed at height:0 to know how far to open it. A zero-height box is
// not hidden; its content is still measured.
const panel = document.createElement('div');
panel.style.height = '0';
panel.appendChild(document.createElement('div'));
panel.appendChild(document.createElement('div'));
document.body.appendChild(panel);
testing.expectEqual(0, panel.clientHeight);
testing.expectTrue(panel.scrollHeight > 0);
// The root containers keep their synthetic size rather than summing
// children. This is what keeps infinite-scroll triggers reading
// `scrollTop + clientHeight >= scrollHeight` on body/documentElement
// behaving as they do today.
testing.expectEqual(document.body.clientHeight, document.body.scrollHeight);
testing.expectEqual(
document.documentElement.clientHeight,
document.documentElement.scrollHeight,
);
}
</script>
<script id="scrollHeightMarqueeLoop">
{
// The vertical counterpart of scrollWidthMarqueeLoop: a ticker that grows
// its lane until it is twice the height of its container.
const track = document.createElement('div');
const lane = document.createElement('div');
lane.innerHTML = '<div>item</div>';
track.appendChild(lane);
document.body.appendChild(track);
// Doubling the markup doubles the measured content height.
const before = lane.scrollHeight;
lane.innerHTML += lane.innerHTML;
testing.expectEqual(before * 2, lane.scrollHeight);
let guard = 0;
while (lane.scrollHeight < track.offsetHeight * 2 && guard < 50) {
lane.innerHTML += lane.innerHTML;
guard++;
}
testing.expectTrue(guard < 50);
}
</script>
<script id="scrollPosition">
{
const test1 = $('#test1');

View File

@@ -1377,13 +1377,106 @@ pub fn setScrollLeft(self: *Element, value: i32, frame: *Frame) !void {
}
pub fn getScrollHeight(self: *Element, frame: *Frame) f64 {
// In our dummy layout engine, content doesn't overflow
return self.getClientHeight(frame);
var visibility_cache: VisibilityCache = .{};
if (!self.checkVisibilityCached(&visibility_cache, frame)) {
return 0.0;
}
const height = self.getElementDimensions(frame).height;
const tag = self.getTag();
// As in getScrollWidth: the root containers carry artificial giant
// defaults, and page-level overflow checks read them.
if (tag == .html or tag == .body) {
return height;
}
return @max(height, self.contentHeight(frame, &visibility_cache));
}
// The height of the direct child elements stacked vertically, the counterpart
// of contentWidth.
//
// Note that the two assume contradictory arrangements — contentWidth lays the
// children out in a row, this stacks them. That is deliberate. We can't detect
// the real layout mode (see contentWidth), so each axis independently assumes
// the arrangement that produces overflow. Together they bound the content
// extent per axis rather than describing one coherent layout: reporting no
// overflow when there is some is what wedges measure-then-mutate loops,
// while the reverse merely over-reports.
fn contentHeight(self: *Element, frame: *Frame, visibility_cache: *VisibilityCache) f64 {
var total: f64 = 0;
var child = self.asNode().firstChild();
while (child) |node| : (child = node.nextSibling()) {
if (node.is(Element)) |el| {
if (el.checkVisibilityCached(visibility_cache, frame)) {
total += el.getElementDimensions(frame).height;
}
}
}
return total;
}
pub fn getScrollWidth(self: *Element, frame: *Frame) f64 {
// In our dummy layout engine, content doesn't overflow
return self.getClientWidth(frame);
var visibility_cache: VisibilityCache = .{};
if (!self.checkVisibilityCached(&visibility_cache, frame)) {
return 0.0;
}
const width = self.getElementDimensions(frame).width;
const tag = self.getTag();
// The root containers carry artificial giant defaults (1920 and
// 100_000_000, see getElementDimensions). Stacking their children on
// top would inflate a value sites read to detect page overflow.
if (tag == .html or tag == .body) {
return width;
}
return @max(width, self.contentWidth(frame, &visibility_cache));
}
// The width of the direct child elements laid end to end on a single row.
//
// The dummy layout engine has no line-breaking, and an element only overflows
// horizontally when its children don't wrap (white-space:nowrap, a flex row, an
// inline-block strip), so the single-row assumption covers the case that
// matters. We can't detect the layout mode to do better: getStyle() sees only
// the inline `style=` attribute, and the computed cascade resolves stylesheet
// rules for `display:none` and `visibility` alone.
//
// Only direct children are measured, never the whole subtree. This runs on
// every scrollWidth read, and recursing would make an element's cost O(subtree)
// rather than O(fan-out).
//
// Growing with the child count is the point: JS that appends content until
// `scrollWidth` passes a threshold (the infinite-marquee idiom) never
// terminates when the metric ignores what it just inserted.
//
// Text children are not measured, matching contentHeight. Estimating a text run
// from its length would need a per-character advance, which in turn has to track
// font-size or "shrink the font until it fits" loops stop converging — and it
// would report overflow for practically every element containing text, since a
// few words already exceed the default box. Element children are what content
// grown by script actually consists of.
fn contentWidth(self: *Element, frame: *Frame, visibility_cache: *VisibilityCache) f64 {
var total: f64 = 0;
// The cache arrives seeded by the caller's own visibility walk, and
// siblings share that ancestor chain, so the loop costs one own-element
// check per child rather than N ancestor walks.
var child = self.asNode().firstChild();
while (child) |node| : (child = node.nextSibling()) {
if (node.is(Element)) |el| {
if (el.checkVisibilityCached(visibility_cache, frame)) {
total += el.getElementDimensions(frame).width;
}
}
}
return total;
}
pub fn getOffsetHeight(self: *Element, frame: *Frame) f64 {