mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-10-09 12:51:45 -04:00
Merge pull request #3588 from lightpanda-io/scroll-clamp-chain
Clamp scroll offsets, and latch a wheel to one scroll container
This commit is contained in:
9 files changed
+409
-121
No files matched your search
@@ -654,7 +654,7 @@ fn rebuildIfDirty(self: *StyleManager) !void {
|
||||
|
||||
/// Own-element cascade result, resolved for every property at once so one
|
||||
/// entry serves any probe.
|
||||
const Props = packed struct(u8) {
|
||||
const Props = packed struct(u10) {
|
||||
// Author value (inline or sheet). Without `author_display` it's the UA
|
||||
// fallback: .none when matchesUaDisplayNoneRule, else .other.
|
||||
display: Display = .other,
|
||||
@@ -664,6 +664,8 @@ const Props = packed struct(u8) {
|
||||
pointer_events_none: bool = false,
|
||||
overflow_x_scrolls: bool = false,
|
||||
overflow_y_scrolls: bool = false,
|
||||
overscroll_x_contains: bool = false,
|
||||
overscroll_y_contains: bool = false,
|
||||
|
||||
fn probe(self: Props, comptime what: Probe, options: CheckVisibilityOptions) bool {
|
||||
return switch (what) {
|
||||
@@ -737,6 +739,15 @@ pub fn overflowAxes(self: *StyleManager, el: *Element) Element.ScrollAxes {
|
||||
return .{ .x = p.overflow_x_scrolls, .y = p.overflow_y_scrolls };
|
||||
}
|
||||
|
||||
/// The axes along which `el` keeps a scroll from chaining out of it: its own
|
||||
/// computed overscroll-behavior on that axis is contain or none. No ancestor
|
||||
/// walk.
|
||||
pub fn overscrollContainAxes(self: *StyleManager, el: *Element) Element.ScrollAxes {
|
||||
self.rebuildIfDirty() catch return .{};
|
||||
const p = self.ownProps(el);
|
||||
return .{ .x = p.overscroll_x_contains, .y = p.overscroll_y_contains };
|
||||
}
|
||||
|
||||
fn anyInChain(self: *StyleManager, el: *Element, comptime what: Probe, options: CheckVisibilityOptions) bool {
|
||||
var current: ?*Element = el;
|
||||
while (current) |elem| : (current = elem.parentElement()) {
|
||||
@@ -776,6 +787,8 @@ const Priorities = struct {
|
||||
pointer_events_none: u64 = 0,
|
||||
overflow_x_scrolls: u64 = 0,
|
||||
overflow_y_scrolls: u64 = 0,
|
||||
overscroll_x_contains: u64 = 0,
|
||||
overscroll_y_contains: u64 = 0,
|
||||
};
|
||||
|
||||
fn compute(self: *StyleManager, el: *Element) Props {
|
||||
@@ -1020,7 +1033,7 @@ fn getBucketKey(compound: Selector.Compound) ?BucketKey {
|
||||
}
|
||||
|
||||
// The declaration names behind TrackedProperties, in field order.
|
||||
const property_names = [_][]const u8{ "display", "visibility", "opacity", "pointer-events", "overflow-x", "overflow-y" };
|
||||
const property_names = [_][]const u8{ "display", "visibility", "opacity", "pointer-events", "overflow-x", "overflow-y", "overscroll-behavior-x", "overscroll-behavior-y" };
|
||||
|
||||
/// Extracts the tracked properties from a style declaration. The object holds
|
||||
/// one entry per name in first-declared order, so folding it in order gives a
|
||||
@@ -1113,6 +1126,8 @@ const TrackedProperties = struct {
|
||||
pointer_events_none: ?bool = null,
|
||||
overflow_x_scrolls: ?bool = null,
|
||||
overflow_y_scrolls: ?bool = null,
|
||||
overscroll_x_contains: ?bool = null,
|
||||
overscroll_y_contains: ?bool = null,
|
||||
|
||||
fn apply(self: *TrackedProperties, name: []const u8, value: []const u8) void {
|
||||
if (std.ascii.eqlIgnoreCase(name, "display")) {
|
||||
@@ -1127,6 +1142,10 @@ const TrackedProperties = struct {
|
||||
self.overflow_x_scrolls = overflowScrolls(value);
|
||||
} else if (std.ascii.eqlIgnoreCase(name, "overflow-y")) {
|
||||
self.overflow_y_scrolls = overflowScrolls(value);
|
||||
} else if (std.ascii.eqlIgnoreCase(name, "overscroll-behavior-x")) {
|
||||
self.overscroll_x_contains = overscrollContains(value);
|
||||
} else if (std.ascii.eqlIgnoreCase(name, "overscroll-behavior-y")) {
|
||||
self.overscroll_y_contains = overscrollContains(value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1137,6 +1156,13 @@ const TrackedProperties = struct {
|
||||
std.ascii.eqlIgnoreCase(value, "overlay");
|
||||
}
|
||||
|
||||
// `contain` keeps the scroll in the box, `none` also kills the bounce we
|
||||
// don't render anyway; only `auto` lets a scroll chain outward.
|
||||
fn overscrollContains(value: []const u8) bool {
|
||||
return std.ascii.eqlIgnoreCase(value, "contain") or
|
||||
std.ascii.eqlIgnoreCase(value, "none");
|
||||
}
|
||||
|
||||
fn isRelevant(self: TrackedProperties) bool {
|
||||
inline for (property_fields) |field| {
|
||||
if (@field(self, field) != null) {
|
||||
@@ -1300,10 +1326,10 @@ const Slots = struct {
|
||||
slots: [property_names.len]Slot = @splat(.{}),
|
||||
|
||||
fn apply(self: *Slots, name: []const u8, value: []const u8, important: bool) void {
|
||||
if (std.ascii.eqlIgnoreCase(name, "overflow")) {
|
||||
const values = CssParser.splitOverflow(value) orelse return;
|
||||
self.apply("overflow-x", values.x, important);
|
||||
self.apply("overflow-y", values.y, important);
|
||||
if (CssParser.axisShorthand(name)) |shorthand| {
|
||||
const values = CssParser.splitAxisPair(value) orelse return;
|
||||
self.apply(shorthand.x, values.x, important);
|
||||
self.apply(shorthand.y, values.y, important);
|
||||
return;
|
||||
}
|
||||
for (property_names, &self.slots) |tracked, *slot| {
|
||||
@@ -1752,6 +1778,15 @@ test "StyleManager: memo: reuse and invalidation" {
|
||||
try (try b.getOrCreateStyle(frame)).asCSSStyleDeclaration().setProperty("overflow", "hidden", null, frame);
|
||||
try testing.expectEqual(Element.ScrollAxes{}, sm.overflowAxes(b));
|
||||
|
||||
// overscroll-behavior expands the same way; only `auto` chains outward.
|
||||
try b.setStyle("overscroll-behavior: contain auto", frame);
|
||||
try testing.expectEqual(Element.ScrollAxes{ .x = true, .y = false }, sm.overscrollContainAxes(b));
|
||||
try testing.expectEqual(Element.ScrollAxes{}, sm.overscrollContainAxes(p));
|
||||
try b.setStyle("overscroll-behavior-y: none", frame);
|
||||
try testing.expectEqual(Element.ScrollAxes{ .x = false, .y = true }, sm.overscrollContainAxes(b));
|
||||
try b.setStyle("overscroll-behavior: contain; overscroll-behavior-x: auto", frame);
|
||||
try testing.expectEqual(Element.ScrollAxes{ .x = false, .y = true }, sm.overscrollContainAxes(b));
|
||||
|
||||
// A stylesheet change resets the memo
|
||||
sm.sheetModified();
|
||||
try testing.expectEqual(false, sm.isHidden(p, .{}));
|
||||
|
||||
@@ -25,11 +25,59 @@ pub const Declaration = struct {
|
||||
important: bool,
|
||||
};
|
||||
|
||||
pub const OverflowValues = struct { x: []const u8, y: []const u8 };
|
||||
pub const AxisPair = struct { x: []const u8, y: []const u8 };
|
||||
|
||||
/// `overflow: <x> [<y>]`; a single value applies to both axes. More than two
|
||||
/// values is invalid and null, as is an empty declaration.
|
||||
pub fn splitOverflow(value: []const u8) ?OverflowValues {
|
||||
pub const AxisShorthand = struct {
|
||||
name: []const u8,
|
||||
x: []const u8,
|
||||
y: []const u8,
|
||||
};
|
||||
|
||||
// The `<x> [<y>]` shorthands whose longhands the style cascade tracks. Both the
|
||||
// CSSOM object and the cascade store these expanded: setting one sets both
|
||||
// longhands, reading or serializing it recombines them.
|
||||
pub const axis_shorthands = [_]AxisShorthand{
|
||||
.{ .name = "overflow", .x = "overflow-x", .y = "overflow-y" },
|
||||
.{ .name = "overscroll-behavior", .x = "overscroll-behavior-x", .y = "overscroll-behavior-y" },
|
||||
};
|
||||
|
||||
/// The axis shorthand `name` names, if it names one.
|
||||
pub fn axisShorthand(name: []const u8) ?AxisShorthand {
|
||||
for (axis_shorthands) |shorthand| {
|
||||
if (std.ascii.eqlIgnoreCase(name, shorthand.name)) {
|
||||
return shorthand;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
pub const AxisLonghand = struct {
|
||||
shorthand: AxisShorthand,
|
||||
is_x: bool,
|
||||
|
||||
/// The longhand on the other axis.
|
||||
pub fn partner(self: AxisLonghand) []const u8 {
|
||||
return if (self.is_x) self.shorthand.y else self.shorthand.x;
|
||||
}
|
||||
};
|
||||
|
||||
/// The axis shorthand `name` is a longhand of, if it is one.
|
||||
pub fn axisLonghand(name: []const u8) ?AxisLonghand {
|
||||
for (axis_shorthands) |shorthand| {
|
||||
if (std.ascii.eqlIgnoreCase(name, shorthand.x)) {
|
||||
return .{ .shorthand = shorthand, .is_x = true };
|
||||
}
|
||||
if (std.ascii.eqlIgnoreCase(name, shorthand.y)) {
|
||||
return .{ .shorthand = shorthand, .is_x = false };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// An `<x> [<y>]` axis shorthand such as `overflow` or `overscroll-behavior`;
|
||||
/// a single value applies to both axes. More than two values is invalid and
|
||||
/// null, as is an empty declaration.
|
||||
pub fn splitAxisPair(value: []const u8) ?AxisPair {
|
||||
var it = std.mem.tokenizeAny(u8, value, &std.ascii.whitespace);
|
||||
const x = it.next() orelse return null;
|
||||
const y = it.next() orelse x;
|
||||
|
||||
@@ -487,16 +487,44 @@ pub fn wheel(frame: *Frame, target: *Element, x: f64, y: f64, delta_x: f64, delt
|
||||
}
|
||||
|
||||
// Deltas come from the wire, so guard NaN and saturate the addition.
|
||||
try wheelScroll(target, deltaToScroll(delta_x), deltaToScroll(delta_y), owner);
|
||||
try scrollAxis(target, .width, deltaToScroll(delta_x), owner);
|
||||
try scrollAxis(target, .height, deltaToScroll(delta_y), owner);
|
||||
}
|
||||
|
||||
/// Each axis scrolls the nearest ancestor-or-self scroll container along it,
|
||||
/// else the viewport. Relative deltas may land on different scrollers per
|
||||
/// axis, unlike an absolute position.
|
||||
fn wheelScroll(target: *Element, delta_x: i32, delta_y: i32, frame: *Frame) !void {
|
||||
// A zero delta resolves to .viewport and scrolls it by nothing.
|
||||
try target.scrollContainer(.{ .x = delta_x != 0 }, frame).scrollBy(delta_x, 0, frame);
|
||||
try target.scrollContainer(.{ .y = delta_y != 0 }, frame).scrollBy(0, delta_y, frame);
|
||||
/// A wheel latches to a single scroller and a delta is never split across two,
|
||||
/// as in Chrome's FindNodeToLatch (cc/input/input_handler.cc): the whole delta
|
||||
/// goes to the nearest ancestor-or-self container that can still move along
|
||||
/// this axis. One whose overscroll-behavior doesn't propagate takes the latch
|
||||
/// even when it can't move, which ends the walk.
|
||||
fn scrollAxis(target: *Element, comptime axis: Element.Axis, delta: i32, frame: *Frame) !void {
|
||||
if (delta == 0) {
|
||||
return;
|
||||
}
|
||||
const axes: Element.ScrollAxes = switch (axis) {
|
||||
.width => .{ .x = true },
|
||||
.height => .{ .y = true },
|
||||
};
|
||||
|
||||
var current: ?*Element = target;
|
||||
while (current) |el| {
|
||||
const container = switch (el.scrollContainer(axes, frame)) {
|
||||
.container => |c| c,
|
||||
.viewport => break,
|
||||
};
|
||||
if (try container.scrollByAxis(axis, delta, frame)) {
|
||||
return;
|
||||
}
|
||||
if (container.containsOverscroll(axes, frame)) {
|
||||
return;
|
||||
}
|
||||
current = container.parentElement();
|
||||
}
|
||||
|
||||
const opts: Element.ScrollToOpts = switch (axis) {
|
||||
.width => .{ .opts = .{ .left = delta } },
|
||||
.height => .{ .opts = .{ .top = delta } },
|
||||
};
|
||||
return frame.window.scrollBy(opts, null, frame);
|
||||
}
|
||||
|
||||
fn deltaToScroll(d: f64) i32 {
|
||||
|
||||
@@ -600,3 +600,32 @@
|
||||
testing.expectEqual(targetY, window.scrollY);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div id="clampBox" style="width: 100px; height: 100px; overflow: scroll;">
|
||||
<div style="width: 500px; height: 500px;">content</div>
|
||||
</div>
|
||||
|
||||
<script id="scrollClampsToExtent">
|
||||
{
|
||||
// The limit is scrollHeight - clientHeight, whatever those measure: a real
|
||||
// browser reserves scrollbar space in clientHeight and lands a few px lower.
|
||||
const box = $('#clampBox');
|
||||
const maxTop = box.scrollHeight - box.clientHeight;
|
||||
const maxLeft = box.scrollWidth - box.clientWidth;
|
||||
testing.expectTrue(maxTop > 0);
|
||||
|
||||
box.scrollTop = 9999;
|
||||
box.scrollLeft = 9999;
|
||||
testing.expectEqual(maxTop, box.scrollTop);
|
||||
testing.expectEqual(maxLeft, box.scrollLeft);
|
||||
|
||||
box.scrollTo(10, 10);
|
||||
box.scrollBy(9999, 9999);
|
||||
testing.expectEqual(maxTop, box.scrollTop);
|
||||
testing.expectEqual(maxLeft, box.scrollLeft);
|
||||
|
||||
box.scrollBy(-9999, -9999);
|
||||
testing.expectEqual(0, box.scrollTop);
|
||||
testing.expectEqual(0, box.scrollLeft);
|
||||
}
|
||||
</script>
|
||||
@@ -113,6 +113,25 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="overscrollBehaviorShorthand">
|
||||
{
|
||||
const div = document.createElement('div');
|
||||
div.style.cssText = 'overscroll-behavior: contain auto';
|
||||
testing.expectEqual('contain', div.style.overscrollBehaviorX);
|
||||
testing.expectEqual('auto', div.style.overscrollBehaviorY);
|
||||
testing.expectEqual('contain auto', div.style.overscrollBehavior);
|
||||
testing.expectEqual(2, div.style.length);
|
||||
|
||||
div.style.overscrollBehaviorY = 'contain';
|
||||
testing.expectEqual('contain', div.style.overscrollBehavior);
|
||||
testing.expectEqual('overscroll-behavior: contain;', div.style.cssText);
|
||||
|
||||
div.style.setProperty('overscroll-behavior', 'none');
|
||||
testing.expectEqual('none', div.style.overscrollBehaviorX);
|
||||
testing.expectEqual('none', div.style.overscrollBehaviorY);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="overflowShorthand">
|
||||
{
|
||||
const div = document.createElement('div');
|
||||
|
||||
+124
-56
@@ -1604,16 +1604,7 @@ pub fn getScrollTop(self: *Element, frame: *Frame) u32 {
|
||||
}
|
||||
|
||||
pub fn setScrollTop(self: *Element, value: i32, frame: *Frame) !void {
|
||||
const owner = self.ownerFrame(frame) orelse return;
|
||||
const gop = try owner.page.element_scroll_positions.getOrPut(owner.page.frame_arena, self);
|
||||
if (!gop.found_existing) {
|
||||
gop.value_ptr.* = .{};
|
||||
}
|
||||
const new_y: u32 = @intCast(@max(0, value));
|
||||
if (gop.value_ptr.y != new_y) {
|
||||
gop.value_ptr.y = new_y;
|
||||
try self.scheduleScrollEvents(owner);
|
||||
}
|
||||
_ = try self.writeScroll(.{ .to = .{ .left = null, .top = value } }, frame);
|
||||
}
|
||||
|
||||
pub fn getScrollLeft(self: *Element, frame: *Frame) u32 {
|
||||
@@ -1623,16 +1614,7 @@ pub fn getScrollLeft(self: *Element, frame: *Frame) u32 {
|
||||
}
|
||||
|
||||
pub fn setScrollLeft(self: *Element, value: i32, frame: *Frame) !void {
|
||||
const owner = self.ownerFrame(frame) orelse return;
|
||||
const gop = try owner.page.element_scroll_positions.getOrPut(owner.page.frame_arena, self);
|
||||
if (!gop.found_existing) {
|
||||
gop.value_ptr.* = .{};
|
||||
}
|
||||
const new_x: u32 = @intCast(@max(0, value));
|
||||
if (gop.value_ptr.x != new_x) {
|
||||
gop.value_ptr.x = new_x;
|
||||
try self.scheduleScrollEvents(owner);
|
||||
}
|
||||
_ = try self.writeScroll(.{ .to = .{ .left = value, .top = null } }, frame);
|
||||
}
|
||||
|
||||
pub const ScrollAxes = struct { x: bool = false, y: bool = false };
|
||||
@@ -1642,14 +1624,6 @@ pub const ScrollAxes = struct { x: bool = false, y: bool = false };
|
||||
const ScrollTarget = union(enum) {
|
||||
viewport,
|
||||
container: *Element,
|
||||
|
||||
pub fn scrollBy(self: ScrollTarget, left: i32, top: i32, frame: *Frame) !void {
|
||||
const opts: ScrollToOpts = .{ .opts = .{ .left = left, .top = top } };
|
||||
return switch (self) {
|
||||
.container => |el| el.scrollBy(opts, null, frame),
|
||||
.viewport => frame.window.scrollBy(opts, null, frame),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/// Nearest ancestor-or-self scroll container along any of `axes`. The walk
|
||||
@@ -1669,6 +1643,14 @@ pub fn scrollContainer(self: *Element, axes: ScrollAxes, frame: *Frame) ScrollTa
|
||||
return .viewport;
|
||||
}
|
||||
|
||||
/// Whether the element's own overscroll-behavior keeps a scroll from chaining
|
||||
/// out of it.
|
||||
pub fn containsOverscroll(self: *Element, axes: ScrollAxes, frame: *Frame) bool {
|
||||
const owner = self.ownerFrame(frame) orelse return false;
|
||||
const contains = owner._style_manager.overscrollContainAxes(self);
|
||||
return (axes.x and contains.x) or (axes.y and contains.y);
|
||||
}
|
||||
|
||||
fn scrollsViewport(self: *const Element) bool {
|
||||
return switch (self.getTag()) {
|
||||
.html, .body => true,
|
||||
@@ -1711,6 +1693,26 @@ pub fn getScrollWidth(self: *Element, frame: *Frame) f64 {
|
||||
return @max(width, self.contentAxis(frame, .width));
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn scrollExtent(self: *Element, frame: *Frame, comptime axis: Axis) ?f64 {
|
||||
if (self.scrollsViewport() or !self.getElementAxis(frame, axis).explicit) {
|
||||
return null;
|
||||
}
|
||||
const client = self.clientAxis(frame, axis);
|
||||
const content = switch (axis) {
|
||||
.width => self.getScrollWidth(frame),
|
||||
.height => self.getScrollHeight(frame),
|
||||
};
|
||||
if (content <= client) {
|
||||
return null;
|
||||
}
|
||||
return content - client;
|
||||
}
|
||||
|
||||
// One axis of the direct child elements' size: laid end to end on a single
|
||||
// row for the width, stacked for the height.
|
||||
//
|
||||
@@ -2040,55 +2042,97 @@ pub const ScrollToOpts = union(enum) {
|
||||
|
||||
pub fn scrollTo(self: *Element, opts: ?ScrollToOpts, y: ?i32, frame: *Frame) !void {
|
||||
const o = (opts orelse return).offsets(y);
|
||||
const owner = self.ownerFrame(frame) orelse return;
|
||||
const gop = try owner.page.element_scroll_positions.getOrPut(owner.page.frame_arena, self);
|
||||
if (!gop.found_existing) {
|
||||
gop.value_ptr.* = .{};
|
||||
}
|
||||
const old_x = gop.value_ptr.x;
|
||||
const old_y = gop.value_ptr.y;
|
||||
if (o.left) |left| gop.value_ptr.x = @intCast(@max(0, left));
|
||||
if (o.top) |top| gop.value_ptr.y = @intCast(@max(0, top));
|
||||
if (gop.value_ptr.x != old_x or gop.value_ptr.y != old_y) {
|
||||
try self.scheduleScrollEvents(owner);
|
||||
}
|
||||
_ = try self.writeScroll(.{ .to = o }, frame);
|
||||
}
|
||||
|
||||
// scrollBy(): like scrollTo() but relative to the current position.
|
||||
pub fn scrollBy(self: *Element, opts: ?ScrollToOpts, y: ?i32, frame: *Frame) !void {
|
||||
const o = (opts orelse return).offsets(y);
|
||||
const owner = self.ownerFrame(frame) orelse return;
|
||||
_ = try self.writeScroll(.{ .by = o }, frame);
|
||||
}
|
||||
|
||||
/// Reports whether the container moved: a wheel walks outward until one does.
|
||||
pub fn scrollByAxis(self: *Element, comptime axis: Axis, delta: i32, frame: *Frame) !bool {
|
||||
return self.writeScroll(.{ .by = switch (axis) {
|
||||
.width => .{ .left = delta, .top = null },
|
||||
.height => .{ .left = null, .top = delta },
|
||||
} }, frame);
|
||||
}
|
||||
|
||||
const ScrollWrite = union(enum) {
|
||||
to: ScrollToOpts.Offsets,
|
||||
by: ScrollToOpts.Offsets,
|
||||
|
||||
// Null leaves that axis alone.
|
||||
fn target(self: ScrollWrite, comptime axis: Axis, current: u32) ?i64 {
|
||||
const offsets = switch (self) {
|
||||
inline else => |o| o,
|
||||
};
|
||||
const value = switch (axis) {
|
||||
.width => offsets.left,
|
||||
.height => offsets.top,
|
||||
} orelse return null;
|
||||
return switch (self) {
|
||||
.to => value,
|
||||
.by => @as(i64, current) + value,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/// Every scroll write goes through here. Reports whether anything moved; one
|
||||
/// that lands where the offsets already are doesn't even take a map entry.
|
||||
fn writeScroll(self: *Element, write: ScrollWrite, frame: *Frame) !bool {
|
||||
const owner = self.ownerFrame(frame) orelse return false;
|
||||
const current: ScrollPosition = owner.page.element_scroll_positions.get(self) orelse .{};
|
||||
|
||||
var x = current.x;
|
||||
var y = current.y;
|
||||
if (write.target(.width, current.x)) |target| {
|
||||
x = self.clampScroll(frame, .width, target);
|
||||
}
|
||||
if (write.target(.height, current.y)) |target| {
|
||||
y = self.clampScroll(frame, .height, target);
|
||||
}
|
||||
if (x == current.x and y == current.y) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const gop = try owner.page.element_scroll_positions.getOrPut(owner.page.frame_arena, self);
|
||||
if (!gop.found_existing) {
|
||||
gop.value_ptr.* = .{};
|
||||
}
|
||||
const old_x = gop.value_ptr.x;
|
||||
const old_y = gop.value_ptr.y;
|
||||
gop.value_ptr.x = @intCast(@max(0, @as(i32, @intCast(gop.value_ptr.x)) +| (o.left orelse 0)));
|
||||
gop.value_ptr.y = @intCast(@max(0, @as(i32, @intCast(gop.value_ptr.y)) +| (o.top orelse 0)));
|
||||
if (gop.value_ptr.x != old_x or gop.value_ptr.y != old_y) {
|
||||
try self.scheduleScrollEvents(owner);
|
||||
gop.value_ptr.x = x;
|
||||
gop.value_ptr.y = y;
|
||||
try self.scheduleScrollEvents(gop.value_ptr, owner);
|
||||
return true;
|
||||
}
|
||||
|
||||
fn clampScroll(self: *Element, frame: *Frame, comptime axis: Axis, target: i64) u32 {
|
||||
var clamped = target;
|
||||
if (clamped < 0) {
|
||||
clamped = 0;
|
||||
} else if (self.scrollExtent(frame, axis)) |extent| {
|
||||
const max: i64 = @floor(extent);
|
||||
clamped = @min(clamped, max);
|
||||
}
|
||||
return @intCast(@min(clamped, std.math.maxInt(u32)));
|
||||
}
|
||||
|
||||
// Scrolling an element fires a scroll event and then a scrollend event,
|
||||
// asynchronously and throttled, mirroring Window.scrollTo. Scrolls of the
|
||||
// scrolling element (the root) are fired at the document instead.
|
||||
// `frame` is the element's owner frame (resolved by the public accessors).
|
||||
fn scheduleScrollEvents(self: *Element, frame: *Frame) !void {
|
||||
const gop = try frame.page.element_scroll_positions.getOrPut(frame.page.frame_arena, self);
|
||||
if (!gop.found_existing) {
|
||||
gop.value_ptr.* = .{};
|
||||
}
|
||||
const task_pending = gop.value_ptr.state != .done;
|
||||
gop.value_ptr.state = .scroll;
|
||||
// `frame` is the element's owner frame and `pos` its entry there, both
|
||||
// resolved by writeScroll.
|
||||
fn scheduleScrollEvents(self: *Element, pos: *ScrollPosition, frame: *Frame) !void {
|
||||
const task_pending = pos.state != .done;
|
||||
pos.state = .scroll;
|
||||
if (task_pending) {
|
||||
return;
|
||||
}
|
||||
|
||||
const task = try frame._factory.create(ScrollEventTask{ .frame = frame, .element = self });
|
||||
errdefer {
|
||||
gop.value_ptr.state = .done;
|
||||
pos.state = .done;
|
||||
frame._factory.destroy(task);
|
||||
}
|
||||
try frame.js.scheduler.add(task, ScrollEventTask.run, 10, .{
|
||||
@@ -2748,6 +2792,30 @@ test "WebApi: Element" {
|
||||
try testing.htmlRunner("element", .{});
|
||||
}
|
||||
|
||||
test "Element: scroll extent" {
|
||||
const frame = try testing.createFrame();
|
||||
defer testing.test_session.closeAllPages();
|
||||
|
||||
const root = try frame.window._document.createElement("div", null, frame);
|
||||
try Frame.parse.htmlAsChildren(frame, root.asNode(),
|
||||
\\<div style="height: 100px; overflow: auto"><div style="height: 500px"></div></div>
|
||||
\\<div style="height: 100px; overflow: auto">text, and no element child to measure</div>
|
||||
);
|
||||
const box = root.asNode().firstChild().?.as(Element);
|
||||
const text = box.nextElementSibling().?;
|
||||
|
||||
try box.setScrollTop(9999, frame);
|
||||
try testing.expectEqual(400, box.getScrollTop(frame));
|
||||
try box.setScrollTop(-1, frame);
|
||||
try testing.expectEqual(0, box.getScrollTop(frame));
|
||||
|
||||
// A real browser clamps this one too, to the height of its text. contentAxis
|
||||
// sums element children only, so we have no extent to clamp against and the
|
||||
// offset stays unbounded.
|
||||
try text.setScrollTop(9999, frame);
|
||||
try testing.expectEqual(9999, text.getScrollTop(frame));
|
||||
}
|
||||
|
||||
test "Element: div chain slot size" {
|
||||
// Guard against accidental growth: new Element fields (e.g. _flags) must
|
||||
// fit in existing padding. Debug is larger from the _proto_canary fields.
|
||||
|
||||
@@ -1016,8 +1016,10 @@ pub fn scrollTo(self: *Window, opts: Element.ScrollToOpts, y: ?i32, frame: *Fram
|
||||
|
||||
pub fn scrollBy(self: *Window, opts: Element.ScrollToOpts, y: ?i32, frame: *Frame) !void {
|
||||
const o = opts.offsets(y);
|
||||
const absx = @as(i32, @intCast(self._scroll_pos.x)) +| (o.left orelse 0);
|
||||
const absy = @as(i32, @intCast(self._scroll_pos.y)) +| (o.top orelse 0);
|
||||
// The viewport has no honest extent, so a stored offset can sit above
|
||||
// maxInt(i32): widen before saturating back down.
|
||||
const absx: i32 = @intCast(@min(@as(i64, self._scroll_pos.x) + (o.left orelse 0), std.math.maxInt(i32)));
|
||||
const absy: i32 = @intCast(@min(@as(i64, self._scroll_pos.y) + (o.top orelse 0), std.math.maxInt(i32)));
|
||||
return self.scrollTo(.{ .x = absx }, absy, frame);
|
||||
}
|
||||
|
||||
|
||||
@@ -136,13 +136,13 @@ pub fn getPropertyValue(self: *const CSSStyleDeclaration, property_name: []const
|
||||
return getDefaultPropertyValue(self, wrapped);
|
||||
}
|
||||
|
||||
/// The value of a declared property, or null when it isn't declared. The
|
||||
/// `overflow` shorthand reads as its longhands when both are present with
|
||||
/// the same priority, the way the CSSOM serializes a shorthand.
|
||||
/// The value of a declared property, or null when it isn't declared. An axis
|
||||
/// shorthand reads as its longhands when both are present with the same
|
||||
/// priority, the way the CSSOM serializes a shorthand.
|
||||
pub fn declaredValue(self: *const CSSStyleDeclaration, name: String, frame: *Frame) ?[]const u8 {
|
||||
if (name.eql(overflow_shorthand)) {
|
||||
const x = self.findProperty(comptime .wrap("overflow-x")) orelse return null;
|
||||
const pair = self.overflowPair(x) orelse return null;
|
||||
if (CssParser.axisShorthand(name.str())) |shorthand| {
|
||||
const x = self.findProperty(.wrap(shorthand.x)) orelse return null;
|
||||
const pair = self.axisPair(x) orelse return null;
|
||||
var buf = std.Io.Writer.Allocating.init(frame.local_arena);
|
||||
pair.formatValue(&buf.writer) catch return null;
|
||||
return buf.written();
|
||||
@@ -151,19 +151,14 @@ pub fn declaredValue(self: *const CSSStyleDeclaration, name: String, frame: *Fra
|
||||
return prop._value.str();
|
||||
}
|
||||
|
||||
// `overflow` is the one shorthand whose longhands the style cascade folds
|
||||
// (StyleManager tracks overflow-x and overflow-y), so it is the one this
|
||||
// object stores expanded, as the CSSOM does for every shorthand: setting it
|
||||
// sets both longhands, reading or serializing it recombines them.
|
||||
const overflow_shorthand: String = .wrap("overflow");
|
||||
|
||||
/// Both overflow longhands, declared with the same priority: the pair reads
|
||||
/// and serializes as the shorthand.
|
||||
const OverflowPair = struct {
|
||||
/// Both longhands of an axis shorthand, declared with the same priority: the
|
||||
/// pair reads and serializes as the shorthand.
|
||||
const AxisPair = struct {
|
||||
name: []const u8,
|
||||
x: *const Property,
|
||||
y: *const Property,
|
||||
|
||||
fn formatValue(self: OverflowPair, writer: *std.Io.Writer) !void {
|
||||
fn formatValue(self: AxisPair, writer: *std.Io.Writer) !void {
|
||||
try self.x._value.format(writer);
|
||||
if (!self.x._value.eql(self.y._value)) {
|
||||
try writer.writeByte(' ');
|
||||
@@ -171,26 +166,24 @@ const OverflowPair = struct {
|
||||
}
|
||||
}
|
||||
|
||||
fn format(self: OverflowPair, writer: *std.Io.Writer) !void {
|
||||
try writer.writeAll("overflow: ");
|
||||
fn format(self: AxisPair, writer: *std.Io.Writer) !void {
|
||||
try writer.writeAll(self.name);
|
||||
try writer.writeAll(": ");
|
||||
try self.formatValue(writer);
|
||||
try formatDeclarationEnd(self.x._important, writer);
|
||||
}
|
||||
};
|
||||
|
||||
/// The pair `prop` belongs to, when it is an overflow longhand and the other
|
||||
/// is declared with the same priority.
|
||||
fn overflowPair(self: *const CSSStyleDeclaration, prop: *const Property) ?OverflowPair {
|
||||
const is_x = prop._name.eql(comptime .wrap("overflow-x"));
|
||||
if (!is_x and !prop._name.eql(comptime .wrap("overflow-y"))) {
|
||||
return null;
|
||||
}
|
||||
const other: String = if (is_x) comptime .wrap("overflow-y") else comptime .wrap("overflow-x");
|
||||
const partner = self.findProperty(other) orelse return null;
|
||||
/// The pair `prop` belongs to, when it is an axis longhand and the other is
|
||||
/// declared with the same priority.
|
||||
fn axisPair(self: *const CSSStyleDeclaration, prop: *const Property) ?AxisPair {
|
||||
const longhand = CssParser.axisLonghand(prop._name.str()) orelse return null;
|
||||
const partner = self.findProperty(.wrap(longhand.partner())) orelse return null;
|
||||
if (partner._important != prop._important) {
|
||||
return null;
|
||||
}
|
||||
return if (is_x) .{ .x = prop, .y = partner } else .{ .x = partner, .y = prop };
|
||||
const name = longhand.shorthand.name;
|
||||
return if (longhand.is_x) .{ .name = name, .x = prop, .y = partner } else .{ .name = name, .x = partner, .y = prop };
|
||||
}
|
||||
|
||||
fn resolvedDimension(element: *Element, dimension: enum { width, height }, frame: *Frame) []const u8 {
|
||||
@@ -207,9 +200,9 @@ fn resolvedDimension(element: *Element, dimension: enum { width, height }, frame
|
||||
pub fn getPropertyPriority(self: *const CSSStyleDeclaration, property_name: []const u8, frame: *Frame) []const u8 {
|
||||
const normalized = normalizePropertyName(property_name, &frame.buf);
|
||||
const wrapped = String.wrap(normalized);
|
||||
if (wrapped.eql(overflow_shorthand)) {
|
||||
const x = self.findProperty(comptime .wrap("overflow-x")) orelse return "";
|
||||
const pair = self.overflowPair(x) orelse return "";
|
||||
if (CssParser.axisShorthand(normalized)) |shorthand| {
|
||||
const x = self.findProperty(.wrap(shorthand.x)) orelse return "";
|
||||
const pair = self.axisPair(x) orelse return "";
|
||||
return if (pair.x._important) "important" else "";
|
||||
}
|
||||
const prop = self.findProperty(wrapped) orelse return "";
|
||||
@@ -240,10 +233,10 @@ pub fn setProperty(self: *CSSStyleDeclaration, property_name: []const u8, value:
|
||||
/// not override an earlier !important one (CSS cascade precedence).
|
||||
fn applyParsedDeclaration(self: *CSSStyleDeclaration, declaration: CssParser.Declaration, frame: *Frame) !void {
|
||||
const normalized = normalizePropertyName(declaration.name, &frame.buf);
|
||||
if (overflow_shorthand.eqlSlice(normalized)) {
|
||||
const values = CssParser.splitOverflow(declaration.value) orelse return;
|
||||
try self.applyParsedDeclaration(.{ .name = "overflow-x", .value = values.x, .important = declaration.important }, frame);
|
||||
try self.applyParsedDeclaration(.{ .name = "overflow-y", .value = values.y, .important = declaration.important }, frame);
|
||||
if (CssParser.axisShorthand(normalized)) |shorthand| {
|
||||
const values = CssParser.splitAxisPair(declaration.value) orelse return;
|
||||
try self.applyParsedDeclaration(.{ .name = shorthand.x, .value = values.x, .important = declaration.important }, frame);
|
||||
try self.applyParsedDeclaration(.{ .name = shorthand.y, .value = values.y, .important = declaration.important }, frame);
|
||||
return;
|
||||
}
|
||||
if (!declaration.important) {
|
||||
@@ -266,10 +259,10 @@ fn setPropertyImpl(self: *CSSStyleDeclaration, property_name: []const u8, value:
|
||||
}
|
||||
|
||||
const normalized = normalizePropertyName(property_name, &frame.buf);
|
||||
if (overflow_shorthand.eqlSlice(normalized)) {
|
||||
const values = CssParser.splitOverflow(value) orelse return false;
|
||||
const x = try self.setPropertyImpl("overflow-x", values.x, important, frame);
|
||||
const y = try self.setPropertyImpl("overflow-y", values.y, important, frame);
|
||||
if (CssParser.axisShorthand(normalized)) |shorthand| {
|
||||
const values = CssParser.splitAxisPair(value) orelse return false;
|
||||
const x = try self.setPropertyImpl(shorthand.x, values.x, important, frame);
|
||||
const y = try self.setPropertyImpl(shorthand.y, values.y, important, frame);
|
||||
return x or y;
|
||||
}
|
||||
|
||||
@@ -309,10 +302,10 @@ pub fn removeProperty(self: *CSSStyleDeclaration, property_name: []const u8, fra
|
||||
|
||||
fn removePropertyImpl(self: *CSSStyleDeclaration, property_name: []const u8, frame: *Frame) !?[]const u8 {
|
||||
const normalized = normalizePropertyName(property_name, &frame.buf);
|
||||
if (overflow_shorthand.eqlSlice(normalized)) {
|
||||
const old_value = self.declaredValue(overflow_shorthand, frame) orelse "";
|
||||
const x = try self.removePropertyImpl("overflow-x", frame);
|
||||
const y = try self.removePropertyImpl("overflow-y", frame);
|
||||
if (CssParser.axisShorthand(normalized)) |shorthand| {
|
||||
const old_value = self.declaredValue(.wrap(shorthand.name), frame) orelse "";
|
||||
const x = try self.removePropertyImpl(shorthand.x, frame);
|
||||
const y = try self.removePropertyImpl(shorthand.y, frame);
|
||||
if (x == null and y == null) {
|
||||
return null;
|
||||
}
|
||||
@@ -402,7 +395,7 @@ pub fn replaceCssText(self: *CSSStyleDeclaration, text: []const u8, frame: *Fram
|
||||
|
||||
pub fn format(self: *const CSSStyleDeclaration, writer: *std.Io.Writer) !void {
|
||||
var first = true;
|
||||
// An overflow pair serializes once, where its first longhand sits.
|
||||
// An axis pair serializes once, where its first longhand sits.
|
||||
var skip: ?*const Property = null;
|
||||
var it = self.iterator();
|
||||
while (it.next()) |prop| {
|
||||
@@ -413,7 +406,7 @@ pub fn format(self: *const CSSStyleDeclaration, writer: *std.Io.Writer) !void {
|
||||
try writer.writeByte(' ');
|
||||
}
|
||||
first = false;
|
||||
if (self.overflowPair(prop)) |pair| {
|
||||
if (self.axisPair(prop)) |pair| {
|
||||
try pair.format(writer);
|
||||
skip = if (pair.x == prop) pair.y else pair.x;
|
||||
} else {
|
||||
|
||||
@@ -514,6 +514,72 @@ test "cdp.input: dispatchMouseEvent mouseWheel scrolls a scroll container, not t
|
||||
try runner.waitForScript(frame._frame_id, "window.sheetScrolled === true", 1000);
|
||||
}
|
||||
|
||||
test "cdp.input: dispatchMouseEvent mouseWheel chains once the container is saturated" {
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
const bc = try ctx.loadBrowserContext(.{});
|
||||
const page = try bc.session.createPage();
|
||||
const frame = page.frame().?;
|
||||
|
||||
const url = "http://localhost:9582/src/browser/tests/mcp_actions.html";
|
||||
try frame.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } });
|
||||
try testing.waitForPage(bc);
|
||||
|
||||
var ls: lp.js.Local.Scope = undefined;
|
||||
frame.js.localScope(&ls);
|
||||
defer ls.deinit();
|
||||
|
||||
var try_catch: lp.js.TryCatch = undefined;
|
||||
try_catch.init(&ls.local);
|
||||
defer try_catch.deinit();
|
||||
|
||||
const leaf_x = try (try ls.local.compileAndRun("document.getElementById('innerleaf').getBoundingClientRect().x", null)).toF64();
|
||||
const leaf_y = try (try ls.local.compileAndRun("document.getElementById('innerleaf').getBoundingClientRect().y", null)).toF64();
|
||||
|
||||
// #outerscroll is a 100px box over 500px of content. A wheel latches to one
|
||||
// scroller: the container takes the whole delta and keeps what doesn't fit,
|
||||
// rather than passing the rest on.
|
||||
try ctx.processMessage(.{
|
||||
.id = 1,
|
||||
.method = "Input.dispatchMouseEvent",
|
||||
.params = .{ .type = "mouseWheel", .x = leaf_x, .y = leaf_y, .deltaY = 1000 },
|
||||
});
|
||||
const latched = try ls.local.compileAndRun("document.getElementById('outerscroll').scrollTop === 400 && window.scrollY === 0", null);
|
||||
try testing.expect(latched.isTrue());
|
||||
|
||||
// Saturated now, so the next wheel latches to the viewport instead.
|
||||
try ctx.processMessage(.{
|
||||
.id = 2,
|
||||
.method = "Input.dispatchMouseEvent",
|
||||
.params = .{ .type = "mouseWheel", .x = leaf_x, .y = leaf_y, .deltaY = 100 },
|
||||
});
|
||||
const chained = try ls.local.compileAndRun("document.getElementById('outerscroll').scrollTop === 400 && window.scrollY === 100", null);
|
||||
try testing.expect(chained.isTrue());
|
||||
|
||||
// overscroll-behavior keeps the latch on a container that can't move, so
|
||||
// nothing scrolls at all.
|
||||
_ = try ls.local.compileAndRun("document.getElementById('outerscroll').style.overscrollBehavior = 'contain'", null);
|
||||
try ctx.processMessage(.{
|
||||
.id = 3,
|
||||
.method = "Input.dispatchMouseEvent",
|
||||
.params = .{ .type = "mouseWheel", .x = leaf_x, .y = leaf_y, .deltaY = 100 },
|
||||
});
|
||||
const contained = try ls.local.compileAndRun("document.getElementById('outerscroll').scrollTop === 400 && window.scrollY === 100", null);
|
||||
try testing.expect(contained.isTrue());
|
||||
|
||||
// Reversing direction latches back to the container, which can move again.
|
||||
// The 100 it can't give back stays unscrolled: no split here either.
|
||||
_ = try ls.local.compileAndRun("document.getElementById('outerscroll').style.overscrollBehavior = 'auto'", null);
|
||||
try ctx.processMessage(.{
|
||||
.id = 4,
|
||||
.method = "Input.dispatchMouseEvent",
|
||||
.params = .{ .type = "mouseWheel", .x = leaf_x, .y = leaf_y, .deltaY = -500 },
|
||||
});
|
||||
const upward = try ls.local.compileAndRun("document.getElementById('outerscroll').scrollTop === 0 && window.scrollY === 100", null);
|
||||
try testing.expect(upward.isTrue());
|
||||
}
|
||||
|
||||
test "cdp.input: dispatchMouseEvent mouseWheel on page content scrolls the viewport" {
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
Reference in new issue
Block a user