mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-09-17 17:22:43 -04:00
Resolve scroll containers through the style cascade
Element.scrollContainer read the inline style= attribute only, so a scroller declared in a stylesheet was invisible to the scroll tool and to wheel scrolling, which then fell through to the viewport. StyleManager now tracks overflow-x and overflow-y alongside display, visibility, opacity and pointer-events, and exposes scrolls(el, axes) as an own-element probe. The overflow shorthand is expanded into its longhands in declaration order, in both the attribute scan and the materialized style object, so a shorthand and its longhands keep the precedence of the source text. overlay counts as auto, as in Chrome. Element.scrollContainer asks the style manager, and the two unused Props bits hold the new flags, so the per-element memo does not grow.
This commit is contained in:
1 parent
d50875fff1
commit
b3758045a8
6 files changed
+172
-62
No files matched your search
+116
-39
@@ -34,13 +34,14 @@ const CSSRule = @import("webapi/css/CSSRule.zig");
|
||||
const CSSStyleRule = @import("webapi/css/CSSStyleRule.zig");
|
||||
const CSSStyleSheet = @import("webapi/css/CSSStyleSheet.zig");
|
||||
const CSSStyleProperties = @import("webapi/css/CSSStyleProperties.zig");
|
||||
const CSSStyleDeclaration = @import("webapi/css/CSSStyleDeclaration.zig");
|
||||
|
||||
const log = lp.log;
|
||||
const String = lp.String;
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
// Tracks the CSS properties the renderless layout acts on (display, visibility,
|
||||
// opacity, pointer-events) from <style> elements.
|
||||
// opacity, pointer-events, overflow) from <style> elements.
|
||||
// Rules are bucketed by their rightmost selector part for fast lookup.
|
||||
const StyleManager = @This();
|
||||
|
||||
@@ -652,7 +653,8 @@ const Props = packed struct(u8) {
|
||||
visibility_hidden: bool = false,
|
||||
opacity_zero: bool = false,
|
||||
pointer_events_none: bool = false,
|
||||
_unused: u2 = 0,
|
||||
overflow_x_scrolls: bool = false,
|
||||
overflow_y_scrolls: bool = false,
|
||||
|
||||
fn probe(self: Props, comptime what: Probe, options: CheckVisibilityOptions) bool {
|
||||
return switch (what) {
|
||||
@@ -713,6 +715,14 @@ pub fn hasPointerEventsNone(self: *StyleManager, el: *Element) bool {
|
||||
return self.anyInChain(el, .pointer_events, .{});
|
||||
}
|
||||
|
||||
/// Whether `el` is a scroll container along any of `axes`: its own computed
|
||||
/// overflow on that axis is auto, scroll or overlay. No ancestor walk.
|
||||
pub fn scrolls(self: *StyleManager, el: *Element, axes: Element.ScrollAxes) bool {
|
||||
self.rebuildIfDirty() catch return false;
|
||||
const p = self.ownProps(el);
|
||||
return (axes.x and p.overflow_x_scrolls) or (axes.y and p.overflow_y_scrolls);
|
||||
}
|
||||
|
||||
fn anyInChain(self: *StyleManager, el: *Element, comptime what: Probe, options: CheckVisibilityOptions) bool {
|
||||
var current: ?*Element = el;
|
||||
while (current) |elem| : (current = elem.parentElement()) {
|
||||
@@ -751,6 +761,8 @@ const Priorities = struct {
|
||||
visibility_hidden: u64 = 0,
|
||||
opacity_zero: u64 = 0,
|
||||
pointer_events_none: u64 = 0,
|
||||
overflow_x_scrolls: u64 = 0,
|
||||
overflow_y_scrolls: u64 = 0,
|
||||
|
||||
fn allInline(self: Priorities) bool {
|
||||
inline for (property_fields) |field| {
|
||||
@@ -1006,18 +1018,19 @@ fn getBucketKey(compound: Selector.Compound) ?BucketKey {
|
||||
}
|
||||
|
||||
// The declaration names behind TrackedProperties, in field order.
|
||||
const property_names = [_][]const u8{ "display", "visibility", "opacity", "pointer-events" };
|
||||
const property_names = [_][]const u8{ "display", "visibility", "opacity", "pointer-events", "overflow-x", "overflow-y" };
|
||||
|
||||
/// Extracts the tracked properties from a style declaration.
|
||||
/// 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
|
||||
/// shorthand and its longhands the same precedence as the source text.
|
||||
fn extractTrackedProperties(style: *CSSStyleProperties) TrackedProperties {
|
||||
var props: TrackedProperties = .{};
|
||||
const decl = style.asCSSStyleDeclaration();
|
||||
for (property_names) |name| {
|
||||
if (decl.findProperty(.wrap(name))) |property| {
|
||||
props.apply(name, property._value.str());
|
||||
}
|
||||
var slots: Slots = .{};
|
||||
var node = style.asCSSStyleDeclaration()._properties.first;
|
||||
while (node) |n| : (node = n.next) {
|
||||
const property = CSSStyleDeclaration.Property.fromNodeLink(n);
|
||||
slots.apply(property._name.str(), property._value.str(), property._important);
|
||||
}
|
||||
return props;
|
||||
return slots.props();
|
||||
}
|
||||
|
||||
// Computes CSS specificity for a selector.
|
||||
@@ -1097,6 +1110,8 @@ const TrackedProperties = struct {
|
||||
visibility_hidden: ?bool = null,
|
||||
opacity_zero: ?bool = null,
|
||||
pointer_events_none: ?bool = null,
|
||||
overflow_x_scrolls: ?bool = null,
|
||||
overflow_y_scrolls: ?bool = null,
|
||||
|
||||
fn apply(self: *TrackedProperties, name: []const u8, value: []const u8) void {
|
||||
if (std.ascii.eqlIgnoreCase(name, "display")) {
|
||||
@@ -1107,9 +1122,20 @@ const TrackedProperties = struct {
|
||||
self.opacity_zero = std.ascii.eqlIgnoreCase(value, "0");
|
||||
} else if (std.ascii.eqlIgnoreCase(name, "pointer-events")) {
|
||||
self.pointer_events_none = std.ascii.eqlIgnoreCase(value, "none");
|
||||
} else if (std.ascii.eqlIgnoreCase(name, "overflow-x")) {
|
||||
self.overflow_x_scrolls = overflowScrolls(value);
|
||||
} else if (std.ascii.eqlIgnoreCase(name, "overflow-y")) {
|
||||
self.overflow_y_scrolls = overflowScrolls(value);
|
||||
}
|
||||
}
|
||||
|
||||
// `overlay` is Chrome's legacy alias of auto.
|
||||
fn overflowScrolls(value: []const u8) bool {
|
||||
return std.ascii.eqlIgnoreCase(value, "auto") or
|
||||
std.ascii.eqlIgnoreCase(value, "scroll") or
|
||||
std.ascii.eqlIgnoreCase(value, "overlay");
|
||||
}
|
||||
|
||||
fn isRelevant(self: TrackedProperties) bool {
|
||||
inline for (property_fields) |field| {
|
||||
if (@field(self, field) != null) {
|
||||
@@ -1238,24 +1264,7 @@ const CustomSink = struct {
|
||||
// properties are matched case-insensitively; a custom property's name is
|
||||
// case-sensitive and goes to `customs` when there is one.
|
||||
fn foldDeclarations(block: []const u8, customs: ?*CustomSink) !TrackedProperties {
|
||||
const Slot = struct {
|
||||
value: ?[]const u8 = null,
|
||||
important: bool = false,
|
||||
|
||||
fn apply(self: *@This(), declaration: CssParser.Declaration) void {
|
||||
if (self.important and !declaration.important) {
|
||||
return;
|
||||
}
|
||||
if (declaration.value.len == 0) {
|
||||
self.* = .{};
|
||||
return;
|
||||
}
|
||||
self.value = declaration.value;
|
||||
self.important = declaration.important;
|
||||
}
|
||||
};
|
||||
|
||||
var slots = [_]Slot{.{}} ** property_names.len;
|
||||
var slots: Slots = .{};
|
||||
var it = CssParser.parseDeclarationsList(block);
|
||||
while (it.next()) |declaration| {
|
||||
if (isCustomProperty(declaration.name)) {
|
||||
@@ -1267,22 +1276,70 @@ fn foldDeclarations(block: []const u8, customs: ?*CustomSink) !TrackedProperties
|
||||
gop.value_ptr.* = .{ .name = declaration.name, .value = declaration.value, .important = declaration.important };
|
||||
continue;
|
||||
}
|
||||
for (property_names, &slots) |name, *slot| {
|
||||
if (std.ascii.eqlIgnoreCase(declaration.name, name)) {
|
||||
slot.apply(declaration);
|
||||
break;
|
||||
slots.apply(declaration.name, declaration.value, declaration.important);
|
||||
}
|
||||
return slots.props();
|
||||
}
|
||||
|
||||
/// One block's winning value per tracked property, folded in declaration
|
||||
/// order.
|
||||
const Slots = struct {
|
||||
const Slot = struct {
|
||||
value: ?[]const u8 = null,
|
||||
important: bool = false,
|
||||
|
||||
fn apply(self: *Slot, value: []const u8, important: bool) void {
|
||||
if (self.important and !important) {
|
||||
return;
|
||||
}
|
||||
if (value.len == 0) {
|
||||
self.* = .{};
|
||||
return;
|
||||
}
|
||||
self.value = value;
|
||||
self.important = important;
|
||||
}
|
||||
};
|
||||
|
||||
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")) {
|
||||
// `overflow: <x> [<y>]`; a single value applies to both axes.
|
||||
var it = std.mem.tokenizeAny(u8, value, &std.ascii.whitespace);
|
||||
const x = it.next() orelse "";
|
||||
const y = it.next() orelse x;
|
||||
self.slotFor("overflow-x").apply(x, important);
|
||||
self.slotFor("overflow-y").apply(y, important);
|
||||
return;
|
||||
}
|
||||
for (property_names, &self.slots) |tracked, *slot| {
|
||||
if (std.ascii.eqlIgnoreCase(name, tracked)) {
|
||||
slot.apply(value, important);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var props: TrackedProperties = .{};
|
||||
for (property_names, slots) |name, slot| {
|
||||
if (slot.value) |value| {
|
||||
props.apply(name, value);
|
||||
fn slotFor(self: *Slots, comptime name: []const u8) *Slot {
|
||||
inline for (property_names, 0..) |tracked, i| {
|
||||
if (comptime std.mem.eql(u8, tracked, name)) {
|
||||
return &self.slots[i];
|
||||
}
|
||||
}
|
||||
comptime unreachable;
|
||||
}
|
||||
return props;
|
||||
}
|
||||
|
||||
fn props(self: Slots) TrackedProperties {
|
||||
var p: TrackedProperties = .{};
|
||||
for (property_names, self.slots) |name, s| {
|
||||
if (s.value) |value| {
|
||||
p.apply(name, value);
|
||||
}
|
||||
}
|
||||
return p;
|
||||
}
|
||||
};
|
||||
|
||||
/// Resolved value of an element's inline `style=` declaration for `property_name`,
|
||||
/// or null when the element has no such declaration. Reads the element's parsed
|
||||
@@ -1606,6 +1663,13 @@ test "StyleManager: inlineProps: scan matches the parsed style object" {
|
||||
\\<i style="visibility:hidden"></i>
|
||||
\\<i style="display:"></i>
|
||||
\\<i></i>
|
||||
\\<i style="overflow: auto"></i>
|
||||
\\<i style="overflow: hidden scroll"></i>
|
||||
\\<i style="overflow: hidden; overflow-y: auto"></i>
|
||||
\\<i style="overflow-y: auto; overflow: hidden"></i>
|
||||
\\<i style="overflow-y: auto !important; overflow: hidden"></i>
|
||||
\\<i style="overflow-x: overlay"></i>
|
||||
\\<i style="overflow:"></i>
|
||||
);
|
||||
const expected = [_]TrackedProperties{
|
||||
.{ .display = .none },
|
||||
@@ -1618,6 +1682,13 @@ test "StyleManager: inlineProps: scan matches the parsed style object" {
|
||||
.{ .visibility_hidden = true },
|
||||
.{},
|
||||
.{},
|
||||
.{ .overflow_x_scrolls = true, .overflow_y_scrolls = true },
|
||||
.{ .overflow_x_scrolls = false, .overflow_y_scrolls = true },
|
||||
.{ .overflow_x_scrolls = false, .overflow_y_scrolls = true },
|
||||
.{ .overflow_x_scrolls = false, .overflow_y_scrolls = false },
|
||||
.{ .overflow_x_scrolls = false, .overflow_y_scrolls = true },
|
||||
.{ .overflow_x_scrolls = true },
|
||||
.{},
|
||||
};
|
||||
|
||||
var i: usize = 0;
|
||||
@@ -1680,6 +1751,12 @@ test "StyleManager: memo: reuse and invalidation" {
|
||||
try testing.expectEqual(true, sm.hasVisibilityHiddenInherited(b));
|
||||
try testing.expectEqual(false, sm.hasPointerEventsNone(b));
|
||||
|
||||
try b.setStyle("overflow: hidden auto", frame);
|
||||
try testing.expectEqual(false, sm.scrolls(b, .{ .x = true }));
|
||||
try testing.expectEqual(true, sm.scrolls(b, .{ .y = true }));
|
||||
try testing.expectEqual(true, sm.scrolls(b, .{ .x = true, .y = true }));
|
||||
try testing.expectEqual(false, sm.scrolls(p, .{ .x = true, .y = true }));
|
||||
|
||||
// A stylesheet change resets the memo
|
||||
sm.sheetModified();
|
||||
try testing.expectEqual(false, sm.isHidden(p, .{}));
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
<div id="outerscroll" style="height: 100px; overflow: auto;" onscroll="window.outerScrolled = true;">
|
||||
<div style="height: 500px;"><p id="innerleaf">Inner leaf</p></div>
|
||||
</div>
|
||||
<style>.panel { height: 100px; overflow: auto; }</style>
|
||||
<div id="sheetscroll" class="panel" onscroll="window.sheetScrolled = true;">
|
||||
<div style="height: 500px;"><p id="sheetleaf">Sheet leaf</p></div>
|
||||
</div>
|
||||
<div id="hoverTarget" onmouseover="window.hovered = true;">Hover Me</div>
|
||||
<input id="keyTarget" onkeydown="window.keyPressed = event.key;" onkeyup="window.keyReleased = event.key;">
|
||||
<select id="sel2" onchange="window.sel2Changed = this.value">
|
||||
|
||||
@@ -22,7 +22,6 @@ const lp = @import("lightpanda");
|
||||
const js = @import("../js/js.zig");
|
||||
const dump = @import("../dump.zig");
|
||||
const Frame = @import("../Frame.zig");
|
||||
const StyleManager = @import("../StyleManager.zig");
|
||||
const Factory = @import("../Factory.zig");
|
||||
|
||||
const CSS = @import("CSS.zig");
|
||||
@@ -1613,33 +1612,13 @@ pub fn scrollContainer(self: *Element, axes: ScrollAxes, frame: *Frame) ?*Elemen
|
||||
while (current) |el| : (current = el.parentElement()) {
|
||||
const tag = el.getTag();
|
||||
if (tag == .html or tag == .body) return null;
|
||||
if ((axes.x and el.overflowScrolls(.x, style_manager)) or (axes.y and el.overflowScrolls(.y, style_manager))) {
|
||||
if (style_manager.scrolls(el, axes)) {
|
||||
return el;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only inline `overflow` is resolved: computed styles don't cascade stylesheet
|
||||
// rules, so a sheet-declared scroll container is treated as page content.
|
||||
fn overflowScrolls(self: *Element, axis: enum { x, y }, style_manager: *StyleManager) bool {
|
||||
const longhand = switch (axis) {
|
||||
.x => style_manager.inlineStyleValue(self, comptime .wrap("overflow-x")),
|
||||
.y => style_manager.inlineStyleValue(self, comptime .wrap("overflow-y")),
|
||||
};
|
||||
const value = longhand orelse blk: {
|
||||
// `overflow: <x> [<y>]`; a single value applies to both axes.
|
||||
const shorthand = style_manager.inlineStyleValue(self, comptime .wrap("overflow")) orelse return false;
|
||||
var it = std.mem.tokenizeAny(u8, shorthand, &std.ascii.whitespace);
|
||||
const x = it.next() orelse return false;
|
||||
break :blk switch (axis) {
|
||||
.x => x,
|
||||
.y => it.next() orelse x,
|
||||
};
|
||||
};
|
||||
return std.ascii.eqlIgnoreCase(value, "auto") or std.ascii.eqlIgnoreCase(value, "scroll");
|
||||
}
|
||||
|
||||
pub fn getScrollHeight(self: *Element, frame: *Frame) f64 {
|
||||
if (!self.isVisible(frame)) {
|
||||
return 0.0;
|
||||
|
||||
@@ -920,7 +920,7 @@ pub const Property = struct {
|
||||
_important: bool = false,
|
||||
_node: std.DoublyLinkedList.Node,
|
||||
|
||||
fn fromNodeLink(n: *std.DoublyLinkedList.Node) *Property {
|
||||
pub fn fromNodeLink(n: *std.DoublyLinkedList.Node) *Property {
|
||||
return @alignCast(@fieldParentPtr("_node", n));
|
||||
}
|
||||
|
||||
|
||||
@@ -1196,6 +1196,19 @@ test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked
|
||||
out.clearRetainingCapacity();
|
||||
}
|
||||
|
||||
// The container may be declared in a stylesheet rather than inline.
|
||||
{
|
||||
const leaf = frame.document.getElementById("sheetleaf", frame).?.asNode();
|
||||
const leaf_id = (try server.active_session.registry.register(leaf)).id;
|
||||
const outer = frame.document.getElementById("sheetscroll", frame).?.asNode();
|
||||
const outer_id = (try server.active_session.registry.register(outer)).id;
|
||||
const msg = try std.fmt.allocPrint(aa, "{{\"jsonrpc\":\"2.0\",\"id\":44,\"method\":\"tools/call\",\"params\":{{\"name\":\"scroll\",\"arguments\":{{\"backendNodeId\":{d},\"y\":30}}}}}}", .{leaf_id});
|
||||
try router.handleMessage(server, aa, msg);
|
||||
const expected = try std.fmt.allocPrint(aa, "Scrolled scroll container (backendNodeId: {d}) of element (backendNodeId: {d}) to x: 0, y: 30", .{ outer_id, leaf_id });
|
||||
try testing.expect(std.mem.indexOf(u8, out.written(), expected) != null);
|
||||
out.clearRetainingCapacity();
|
||||
}
|
||||
|
||||
// Without a node the window scrolls; an omitted axis keeps its offset.
|
||||
{
|
||||
try router.handleMessage(server, aa,
|
||||
@@ -1289,6 +1302,7 @@ test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked
|
||||
\\ window.changed === true && window.selChanged === 'opt2' &&
|
||||
\\ document.getElementById('outerscroll').scrollTop === 30 &&
|
||||
\\ document.getElementById('innerleaf').scrollTop === 0 &&
|
||||
\\ document.getElementById('sheetscroll').scrollTop === 30 &&
|
||||
\\ document.getElementById('plain').scrollTop === 7 &&
|
||||
\\ window.scrollX === 5 && window.scrollY === 20 &&
|
||||
\\ window.hovered === true &&
|
||||
|
||||
@@ -398,6 +398,42 @@ test "cdp.input: dispatchMouseEvent mouseWheel scrolls a scroll container, not t
|
||||
try testing.expect(split.isTrue());
|
||||
}
|
||||
|
||||
test "cdp.input: dispatchMouseEvent mouseWheel scrolls a stylesheet-declared scroll container" {
|
||||
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 rect_x = try (try ls.local.compileAndRun("document.getElementById('sheetleaf').getBoundingClientRect().x", null)).toF64();
|
||||
const rect_y = try (try ls.local.compileAndRun("document.getElementById('sheetleaf').getBoundingClientRect().y", null)).toF64();
|
||||
|
||||
try ctx.processMessage(.{
|
||||
.id = 1,
|
||||
.method = "Input.dispatchMouseEvent",
|
||||
.params = .{ .type = "mouseWheel", .x = rect_x, .y = rect_y, .deltaY = 40 },
|
||||
});
|
||||
|
||||
const result = try ls.local.compileAndRun("document.getElementById('sheetscroll').scrollTop === 40 && document.getElementById('sheetleaf').scrollTop === 0 && window.scrollY === 0", null);
|
||||
try testing.expect(result.isTrue());
|
||||
|
||||
var runner = bc.session.runner(.{});
|
||||
try runner.waitForScript(frame._frame_id, "window.sheetScrolled === true", 1000);
|
||||
}
|
||||
|
||||
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