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:
Karl Seguin authored and GitHub committed 2026-09-23 06:47:17 +08:00
commit e1aec431d4
9 files changed
+409 -121

No files matched your search

+41 -6
View File
@@ -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, .{}));
+52 -4
View File
@@ -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;
+36 -8
View File
@@ -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 {
+29
View File
@@ -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>
+19
View File
@@ -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
View File
@@ -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.
+4 -2
View File
@@ -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);
}
+38 -45
View File
@@ -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 {
+66
View File
@@ -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();