diff --git a/src/browser/frame/user_input.zig b/src/browser/frame/user_input.zig index 3248974eb..a1734bc82 100644 --- a/src/browser/frame/user_input.zig +++ b/src/browser/frame/user_input.zig @@ -27,6 +27,7 @@ const lp = @import("lightpanda"); const builtin = @import("builtin"); const Frame = @import("../Frame.zig"); +const js = @import("../js/js.zig"); const Node = @import("../webapi/Node.zig"); const Event = @import("../webapi/Event.zig"); @@ -271,6 +272,58 @@ pub fn findClickActivationTarget(target: *Node, bubbles: bool) ?*Node { return null; } +fn runJavascriptUrl(frame: *Frame, source: []const u8) !void { + const arena = try frame.getArena(.tiny, "javascript-url"); + errdefer frame.releaseArena(arena); + + const task = try arena.create(JavascriptUrlTask); + task.* = .{ + .frame = frame, + .arena = arena, + // TODO: the URL body should be percent-decoded; hrefs written in + // markup rarely are. + .source = try arena.dupe(u8, source), + }; + try frame.js.scheduler.add(task, JavascriptUrlTask.run, 0, .{ + .name = "javascript-url", + .finalizer = JavascriptUrlTask.finalize, + }); +} + +const JavascriptUrlTask = struct { + frame: *Frame, + arena: std.mem.Allocator, + source: []const u8, + + fn run(ptr: *anyopaque) !?u32 { + const self: *JavascriptUrlTask = @ptrCast(@alignCast(ptr)); + const frame = self.frame; + defer self.deinit(); + + var ls: js.Local.Scope = undefined; + frame.js.localScope(&ls); + defer ls.deinit(); + + const script = ls.local.compile(self.source, "javascript:") catch |err| { + log.warn(.browser, "javascript-url compile", .{ .err = err, .type = frame._type, .url = frame.url }); + return null; + }; + _ = script.run() catch |err| { + log.warn(.browser, "javascript-url run", .{ .err = err, .type = frame._type, .url = frame.url }); + }; + return null; + } + + fn finalize(ptr: *anyopaque) void { + const self: *JavascriptUrlTask = @ptrCast(@alignCast(ptr)); + self.deinit(); + } + + fn deinit(self: *JavascriptUrlTask) void { + self.frame.releaseArena(self.arena); + } +}; + pub fn handleClick(frame: *Frame, target: *Node) !void { // TODO: Also support elements when implement const element = target.is(Element) orelse return; @@ -284,7 +337,10 @@ pub fn handleClick(frame: *Frame, target: *Node) !void { } if (std.mem.startsWith(u8, href, "javascript:")) { - return; + // Navigating to a javascript: URL evaluates the script in the + // node's frame as a queued task. (A string completion value + // would replace the document; we ignore results.) + return runJavascriptUrl(target.ownerFrame(frame), href["javascript:".len..]); } if (try element.hasAttribute(comptime .wrap("download"), frame)) { diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 7bbd62a48..1e8f0d464 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -1094,6 +1094,7 @@ pub const PageJsApis = flattenTypes(&.{ @import("../webapi/event/BeforeUnloadEvent.zig"), @import("../webapi/event/StorageEvent.zig"), @import("../webapi/event/DeviceMotionEvent.zig"), + @import("../webapi/event/GamepadEvent.zig"), @import("../webapi/event/DeviceOrientationEvent.zig"), @import("../webapi/event/TouchEvent.zig"), @import("../webapi/event/UIEvent.zig"), diff --git a/src/browser/webapi/CSS.zig b/src/browser/webapi/CSS.zig index 00bd1a952..0df73dcad 100644 --- a/src/browser/webapi/CSS.zig +++ b/src/browser/webapi/CSS.zig @@ -29,7 +29,30 @@ pub fn parseDimension(value: []const u8) ?f64 { if (value.len == 0) { return null; } + return parseNonEmptyDimension(value); +} +// parseDimension plus viewport-relative units, which the faux layout +// resolves against the page viewport. +pub fn parseDimensionViewport(value: []const u8, frame: *Frame) ?f64 { + if (value.len == 0) { + return null; + } + + 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 parseNonEmptyDimension(value); +} + +fn parseNonEmptyDimension(value: []const u8) ?f64 { var num_str = value; if (std.mem.endsWith(u8, value, "px")) { num_str = value[0 .. value.len - 2]; diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index b2a621c01..c09e01377 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -817,6 +817,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 @@ -854,7 +865,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; } } diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index b1193459f..26ecce962 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -1252,8 +1252,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) { diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig index c344a761f..0c0bad24e 100644 --- a/src/browser/webapi/Event.zig +++ b/src/browser/webapi/Event.zig @@ -87,6 +87,7 @@ pub const Type = union(enum) { before_unload_event: *@import("event/BeforeUnloadEvent.zig"), storage_event: *@import("event/StorageEvent.zig"), device_motion_event: *@import("event/DeviceMotionEvent.zig"), + gamepad_event: *@import("event/GamepadEvent.zig"), device_orientation_event: *@import("event/DeviceOrientationEvent.zig"), ui_event: *@import("event/UIEvent.zig"), promise_rejection_event: *@import("event/PromiseRejectionEvent.zig"), @@ -200,6 +201,7 @@ pub fn is(self: *Event, comptime T: type) ?*T { .before_unload_event => |e| return if (T == @import("event/BeforeUnloadEvent.zig")) e else null, .storage_event => |e| return if (T == @import("event/StorageEvent.zig")) e else null, .device_motion_event => |e| return if (T == @import("event/DeviceMotionEvent.zig")) e else null, + .gamepad_event => |e| return if (T == @import("event/GamepadEvent.zig")) e else null, .device_orientation_event => |e| return if (T == @import("event/DeviceOrientationEvent.zig")) e else null, .promise_rejection_event => |e| return if (T == @import("event/PromiseRejectionEvent.zig")) e else null, .submit_event => |e| return if (T == @import("event/SubmitEvent.zig")) e else null, diff --git a/src/browser/webapi/WebDriver.zig b/src/browser/webapi/WebDriver.zig index 140df74ea..4ebe5e52e 100644 --- a/src/browser/webapi/WebDriver.zig +++ b/src/browser/webapi/WebDriver.zig @@ -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; @@ -81,7 +83,7 @@ pub fn click(_: *const WebDriver, element: *Element, frame: *Frame) !void { // { type: "pointer", actions: [{type: "pointerMove", x, y, origin}, ...] } // { type: "key", actions: [{type: "keyDown", value}, ...] } // { type: "wheel", actions: [{type: "scroll", deltaX, deltaY, origin}, ...] } -pub fn actionSequence(_: *const WebDriver, sources: js.Value, frame: *Frame) !void { +pub fn actionSequence(_: *const WebDriver, sources: js.Value, frame: *Frame) !js.Promise { if (sources.isArray() == false) { return error.InvalidArgument; } @@ -92,25 +94,33 @@ pub fn actionSequence(_: *const WebDriver, sources: js.Value, frame: *Frame) !vo const persisted = try sources.persist(); errdefer persisted.release(); + // Resolved once the actions have been performed, so testdriver's + // Actions().send() promise doesn't settle before the events fired. + const resolver = frame.js.local.?.createPromiseResolver(); + const action_sequence = try arena.create(ActionSequence); action_sequence.* = .{ .frame = frame, .arena = arena, .sources = persisted, + .resolver = try resolver.persist(), }; - errdefer action_sequence.sources.release(); + errdefer action_sequence.resolver.release(); // cannot be run synchronously, has to be run on the next tick try frame.js.scheduler.add(action_sequence, ActionSequence.run, 0, .{ .name = "WebDriver.actionSequence", .finalizer = ActionSequence.finalize, }); + + return resolver.promise(); } const ActionSequence = struct { frame: *Frame, arena: Allocator, sources: js.Value.Global, + resolver: js.PromiseResolver.Global, fn run(ptr: *anyopaque) !?u32 { const self: *ActionSequence = @ptrCast(@alignCast(ptr)); @@ -121,6 +131,10 @@ const ActionSequence = struct { frame.js.localScope(&ls); defer ls.deinit(); + errdefer |err| { + ls.toLocal(self.resolver).reject("WebDriver.actionSequence", ls.local.newString(@errorName(err))); + } + const sources = self.sources.local(&ls.local).toArray(); for (0..sources.len()) |i| { const source_val = try sources.get(@intCast(i)); @@ -138,6 +152,8 @@ const ActionSequence = struct { } // "none" sources only carry pauses, which have no observable effect here. } + + ls.toLocal(self.resolver).resolve("WebDriver.actionSequence", {}); return null; } @@ -148,6 +164,7 @@ const ActionSequence = struct { fn deinit(self: *ActionSequence) void { self.sources.release(); + self.resolver.release(); self.frame.releaseArena(self.arena); } }; @@ -159,9 +176,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)); @@ -178,21 +205,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.) @@ -219,14 +262,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); } } @@ -310,9 +362,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), @@ -326,7 +381,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; } @@ -349,6 +419,45 @@ fn dispatch(target: *EventTarget, event: *Event, frame: *Frame, typ: []const u8) }; } +fn hasNonPassiveListener(el: *Element, typ: []const u8, frame: *Frame) bool { + // Listeners live in the event manager of the element's own frame (and the + // propagation path ends at that frame's window), which is not the caller's + // frame when the element belongs to e.g. an iframe's document. + const owner = el.ownerFrame(frame); + const base = &owner._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(owner.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); diff --git a/src/browser/webapi/event/GamepadEvent.zig b/src/browser/webapi/event/GamepadEvent.zig new file mode 100644 index 000000000..62fe4f75c --- /dev/null +++ b/src/browser/webapi/event/GamepadEvent.zig @@ -0,0 +1,77 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const lp = @import("lightpanda"); + +const js = @import("../../js/js.zig"); +const Frame = @import("../../Frame.zig"); + +const Event = @import("../Event.zig"); + +const String = lp.String; +const Allocator = std.mem.Allocator; + +// https://w3c.github.io/gamepad/#gamepadevent-interface +const GamepadEvent = @This(); + +_proto: *Event, + +const GamepadEventOptions = struct {}; + +const Options = Event.inheritOptions(GamepadEvent, GamepadEventOptions); + +pub fn init(typ: []const u8, _opts: ?Options, frame: *Frame) !*GamepadEvent { + const arena = try frame.getArena(.tiny, "GamepadEvent"); + errdefer frame.releaseArena(arena); + const type_string = try String.init(arena, typ, .{}); + + const opts = _opts orelse Options{}; + const event = try frame._factory.event( + arena, + type_string, + GamepadEvent{ + ._proto = undefined, + }, + ); + + Event.populatePrototypes(event, opts, false); + return event; +} + +pub fn asEvent(self: *GamepadEvent) *Event { + return self._proto; +} + +// There are no gamepads in a headless browser. +pub fn getGamepad(_: *const GamepadEvent) ?bool { + return null; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(GamepadEvent); + + pub const Meta = struct { + pub const name = "GamepadEvent"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const constructor = bridge.constructor(GamepadEvent.init, .{}); + pub const gamepad = bridge.accessor(GamepadEvent.getGamepad, null, .{}); +}; diff --git a/src/browser/webapi/event/TouchEvent.zig b/src/browser/webapi/event/TouchEvent.zig index 4956ed0ad..f15da08b7 100644 --- a/src/browser/webapi/event/TouchEvent.zig +++ b/src/browser/webapi/event/TouchEvent.zig @@ -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; }