mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-07-31 17:55:59 -04:00
webapi: wheel/touch testdriver actions; passive-listener cancelability
Fixes all 29 failing files under /dom/events/non-cancelable-when-passive/
(13/42 -> 42/42 files passing):
- WebDriver wheel actions only worked with an element origin;
testdriver's Actions().scroll() uses the viewport origin, so nothing
was dispatched. Viewport origins now resolve their target through the
faux layout (a new vertical-axis-only elementFromPoint variant, since
the faux layout gives elements no useful horizontal extent), falling
back to the document element. The faux dimensions also learn
vh/vw units (resolved against the page viewport), which the tests'
200vh scroll containers rely on.
- Wheel dispatch also fires the legacy mousewheel event, like Blink.
- Touch pointer sources (Actions().addPointer("touch")) now dispatch
touchstart/touchmove/touchend (with pointer events, without mouse
events) instead of being treated as a mouse.
- Per the cancelability rules for scroll-blocking events, wheel,
mousewheel and touch events are dispatched cancelable only when some
listener on the propagation path (target chain plus window) is
non-passive: the UA knows preventDefault can't be called otherwise.
Coverage: /dom/events/non-cancelable-when-passive/: all 42 files pass
(29 newly passing, 1 subtest each); no regressions across /dom/events/.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -38,6 +38,20 @@ pub fn parseDimension(value: []const u8) ?f64 {
|
||||
return std.fmt.parseFloat(f64, num_str) catch null;
|
||||
}
|
||||
|
||||
// parseDimension plus viewport-relative units, which the faux layout
|
||||
// resolves against the page viewport.
|
||||
pub fn parseDimensionViewport(value: []const u8, frame: *Frame) ?f64 {
|
||||
if (std.mem.endsWith(u8, value, "vh")) {
|
||||
const n = std.fmt.parseFloat(f64, value[0 .. value.len - 2]) catch return null;
|
||||
return n * @as(f64, @floatFromInt(frame._page.getViewport().height)) / 100.0;
|
||||
}
|
||||
if (std.mem.endsWith(u8, value, "vw")) {
|
||||
const n = std.fmt.parseFloat(f64, value[0 .. value.len - 2]) catch return null;
|
||||
return n * @as(f64, @floatFromInt(frame._page.getViewport().width)) / 100.0;
|
||||
}
|
||||
return parseDimension(value);
|
||||
}
|
||||
|
||||
/// Escapes a CSS identifier string
|
||||
/// https://drafts.csswg.org/cssom/#the-css.escape()-method
|
||||
pub fn escape(value: []const u8, frame: *Frame) ![]const u8 {
|
||||
|
||||
@@ -831,6 +831,17 @@ pub fn moveBefore(self: *Document, node: js.Value, child: js.Value, frame: *Fram
|
||||
}
|
||||
|
||||
pub fn elementFromPoint(self: *Document, x: f64, y: f64, frame: *Frame) !?*Element {
|
||||
return self.elementFromPointImpl(x, y, false, frame);
|
||||
}
|
||||
|
||||
// The faux layout gives most elements no useful horizontal extent, so
|
||||
// viewport-relative hit-testing (WebDriver scroll actions) matches on the
|
||||
// vertical axis only.
|
||||
pub fn elementFromVerticalPoint(self: *Document, y: f64, frame: *Frame) !?*Element {
|
||||
return self.elementFromPointImpl(0, y, true, frame);
|
||||
}
|
||||
|
||||
fn elementFromPointImpl(self: *Document, x: f64, y: f64, ignore_x: bool, frame: *Frame) !?*Element {
|
||||
// DFS in document order; topmost = last visited element whose rect contains (x, y).
|
||||
//
|
||||
// Faux-layout shortcut: rect.top is calculateDocumentPosition × 5, which is
|
||||
@@ -868,7 +879,8 @@ pub fn elementFromPoint(self: *Document, x: f64, y: f64, frame: *Frame) !?*Eleme
|
||||
const top = pos;
|
||||
const right = pos + dims.width;
|
||||
const bottom = pos + dims.height;
|
||||
if (x >= left and x <= right and y >= top and y <= bottom) {
|
||||
const x_contained = ignore_x or (x >= left and x <= right);
|
||||
if (x_contained and y >= top and y <= bottom) {
|
||||
topmost = element;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1232,8 +1232,8 @@ pub fn getElementDimensions(self: *Element, frame: *Frame) struct { width: f64,
|
||||
|
||||
if (self.getStyle(frame)) |style| {
|
||||
const decl = style.asCSSStyleDeclaration();
|
||||
width = CSS.parseDimension(decl.getPropertyValue("width", frame)) orelse 5.0;
|
||||
height = CSS.parseDimension(decl.getPropertyValue("height", frame)) orelse 5.0;
|
||||
width = CSS.parseDimensionViewport(decl.getPropertyValue("width", frame), frame) orelse 5.0;
|
||||
height = CSS.parseDimensionViewport(decl.getPropertyValue("height", frame), frame) orelse 5.0;
|
||||
}
|
||||
|
||||
if (width == 5.0 or height == 5.0) {
|
||||
|
||||
@@ -30,6 +30,8 @@ const MouseEvent = @import("event/MouseEvent.zig");
|
||||
const PointerEvent = @import("event/PointerEvent.zig");
|
||||
const KeyboardEvent = @import("event/KeyboardEvent.zig");
|
||||
const WheelEvent = @import("event/WheelEvent.zig");
|
||||
const TouchEvent = @import("event/TouchEvent.zig");
|
||||
const EventManagerBase = @import("../EventManagerBase.zig");
|
||||
|
||||
const log = lp.log;
|
||||
const Allocator = std.mem.Allocator;
|
||||
@@ -160,9 +162,19 @@ fn performPointerSource(source: js.Object, frame: *Frame) !void {
|
||||
}
|
||||
const actions = actions_val.toArray();
|
||||
|
||||
// A touch pointer dispatches touch events instead of mouse events.
|
||||
var is_touch = false;
|
||||
const params = try source.get("parameters");
|
||||
if (params.isObject()) {
|
||||
if ((try params.toObject().get("pointerType")).toSSO(false)) |pointer_type| {
|
||||
is_touch = pointer_type.eql(comptime .wrap("touch"));
|
||||
} else |_| {}
|
||||
}
|
||||
|
||||
// The element the pointer is currently over, set by the last pointerMove
|
||||
// whose origin resolved to an element.
|
||||
var target: ?*Element = null;
|
||||
var pressed = false;
|
||||
|
||||
for (0..actions.len()) |i| {
|
||||
const action_val = try actions.get(@intCast(i));
|
||||
@@ -179,21 +191,37 @@ fn performPointerSource(source: js.Object, frame: *Frame) !void {
|
||||
}
|
||||
const el = target orelse continue;
|
||||
dispatchPointer(el, "pointermove", 0, 0, frame);
|
||||
dispatchMouse(el, "mousemove", 0, 0, frame);
|
||||
if (is_touch) {
|
||||
if (pressed) {
|
||||
dispatchTouch(el, "touchmove", frame);
|
||||
}
|
||||
} else {
|
||||
dispatchMouse(el, "mousemove", 0, 0, frame);
|
||||
}
|
||||
} else if (action_type.eql(comptime .wrap("pointerDown"))) {
|
||||
const el = target orelse continue;
|
||||
const button = readI32(action, "button", 0);
|
||||
pressed = true;
|
||||
dispatchPointer(el, "pointerdown", button, 1, frame);
|
||||
dispatchMouse(el, "mousedown", button, 1, frame);
|
||||
Frame.user_input.focusEditingHostForMouseDown(frame, el) catch |err| {
|
||||
log.warn(.app, "webdriver editable focus", .{ .err = err });
|
||||
};
|
||||
if (is_touch) {
|
||||
dispatchTouch(el, "touchstart", frame);
|
||||
} else {
|
||||
dispatchMouse(el, "mousedown", button, 1, frame);
|
||||
Frame.user_input.focusEditingHostForMouseDown(frame, el) catch |err| {
|
||||
log.warn(.app, "webdriver editable focus", .{ .err = err });
|
||||
};
|
||||
}
|
||||
} else if (action_type.eql(comptime .wrap("pointerUp"))) {
|
||||
const el = target orelse continue;
|
||||
const button = readI32(action, "button", 0);
|
||||
pressed = false;
|
||||
dispatchPointer(el, "pointerup", button, 0, frame);
|
||||
dispatchMouse(el, "mouseup", button, 0, frame);
|
||||
dispatchMouse(el, "click", button, 0, frame);
|
||||
if (is_touch) {
|
||||
dispatchTouch(el, "touchend", frame);
|
||||
} else {
|
||||
dispatchMouse(el, "mouseup", button, 0, frame);
|
||||
dispatchMouse(el, "click", button, 0, frame);
|
||||
}
|
||||
}
|
||||
// "pause" carries timing only and is ignored. ("pointerCancel" is not
|
||||
// emitted by the testdriver Actions builder.)
|
||||
@@ -220,14 +248,23 @@ fn performWheelSource(source: js.Object, frame: *Frame) !void {
|
||||
}
|
||||
|
||||
const origin = try action.get("origin");
|
||||
if (!origin.isObject()) {
|
||||
continue;
|
||||
var el: ?*Element = null;
|
||||
if (origin.isObject()) {
|
||||
el = origin.local.jsValueToZig(*Element, origin) catch null;
|
||||
} else {
|
||||
// "viewport"/"pointer" origins: approximate hit-testing with
|
||||
// the faux layout's vertical axis, falling back to the root.
|
||||
const y = readI32(action, "y", 0);
|
||||
el = frame.document.elementFromVerticalPoint(@floatFromInt(y), frame) catch null;
|
||||
if (el == null) {
|
||||
el = frame.document.getDocumentElement();
|
||||
}
|
||||
}
|
||||
const el = origin.local.jsValueToZig(*Element, origin) catch continue;
|
||||
const target = el orelse continue;
|
||||
|
||||
const delta_x = readI32(action, "deltaX", 0);
|
||||
const delta_y = readI32(action, "deltaY", 0);
|
||||
dispatchWheel(el, delta_x, delta_y, frame);
|
||||
dispatchWheel(target, delta_x, delta_y, frame);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,9 +348,12 @@ fn dispatchMouse(el: *Element, comptime typ: []const u8, button: i32, buttons: u
|
||||
}
|
||||
|
||||
fn dispatchWheel(el: *Element, delta_x: i32, delta_y: i32, frame: *Frame) void {
|
||||
// The UA dispatches scroll-blocking events as non-cancelable when every
|
||||
// listener on the propagation path is passive: it already knows
|
||||
// preventDefault can't be called.
|
||||
const event = WheelEvent.initTrusted("wheel", .{
|
||||
.bubbles = true,
|
||||
.cancelable = true,
|
||||
.cancelable = hasNonPassiveListener(el, "wheel", frame),
|
||||
.composed = true,
|
||||
.deltaX = @floatFromInt(delta_x),
|
||||
.deltaY = @floatFromInt(delta_y),
|
||||
@@ -327,7 +367,22 @@ fn dispatchWheel(el: *Element, delta_x: i32, delta_y: i32, frame: *Frame) void {
|
||||
defer _ = event.asEvent().releaseRef(frame._page);
|
||||
dispatch(el.asEventTarget(), event.asEvent(), frame, "wheel");
|
||||
|
||||
if (event.asEvent()._prevent_default) {
|
||||
// Blink also fires the legacy mousewheel event.
|
||||
const legacy = WheelEvent.initTrusted("mousewheel", .{
|
||||
.bubbles = true,
|
||||
.cancelable = hasNonPassiveListener(el, "mousewheel", frame),
|
||||
.composed = true,
|
||||
.deltaX = @floatFromInt(delta_x),
|
||||
.deltaY = @floatFromInt(delta_y),
|
||||
}, frame) catch |err| {
|
||||
log.warn(.app, "webdriver mousewheel event", .{ .err = err });
|
||||
return;
|
||||
};
|
||||
legacy.asEvent().acquireRef();
|
||||
defer _ = legacy.asEvent().releaseRef(frame._page);
|
||||
dispatch(el.asEventTarget(), legacy.asEvent(), frame, "mousewheel");
|
||||
|
||||
if (event.asEvent()._prevent_default or legacy.asEvent()._prevent_default) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -350,6 +405,41 @@ fn dispatch(target: *EventTarget, event: *Event, frame: *Frame, typ: []const u8)
|
||||
};
|
||||
}
|
||||
|
||||
fn hasNonPassiveListener(el: *Element, typ: []const u8, frame: *Frame) bool {
|
||||
const base = &frame._event_manager.base;
|
||||
var current: ?*@import("Node.zig") = el.asNode();
|
||||
while (current) |node| : (current = node.parentNode()) {
|
||||
if (anyNonPassive(base.getListeners(node.asEventTarget(), .wrap(typ)))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return anyNonPassive(base.getListeners(frame.window.asEventTarget(), .wrap(typ)));
|
||||
}
|
||||
|
||||
fn anyNonPassive(list_: ?*std.DoublyLinkedList) bool {
|
||||
const list = list_ orelse return false;
|
||||
var link = list.first;
|
||||
while (link) |l| : (link = l.next) {
|
||||
const listener: *align(8) EventManagerBase.Listener = @fieldParentPtr("node", l);
|
||||
if (!listener.passive) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
fn dispatchTouch(el: *Element, comptime typ: []const u8, frame: *Frame) void {
|
||||
const event = TouchEvent.initTrusted(typ, .{
|
||||
.bubbles = true,
|
||||
.cancelable = hasNonPassiveListener(el, typ, frame),
|
||||
.composed = true,
|
||||
}, frame) catch |err| {
|
||||
log.warn(.app, "webdriver touch event", .{ .err = err });
|
||||
return;
|
||||
};
|
||||
dispatch(el.asEventTarget(), event.asEvent(), frame, typ);
|
||||
}
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = js.Bridge(WebDriver);
|
||||
|
||||
|
||||
@@ -51,6 +51,14 @@ pub const Options = Event.inheritOptions(
|
||||
);
|
||||
|
||||
pub fn init(typ: []const u8, _opts: ?Options, frame: *Frame) !*TouchEvent {
|
||||
return initWithTrusted(typ, _opts, false, frame);
|
||||
}
|
||||
|
||||
pub fn initTrusted(typ: []const u8, _opts: ?Options, frame: *Frame) !*TouchEvent {
|
||||
return initWithTrusted(typ, _opts, true, frame);
|
||||
}
|
||||
|
||||
fn initWithTrusted(typ: []const u8, _opts: ?Options, trusted: bool, frame: *Frame) !*TouchEvent {
|
||||
const arena = try frame.getArena(.tiny, "TouchEvent");
|
||||
errdefer frame.releaseArena(arena);
|
||||
const type_string = try String.init(arena, typ, .{});
|
||||
@@ -68,7 +76,7 @@ pub fn init(typ: []const u8, _opts: ?Options, frame: *Frame) !*TouchEvent {
|
||||
},
|
||||
);
|
||||
|
||||
Event.populatePrototypes(event, opts, false);
|
||||
Event.populatePrototypes(event, opts, trusted);
|
||||
return event;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user