From afae5bef5db1e0e8147a614b9933e31fc56c0076 Mon Sep 17 00:00:00 2001 From: Joel Einbinder Date: Tue, 14 Sep 2021 15:22:52 -0400 Subject: [PATCH] feat(mouse): page.mouse.wheel (#8690) --- docs/src/api/class-mouse.md | 19 +++++ src/client/input.ts | 6 ++ src/dispatchers/pageDispatcher.ts | 4 + src/protocol/channels.ts | 10 +++ src/protocol/protocol.yml | 7 ++ src/protocol/validator.ts | 4 + src/server/chromium/crInput.ts | 11 +++ src/server/firefox/ffInput.ts | 19 +++++ src/server/firefox/ffPage.ts | 1 + src/server/input.ts | 6 ++ src/server/webkit/wkInput.ts | 24 ++++++ src/server/webkit/wkPage.ts | 2 + tests/page/wheel.spec.ts | 127 ++++++++++++++++++++++++++++++ types/types.d.ts | 10 +++ 14 files changed, 250 insertions(+) create mode 100644 tests/page/wheel.spec.ts diff --git a/docs/src/api/class-mouse.md b/docs/src/api/class-mouse.md index ad6e168d03..31e7009b50 100644 --- a/docs/src/api/class-mouse.md +++ b/docs/src/api/class-mouse.md @@ -120,3 +120,22 @@ Dispatches a `mouseup` event. ### option: Mouse.up.button = %%-input-button-%% ### option: Mouse.up.clickCount = %%-input-click-count-%% + +## async method: Mouse.wheel + +Dispatches a `wheel` event. + +:::note +Wheel events may cause scrolling if they are not handled, and this method does not +wait for the scrolling to finish before returning. +::: + +### param: Mouse.wheel.deltaX +- `deltaX` <[float]> + +Pixels to scroll horizontally. + +### param: Mouse.wheel.deltaY +- `deltaY` <[float]> + +Pixels to scroll vertically. diff --git a/src/client/input.ts b/src/client/input.ts index 54c14c3f9d..3b0c494b93 100644 --- a/src/client/input.ts +++ b/src/client/input.ts @@ -91,6 +91,12 @@ export class Mouse implements api.Mouse { async dblclick(x: number, y: number, options: Omit = {}) { await this.click(x, y, { ...options, clickCount: 2 }); } + + async wheel(deltaX: number, deltaY: number) { + await this._page._wrapApiCall(async channel => { + await channel.mouseWheel({ deltaX, deltaY }); + }); + } } export class Touchscreen implements api.Touchscreen { diff --git a/src/dispatchers/pageDispatcher.ts b/src/dispatchers/pageDispatcher.ts index e78727c402..eaa1914ee4 100644 --- a/src/dispatchers/pageDispatcher.ts +++ b/src/dispatchers/pageDispatcher.ts @@ -192,6 +192,10 @@ export class PageDispatcher extends Dispatcher { + await this._page.mouse.wheel(params.deltaX, params.deltaY); + } + async touchscreenTap(params: channels.PageTouchscreenTapParams, metadata: CallMetadata): Promise { await this._page.touchscreen.tap(params.x, params.y); } diff --git a/src/protocol/channels.ts b/src/protocol/channels.ts index 7fc39f70c7..9643ffbc5b 100644 --- a/src/protocol/channels.ts +++ b/src/protocol/channels.ts @@ -1105,6 +1105,7 @@ export interface PageChannel extends EventTargetChannel { mouseDown(params: PageMouseDownParams, metadata?: Metadata): Promise; mouseUp(params: PageMouseUpParams, metadata?: Metadata): Promise; mouseClick(params: PageMouseClickParams, metadata?: Metadata): Promise; + mouseWheel(params: PageMouseWheelParams, metadata?: Metadata): Promise; touchscreenTap(params: PageTouchscreenTapParams, metadata?: Metadata): Promise; accessibilitySnapshot(params: PageAccessibilitySnapshotParams, metadata?: Metadata): Promise; pdf(params: PagePdfParams, metadata?: Metadata): Promise; @@ -1367,6 +1368,14 @@ export type PageMouseClickOptions = { clickCount?: number, }; export type PageMouseClickResult = void; +export type PageMouseWheelParams = { + deltaX: number, + deltaY: number, +}; +export type PageMouseWheelOptions = { + +}; +export type PageMouseWheelResult = void; export type PageTouchscreenTapParams = { x: number, y: number, @@ -3626,6 +3635,7 @@ export const commandsWithTracingSnapshots = new Set([ 'Page.mouseDown', 'Page.mouseUp', 'Page.mouseClick', + 'Page.mouseWheel', 'Page.touchscreenTap', 'Frame.evalOnSelector', 'Frame.evalOnSelectorAll', diff --git a/src/protocol/protocol.yml b/src/protocol/protocol.yml index 5b27b79ac1..f529eb5269 100644 --- a/src/protocol/protocol.yml +++ b/src/protocol/protocol.yml @@ -1024,6 +1024,13 @@ Page: tracing: snapshot: true + mouseWheel: + parameters: + deltaX: number + deltaY: number + tracing: + snapshot: true + touchscreenTap: parameters: x: number diff --git a/src/protocol/validator.ts b/src/protocol/validator.ts index af9a11b0f8..fdedf9c9b7 100644 --- a/src/protocol/validator.ts +++ b/src/protocol/validator.ts @@ -561,6 +561,10 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { button: tOptional(tEnum(['left', 'right', 'middle'])), clickCount: tOptional(tNumber), }); + scheme.PageMouseWheelParams = tObject({ + deltaX: tNumber, + deltaY: tNumber, + }); scheme.PageTouchscreenTapParams = tObject({ x: tNumber, y: tNumber, diff --git a/src/server/chromium/crInput.ts b/src/server/chromium/crInput.ts index 48b6dd37f6..60ef79e524 100644 --- a/src/server/chromium/crInput.ts +++ b/src/server/chromium/crInput.ts @@ -135,6 +135,17 @@ export class RawMouseImpl implements input.RawMouse { clickCount }); } + + async wheel(x: number, y: number, buttons: Set, modifiers: Set, deltaX: number, deltaY: number): Promise { + await this._client.send('Input.dispatchMouseEvent', { + type: 'mouseWheel', + x, + y, + modifiers: toModifiersMask(modifiers), + deltaX, + deltaY, + }); + } } export class RawTouchscreenImpl implements input.RawTouchscreen { diff --git a/src/server/firefox/ffInput.ts b/src/server/firefox/ffInput.ts index 399487ec4f..cdeb6cd7eb 100644 --- a/src/server/firefox/ffInput.ts +++ b/src/server/firefox/ffInput.ts @@ -16,6 +16,7 @@ */ import * as input from '../input'; +import { Page } from '../page'; import * as types from '../types'; import { FFSession } from './ffConnection'; @@ -101,6 +102,7 @@ export class RawKeyboardImpl implements input.RawKeyboard { export class RawMouseImpl implements input.RawMouse { private _client: FFSession; + private _page?: Page; constructor(client: FFSession) { this._client = client; @@ -140,6 +142,23 @@ export class RawMouseImpl implements input.RawMouse { clickCount }); } + + async wheel(x: number, y: number, buttons: Set, modifiers: Set, deltaX: number, deltaY: number): Promise { + // Wheel events hit the compositor first, so wait one frame for it to be synced. + await this._page!.mainFrame().evaluateExpression(`new Promise(requestAnimationFrame)`, false, false, 'utility'); + await this._client.send('Page.dispatchWheelEvent', { + deltaX, + deltaY, + x, + y, + deltaZ: 0, + modifiers: toModifiersMask(modifiers) + }); + } + + setPage(page: Page) { + this._page = page; + } } export class RawTouchscreenImpl implements input.RawTouchscreen { diff --git a/src/server/firefox/ffPage.ts b/src/server/firefox/ffPage.ts index 2d93e0f91d..fcc7a5792e 100644 --- a/src/server/firefox/ffPage.ts +++ b/src/server/firefox/ffPage.ts @@ -63,6 +63,7 @@ export class FFPage implements PageDelegate { this._contextIdToContext = new Map(); this._browserContext = browserContext; this._page = new Page(this, browserContext); + this.rawMouse.setPage(this._page); this._networkManager = new FFNetworkManager(session, this._page); this._page.on(Page.Events.FrameDetached, frame => this._removeContextsForFrame(frame)); // TODO: remove Page.willOpenNewWindowAsynchronously from the protocol. diff --git a/src/server/input.ts b/src/server/input.ts index b9b6dd2fe3..a08e7b2bf1 100644 --- a/src/server/input.ts +++ b/src/server/input.ts @@ -160,6 +160,7 @@ export interface RawMouse { move(x: number, y: number, button: types.MouseButton | 'none', buttons: Set, modifiers: Set): Promise; down(x: number, y: number, button: types.MouseButton, buttons: Set, modifiers: Set, clickCount: number): Promise; up(x: number, y: number, button: types.MouseButton, buttons: Set, modifiers: Set, clickCount: number): Promise; + wheel(x: number, y: number, buttons: Set, modifiers: Set, deltaX: number, deltaY: number): Promise; } export class Mouse { @@ -232,6 +233,11 @@ export class Mouse { async dblclick(x: number, y: number, options: { delay?: number, button?: types.MouseButton } = {}) { await this.click(x, y, { ...options, clickCount: 2 }); } + + async wheel(deltaX: number, deltaY: number) { + await this._raw.wheel(this._x, this._y, this._buttons, this._keyboard._modifiers(), deltaX, deltaY); + await this._page._doSlowMo(); + } } const aliases = new Map([ diff --git a/src/server/webkit/wkInput.ts b/src/server/webkit/wkInput.ts index 612ca54933..c2576dc7f9 100644 --- a/src/server/webkit/wkInput.ts +++ b/src/server/webkit/wkInput.ts @@ -20,6 +20,7 @@ import * as types from '../types'; import { macEditingCommands } from '../macEditingCommands'; import { WKSession } from './wkConnection'; import { isString } from '../../utils/utils'; +import type { Page } from '../page'; function toModifiersMask(modifiers: Set): number { // From Source/WebKit/Shared/WebEvent.h @@ -101,11 +102,17 @@ export class RawKeyboardImpl implements input.RawKeyboard { export class RawMouseImpl implements input.RawMouse { private readonly _pageProxySession: WKSession; + private _session?: WKSession; + private _page?: Page; constructor(session: WKSession) { this._pageProxySession = session; } + setSession(session: WKSession) { + this._session = session; + } + async move(x: number, y: number, button: types.MouseButton | 'none', buttons: Set, modifiers: Set): Promise { await this._pageProxySession.send('Input.dispatchMouseEvent', { type: 'move', @@ -140,6 +147,23 @@ export class RawMouseImpl implements input.RawMouse { clickCount }); } + + async wheel(x: number, y: number, buttons: Set, modifiers: Set, deltaX: number, deltaY: number): Promise { + await this._session!.send('Page.updateScrollingState'); + // Wheel events hit the compositor first, so wait one frame for it to be synced. + await this._page!.mainFrame().evaluateExpression(`new Promise(requestAnimationFrame)`, false, false, 'utility'); + await this._pageProxySession.send('Input.dispatchWheelEvent', { + x, + y, + deltaX, + deltaY, + modifiers: toModifiersMask(modifiers), + }); + } + + setPage(page: Page) { + this._page = page; + } } export class RawTouchscreenImpl implements input.RawTouchscreen { diff --git a/src/server/webkit/wkPage.ts b/src/server/webkit/wkPage.ts index 415452c963..5d4e1c478e 100644 --- a/src/server/webkit/wkPage.ts +++ b/src/server/webkit/wkPage.ts @@ -85,6 +85,7 @@ export class WKPage implements PageDelegate { this.rawTouchscreen = new RawTouchscreenImpl(pageProxySession); this._contextIdToContext = new Map(); this._page = new Page(this, browserContext); + this.rawMouse.setPage(this._page); this._workers = new WKWorkers(this._page); this._session = undefined as any as WKSession; this._browserContext = browserContext; @@ -139,6 +140,7 @@ export class WKPage implements PageDelegate { eventsHelper.removeEventListeners(this._sessionListeners); this._session = session; this.rawKeyboard.setSession(session); + this.rawMouse.setSession(session); this._addSessionListeners(); this._workers.setSession(session); } diff --git a/tests/page/wheel.spec.ts b/tests/page/wheel.spec.ts new file mode 100644 index 0000000000..a352eacaba --- /dev/null +++ b/tests/page/wheel.spec.ts @@ -0,0 +1,127 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { Page } from '../../'; +import { test as it, expect } from './pageTest'; +it.skip(({isElectron, browserMajorVersion}) => { + // Old Electron has flaky wheel events. + return isElectron && browserMajorVersion <= 11; +}); +it('should dispatch wheel events', async ({page, server}) => { + await page.setContent(`
`); + await page.mouse.move(50, 60); + await listenForWheelEvents(page, 'div'); + await page.mouse.wheel(0, 100); + expect(await page.evaluate('window.lastEvent')).toEqual({ + deltaX: 0, + deltaY: 100, + clientX: 50, + clientY: 60, + deltaMode: 0, + ctrlKey: false, + shiftKey: false, + altKey: false, + metaKey: false, + }); + await page.waitForFunction('window.scrollY === 100'); +}); + +it('should scroll when nobody is listening', async ({page, server}) => { + await page.goto(server.PREFIX + '/input/scrollable.html'); + await page.mouse.move(50, 60); + await page.mouse.wheel(0, 100); + await page.waitForFunction('window.scrollY === 100'); +}); + +it('should set the modifiers', async ({page}) => { + await page.setContent(`
`); + await page.mouse.move(50, 60); + await listenForWheelEvents(page, 'div'); + await page.keyboard.down('Shift'); + await page.mouse.wheel(0, 100); + expect(await page.evaluate('window.lastEvent')).toEqual({ + deltaX: 0, + deltaY: 100, + clientX: 50, + clientY: 60, + deltaMode: 0, + ctrlKey: false, + shiftKey: true, + altKey: false, + metaKey: false, + }); +}); + +it('should scroll horizontally', async ({page}) => { + await page.setContent(`
`); + await page.mouse.move(50, 60); + await listenForWheelEvents(page, 'div'); + await page.mouse.wheel(100, 0); + expect(await page.evaluate('window.lastEvent')).toEqual({ + deltaX: 100, + deltaY: 0, + clientX: 50, + clientY: 60, + deltaMode: 0, + ctrlKey: false, + shiftKey: false, + altKey: false, + metaKey: false, + }); + await page.waitForFunction('window.scrollX === 100'); +}); + +it('should work when the event is canceled', async ({page}) => { + await page.setContent(`
`); + await page.mouse.move(50, 60); + await listenForWheelEvents(page, 'div'); + await page.evaluate(() => { + document.querySelector('div').addEventListener('wheel', e => e.preventDefault()); + }); + await page.mouse.wheel(0, 100); + expect(await page.evaluate('window.lastEvent')).toEqual({ + deltaX: 0, + deltaY: 100, + clientX: 50, + clientY: 60, + deltaMode: 0, + ctrlKey: false, + shiftKey: false, + altKey: false, + metaKey: false, + }); + // give the page a chacne to scroll + await page.waitForTimeout(100); + // ensure that it did not. + expect(await page.evaluate('window.scrollY')).toBe(0); +}); + +async function listenForWheelEvents(page: Page, selector: string) { + await page.evaluate(selector => { + document.querySelector(selector).addEventListener('wheel', (e: WheelEvent) => { + window['lastEvent'] = { + deltaX: e.deltaX, + deltaY: e.deltaY, + clientX: e.clientX, + clientY: e.clientY, + deltaMode: e.deltaMode, + ctrlKey: e.ctrlKey, + shiftKey: e.shiftKey, + altKey: e.altKey, + metaKey: e.metaKey, + }; + }, { passive: false }); + }, selector); +} diff --git a/types/types.d.ts b/types/types.d.ts index e198a778c1..c0e44d2d8f 100644 --- a/types/types.d.ts +++ b/types/types.d.ts @@ -13185,6 +13185,16 @@ export interface Mouse { */ clickCount?: number; }): Promise; + + /** + * Dispatches a `wheel` event. + * + * > NOTE: Wheel events may cause scrolling if they are not handled, and this method does not wait for the scrolling to + * finish before returning. + * @param deltaX Pixels to scroll horizontally. + * @param deltaY Pixels to scroll vertically. + */ + wheel(deltaX: number, deltaY: number): Promise; } /**