mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-10-08 12:21:44 -04:00
Merge pull request #3614 from lightpanda-io/viewport-scroll-extent
Give the document a height and clamp the viewport scroll to it
This commit is contained in:
6 files changed
+167
-33
No files matched your search
@@ -337,17 +337,14 @@
|
||||
testing.expectEqual(0, panel.clientHeight);
|
||||
testing.expectTrue(panel.scrollHeight > 0);
|
||||
|
||||
// The root containers keep their synthetic size rather than summing
|
||||
// children. The root element's clientHeight is the viewport (CSSOM View):
|
||||
// jstracker tiles clientWidth/10 cells over clientHeight and calls
|
||||
// elementFromPoint per cell, which the 100M px box turned into ~5M walks.
|
||||
// The root element's clientHeight is the viewport (CSSOM View); jstracker
|
||||
// tiles it with elementFromPoint calls.
|
||||
testing.expectEqual(document.body.clientHeight, document.body.scrollHeight);
|
||||
testing.expectEqual(window.innerHeight, document.documentElement.clientHeight);
|
||||
testing.expectEqual(
|
||||
document.documentElement.offsetHeight,
|
||||
Math.max(document.documentElement.offsetHeight, document.documentElement.clientHeight),
|
||||
document.documentElement.scrollHeight,
|
||||
);
|
||||
testing.expectTrue(document.documentElement.scrollHeight > document.documentElement.clientHeight);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -578,6 +575,11 @@
|
||||
deep.id = 'deep';
|
||||
document.body.appendChild(deep);
|
||||
|
||||
// Room to scroll it to the top
|
||||
const room = document.createElement('div');
|
||||
room.style.height = '10000px';
|
||||
document.body.appendChild(room);
|
||||
|
||||
const targetY = deep.getBoundingClientRect().y;
|
||||
testing.expectTrue(targetY > 0);
|
||||
|
||||
|
||||
@@ -70,5 +70,7 @@
|
||||
document.getElementById('rad').addEventListener('click', function() { window.radClicked = true; });
|
||||
document.getElementById('rad').addEventListener('change', function() { window.radChanged = true; });
|
||||
</script>
|
||||
<!-- Room to scroll the window -->
|
||||
<div style="height: 10000px; width: 10000px"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,6 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<script src="testing.js"></script>
|
||||
|
||||
<!-- Room to scroll -->
|
||||
<div style="height: 10000px; width: 10000px"></div>
|
||||
|
||||
<script id=scrollBy_exists>
|
||||
testing.expectEqual('function', typeof window.scrollBy);
|
||||
</script>
|
||||
@@ -45,6 +48,43 @@
|
||||
testing.expectEqual(0, window.scrollY);
|
||||
</script>
|
||||
|
||||
<script id=scrollTo_clamps_to_document>
|
||||
const max = document.documentElement.scrollHeight - window.innerHeight;
|
||||
testing.expectTrue(max > 0);
|
||||
window.scrollTo(0, 99999999);
|
||||
testing.expectEqual(max, window.scrollY);
|
||||
window.scrollBy(0, 100);
|
||||
testing.expectEqual(max, window.scrollY);
|
||||
|
||||
const maxX = document.documentElement.scrollWidth - window.innerWidth;
|
||||
testing.expectTrue(maxX > 0);
|
||||
window.scrollTo(99999999, 0);
|
||||
testing.expectEqual(maxX, window.scrollX);
|
||||
|
||||
// Added content extends the limit
|
||||
const more = document.createElement('div');
|
||||
more.style.height = '5000px';
|
||||
document.body.appendChild(more);
|
||||
window.scrollTo(0, 99999999);
|
||||
testing.expectEqual(max + 5000, window.scrollY);
|
||||
more.remove();
|
||||
window.scrollTo(0, 0);
|
||||
</script>
|
||||
|
||||
<script id=scrollTo_reaches_nested_spacer>
|
||||
// Virtualized lists size an inner spacer inside an auto-height wrapper
|
||||
const outer = document.createElement('div');
|
||||
const spacer = document.createElement('div');
|
||||
spacer.style.height = '20000px';
|
||||
outer.appendChild(spacer);
|
||||
document.body.appendChild(outer);
|
||||
window.scrollTo(0, 99999999);
|
||||
testing.expectTrue(window.scrollY + window.innerHeight >= 20000);
|
||||
testing.expectEqual(document.documentElement.scrollHeight - window.innerHeight, window.scrollY);
|
||||
outer.remove();
|
||||
window.scrollTo(0, 0);
|
||||
</script>
|
||||
|
||||
<script id=element_scroll_events_coalesce_and_preserve_reentrancy type=module>
|
||||
const state = await testing.async();
|
||||
const element = document.createElement('div');
|
||||
|
||||
@@ -83,6 +83,13 @@ _script_created_parser: ?Parser.Streaming = null,
|
||||
_close_requested: bool = false,
|
||||
_adopted_style_sheets: ?js.Object.Global = null,
|
||||
_selection: Selection = .{ ._rc = .init(1) },
|
||||
// extent() cache, keyed on style version and viewport.
|
||||
_extent: ?struct {
|
||||
version: usize,
|
||||
viewport_width: u32,
|
||||
viewport_height: u32,
|
||||
extent: Extent,
|
||||
} = null,
|
||||
// Ordered stack of currently-showing popovers
|
||||
_open_popovers: std.ArrayList(*Element) = .empty,
|
||||
|
||||
@@ -496,6 +503,81 @@ pub fn getDocumentElement(self: *Document) ?*Element {
|
||||
return null;
|
||||
}
|
||||
|
||||
pub const Extent = struct { width: f64, height: f64 };
|
||||
|
||||
/// The document's size. Height: enough for every synthetic position (5px per
|
||||
/// node), the bottom of every element with an inline height, and body's
|
||||
/// stacked children. Width: body's widest child. An inline size on body
|
||||
/// stretches both. A document without a frame isn't rendered, so it has no
|
||||
/// size.
|
||||
pub fn extent(self: *Document) Extent {
|
||||
const frame = self._frame orelse return .{ .width = 0, .height = 0 };
|
||||
const version = frame.page.style_version;
|
||||
const viewport = frame.page.getViewport();
|
||||
if (self._extent) |cached| {
|
||||
if (cached.version == version and cached.viewport_width == viewport.width and cached.viewport_height == viewport.height) {
|
||||
return cached.extent;
|
||||
}
|
||||
}
|
||||
|
||||
const style_manager = &frame._style_manager;
|
||||
var size: Extent = .{ .width = 0, .height = 0 };
|
||||
|
||||
// A nested spacer (virtualized lists) must extend the document even when
|
||||
// its auto-height ancestors count as 5px each. Body's own children are
|
||||
// stacked below instead. Only inline heights: virtualizers set theirs
|
||||
// inline, and the cascade per element is too slow on every mutation.
|
||||
var index: f64 = 0;
|
||||
var tw = @import("TreeWalker.zig").Full.init(self.asNode(), .{});
|
||||
while (tw.next()) |node| : (index += 1) {
|
||||
const el = node.is(Element) orelse continue;
|
||||
const parent = el.parentElement() orelse continue;
|
||||
if (parent.getTag() == .html or parent.getTag() == .body) {
|
||||
continue;
|
||||
}
|
||||
if (style_manager.inlineSize(el, .height)) |height| {
|
||||
size.height = @max(size.height, index * 5.0 + height);
|
||||
}
|
||||
}
|
||||
size.height = @max(size.height, index * 5.0);
|
||||
|
||||
if (self.is(HTMLDocument)) |html_doc| {
|
||||
if (html_doc.getBody()) |html_body| {
|
||||
const body = html_body.asElement();
|
||||
size.height = @max(size.height, body.contentAxis(frame, .height), style_manager.inlineSize(body, .height) orelse 0);
|
||||
size.width = style_manager.inlineSize(body, .width) orelse 0;
|
||||
var child = body.asNode().firstChild();
|
||||
while (child) |node| : (child = node.nextSibling()) {
|
||||
const el = node.is(Element) orelse continue;
|
||||
if (!style_manager.hasDisplayNone(el)) {
|
||||
size.width = @max(size.width, el.getElementAxis(frame, .width).value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Whole pixels, like scroll offsets
|
||||
size = .{ .width = @ceil(size.width), .height = @ceil(size.height) };
|
||||
self._extent = .{
|
||||
.version = version,
|
||||
.viewport_width = viewport.width,
|
||||
.viewport_height = viewport.height,
|
||||
.extent = size,
|
||||
};
|
||||
return size;
|
||||
}
|
||||
|
||||
/// What the viewport scrolls over: the document, at least viewport-sized.
|
||||
pub fn scrollSize(self: *Document) Extent {
|
||||
const frame = self._frame orelse return .{ .width = 0, .height = 0 };
|
||||
const size = self.extent();
|
||||
const viewport = frame.page.getViewport();
|
||||
return .{
|
||||
.width = @max(size.width, @as(f64, @floatFromInt(viewport.width))),
|
||||
.height = @max(size.height, @as(f64, @floatFromInt(viewport.height))),
|
||||
};
|
||||
}
|
||||
|
||||
fn getSelection(self: *Document) *Selection {
|
||||
return &self._selection;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ const text_measure = @import("../text_measure.zig");
|
||||
|
||||
const CSS = @import("CSS.zig");
|
||||
const Node = @import("Node.zig");
|
||||
const TreeWalker = @import("TreeWalker.zig");
|
||||
const ShadowRoot = @import("ShadowRoot.zig");
|
||||
const EventTarget = @import("EventTarget.zig");
|
||||
const collections = @import("collections.zig");
|
||||
@@ -1511,12 +1512,12 @@ pub fn getElementAxis(self: *Element, frame: *Frame, comptime axis: Axis) Axis.S
|
||||
}
|
||||
}
|
||||
|
||||
// Root containers get large default size to contain descendant positions.
|
||||
// With calculateDocumentPosition using linear depth scaling (100px per level),
|
||||
// even very deep trees (100 levels) stay within 10,000px.
|
||||
// 100M pixels is plausible for very long documents.
|
||||
// Root containers span the document, see Document.extent.
|
||||
if (root) {
|
||||
return .{ .value = if (axis == .width) 1920.0 else 100_000_000.0 };
|
||||
return .{ .value = switch (axis) {
|
||||
.width => 1920.0,
|
||||
.height => if (self.asNode().ownerDocument(frame)) |doc| doc.extent().height else 0.0,
|
||||
} };
|
||||
}
|
||||
|
||||
// Presentational attributes lose to CSS sizes.
|
||||
@@ -1550,6 +1551,13 @@ fn clientAxis(self: *Element, frame: *Frame, comptime axis: Axis) f64 {
|
||||
return self.viewportAxis(frame, axis) orelse self.boxAxis(frame, axis);
|
||||
}
|
||||
|
||||
/// Document.scrollSize, when self is the root scroller (see viewportAxis).
|
||||
fn rootScrollSize(self: *Element, frame: *Frame, comptime axis: Axis) ?f64 {
|
||||
_ = self.viewportAxis(frame, axis) orelse return null;
|
||||
const doc = self.asNode().ownerDocument(frame) orelse return null;
|
||||
return @field(doc.scrollSize(), @tagName(axis));
|
||||
}
|
||||
|
||||
fn viewportAxis(self: *Element, frame: *Frame, comptime axis: Axis) ?f64 {
|
||||
const tag = self.getTag();
|
||||
if (tag != .html and tag != .body) {
|
||||
@@ -1690,10 +1698,9 @@ pub fn getScrollHeight(self: *Element, frame: *Frame) f64 {
|
||||
const height = self.getElementAxis(frame, .height).value;
|
||||
|
||||
const tag = self.getTag();
|
||||
// As in getScrollWidth: the root containers carry artificial giant
|
||||
// defaults, and page-level overflow checks read them.
|
||||
// The root scroller reports what the viewport scrolls over.
|
||||
if (tag == .html or tag == .body) {
|
||||
return height;
|
||||
return self.rootScrollSize(frame, .height) orelse height;
|
||||
}
|
||||
|
||||
return @max(height, self.contentAxis(frame, .height));
|
||||
@@ -1707,11 +1714,10 @@ pub fn getScrollWidth(self: *Element, frame: *Frame) f64 {
|
||||
const width = self.getElementAxis(frame, .width).value;
|
||||
|
||||
const tag = self.getTag();
|
||||
// The root containers carry artificial giant defaults (1920 and
|
||||
// 100_000_000, see getElementAxis). Stacking their children on
|
||||
// top would inflate a value sites read to detect page overflow.
|
||||
// Roots don't sum their children side by side. The root scroller
|
||||
// reports what the viewport scrolls over.
|
||||
if (tag == .html or tag == .body) {
|
||||
return width;
|
||||
return self.rootScrollSize(frame, .width) orelse width;
|
||||
}
|
||||
|
||||
return @max(width, self.contentAxis(frame, .width));
|
||||
@@ -1719,9 +1725,7 @@ pub fn getScrollWidth(self: *Element, frame: *Frame) f64 {
|
||||
|
||||
/// Null where we can't prove a limit, which leaves the offset unbounded:
|
||||
/// without an explicit size the client and content measurements collapse onto
|
||||
/// the same sum, and html and body carry giant defaults that would fabricate
|
||||
/// an extent against the real viewport. Refusing a scroll we can't prove
|
||||
/// impossible is worse than allowing one too many.
|
||||
/// the same sum. html and body scroll the viewport, clamped by Window.
|
||||
fn scrollExtent(self: *Element, frame: *Frame, comptime axis: Axis) ?f64 {
|
||||
if (self.scrollsViewport() or !self.getElementAxis(frame, axis).explicit) {
|
||||
return null;
|
||||
@@ -1759,7 +1763,7 @@ fn scrollExtent(self: *Element, frame: *Frame, comptime axis: Axis) ?f64 {
|
||||
//
|
||||
// Text children add height only under an explicit width to wrap at.
|
||||
// Otherwise almost every element with text would report overflow.
|
||||
fn contentAxis(self: *Element, frame: *Frame, comptime axis: Axis) f64 {
|
||||
pub fn contentAxis(self: *Element, frame: *Frame, comptime axis: Axis) f64 {
|
||||
var total: f64 = 0;
|
||||
const owner = self.ownerFrame(frame) orelse return 0;
|
||||
const style_manager = &owner._style_manager;
|
||||
@@ -1792,8 +1796,7 @@ fn contentAxis(self: *Element, frame: *Frame, comptime axis: Axis) f64 {
|
||||
return total;
|
||||
}
|
||||
|
||||
// Unlike clientHeight, the root's offsetHeight is its box (the document
|
||||
// extent), so it stays on the synthetic root default.
|
||||
// Unlike clientHeight, the root's offsetHeight is the document height.
|
||||
pub fn getOffsetHeight(self: *Element, frame: *Frame) f64 {
|
||||
if (!self.isVisible(frame)) {
|
||||
return 0.0;
|
||||
@@ -1926,14 +1929,11 @@ fn calculateDocumentPosition(node: *Node) f64 {
|
||||
|
||||
// Counts total nodes in a subtree (node + all descendants)
|
||||
fn countSubtreeNodes(node: *Node) f64 {
|
||||
var count: f64 = 1.0; // Count this node
|
||||
|
||||
var child = node.firstChild();
|
||||
while (child) |c| {
|
||||
count += countSubtreeNodes(c);
|
||||
child = c.nextSibling();
|
||||
var count: f64 = 0;
|
||||
var tw = TreeWalker.Full.init(node, .{});
|
||||
while (tw.next()) |_| {
|
||||
count += 1;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
|
||||
@@ -951,8 +951,11 @@ fn getDevicePixelRatio(_: *const Window, frame: *Frame) f32 {
|
||||
|
||||
pub fn scrollTo(self: *Window, opts: Element.ScrollToOpts, y: ?i32, frame: *Frame) !void {
|
||||
const o = opts.offsets(y);
|
||||
const new_x: u32 = if (o.left) |left| @intCast(@max(0, left)) else self._scroll_pos.x;
|
||||
const new_y: u32 = if (o.top) |top| @intCast(@max(0, top)) else self._scroll_pos.y;
|
||||
const size = self._frame.document.scrollSize();
|
||||
const max_x = scrollLimit(size.width, self.getInnerWidth(self._frame));
|
||||
const max_y = scrollLimit(size.height, self.getInnerHeight(self._frame));
|
||||
const new_x: u32 = if (o.left) |left| @min(@as(u32, @intCast(@max(0, left))), max_x) else self._scroll_pos.x;
|
||||
const new_y: u32 = if (o.top) |top| @min(@as(u32, @intCast(@max(0, top))), max_y) else self._scroll_pos.y;
|
||||
|
||||
if (new_x == self._scroll_pos.x and new_y == self._scroll_pos.y) {
|
||||
return;
|
||||
@@ -1014,6 +1017,11 @@ pub fn scrollTo(self: *Window, opts: Element.ScrollToOpts, y: ?i32, frame: *Fram
|
||||
);
|
||||
}
|
||||
|
||||
fn scrollLimit(size: f64, visible: u32) u32 {
|
||||
const limit = size - @as(f64, @floatFromInt(visible));
|
||||
return @intFromFloat(std.math.clamp(limit, 0, std.math.maxInt(u32)));
|
||||
}
|
||||
|
||||
pub fn scrollBy(self: *Window, opts: Element.ScrollToOpts, y: ?i32, frame: *Frame) !void {
|
||||
const o = opts.offsets(y);
|
||||
// The viewport has no honest extent, so a stored offset can sit above
|
||||
|
||||
Reference in new issue
Block a user