diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 25a9b683..83000696 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -61,6 +61,7 @@ const popover = @import("webapi/element/popover.zig"); const NavigationKind = @import("webapi/navigation/root.zig").NavigationKind; const KeyboardEvent = @import("webapi/event/KeyboardEvent.zig"); const MouseEvent = @import("webapi/event/MouseEvent.zig"); +const WheelEvent = @import("webapi/event/WheelEvent.zig"); const HttpClient = @import("HttpClient.zig"); @@ -3962,6 +3963,55 @@ pub fn triggerMouseRelease(self: *Frame, x: f64, y: f64) !void { try self._event_manager.dispatch(target.asEventTarget(), up_event.asEvent()); } +pub fn triggerMouseWheel(self: *Frame, x: f64, y: f64, delta_x: f64, delta_y: f64) !void { + const target = (try self.window._document.elementFromPoint(x, y, self)) orelse return; + if (comptime IS_DEBUG) { + log.debug(.frame, "frame mouse wheel", .{ + .url = self.url, + .node = target, + .x = x, + .y = y, + .delta_x = delta_x, + .delta_y = delta_y, + .type = self._type, + }); + } + + const wheel_event: *WheelEvent = try .initTrusted("wheel", .{ + .bubbles = true, + .cancelable = true, + .composed = true, + .clientX = x, + .clientY = y, + .deltaX = delta_x, + .deltaY = delta_y, + }, self); + + // Keep the event alive past dispatch so we can read _prevent_default. + wheel_event.asEvent().acquireRef(); + defer _ = wheel_event.asEvent().releaseRef(self._page); + try self._event_manager.dispatch(target.asEventTarget(), wheel_event.asEvent()); + + if (wheel_event.asEvent()._prevent_default) { + return; + } + + // Apply the scroll and fire a trusted scroll event, mirroring WebDriver wheel. + // CDP deltas are untrusted, so guard NaN and saturate the addition. + const new_left: i32 = @as(i32, @intCast(target.getScrollLeft(self))) +| deltaToScroll(delta_x); + const new_top: i32 = @as(i32, @intCast(target.getScrollTop(self))) +| deltaToScroll(delta_y); + try target.setScrollLeft(new_left, self); + try target.setScrollTop(new_top, self); + + const scroll_event = try Event.initTrusted(comptime .wrap("scroll"), .{ .bubbles = true }, self._page); + try self._event_manager.dispatch(target.asEventTarget(), scroll_event); +} + +fn deltaToScroll(d: f64) i32 { + if (std.math.isNan(d)) return 0; + return @intFromFloat(std.math.clamp(d, std.math.minInt(i32), std.math.maxInt(i32))); +} + // callback when the "click" event reaches the frame. pub fn handleClick(self: *Frame, target: *Node) !void { // TODO: Also support elements when implement diff --git a/src/cdp/domains/input.zig b/src/cdp/domains/input.zig index 1bbb707a..17a91dc9 100644 --- a/src/cdp/domains/input.zig +++ b/src/cdp/domains/input.zig @@ -82,6 +82,8 @@ fn dispatchMouseEvent(cmd: *CDP.Command) !void { x: f64, y: f64, type: Type, + deltaX: f64 = 0, + deltaY: f64 = 0, // Many optional parameters are not implemented yet, see documentation url. const Type = enum { @@ -94,19 +96,13 @@ fn dispatchMouseEvent(cmd: *CDP.Command) !void { try cmd.sendResult(null, .{}); - // quickly ignore types we know we don't handle - switch (params.type) { - .mouseWheel => return, - else => {}, - } - const bc = cmd.browser_context orelse return; const frame = bc.session.currentFrame() orelse return; switch (params.type) { .mousePressed => try frame.triggerMouseClick(params.x, params.y), .mouseReleased => try frame.triggerMouseRelease(params.x, params.y), .mouseMoved => try frame.triggerMouseMove(params.x, params.y), - .mouseWheel => unreachable, + .mouseWheel => try frame.triggerMouseWheel(params.x, params.y, params.deltaX, params.deltaY), } // result already sent } @@ -204,3 +200,40 @@ test "cdp.input: dispatchMouseEvent mouseReleased fires mouseup" { const result = try ls.local.compileAndRun("window.released === true", null); try testing.expect(result.isTrue()); } + +test "cdp.input: dispatchMouseEvent mouseWheel fires wheel event" { + var ctx = try testing.context(); + defer ctx.deinit(); + + const bc = try ctx.loadBrowserContext(.{}); + const frame = try bc.session.createPage(); + const url = "http://localhost:9582/src/browser/tests/mcp_actions.html"; + try frame.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } }); + var runner = try bc.session.runner(.{}); + try runner.wait(.{ .ms = 2000 }); + + 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(); + + _ = try ls.local.compileAndRun( + \\document.getElementById('scrollbox') + \\ .addEventListener('wheel', (e) => { window.wheelDeltaY = e.deltaY; }); + , null); + + const rect_x = try (try ls.local.compileAndRun("document.getElementById('scrollbox').getBoundingClientRect().x", null)).toF64(); + const rect_y = try (try ls.local.compileAndRun("document.getElementById('scrollbox').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("window.wheelDeltaY === 40", null); + try testing.expect(result.isTrue()); +}