fix: improve mouse actions (#10021)

This commit is contained in:
jrandolf 2023-04-17 10:56:51 +02:00 committed by GitHub
parent 285c7912fc
commit 34db39e447
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 402 additions and 111 deletions

View File

@ -97,6 +97,8 @@ sidebar_label: API
| [LaunchOptions](./puppeteer.launchoptions.md) | Generic launch options that can be passed when launching any browser. |
| [MediaFeature](./puppeteer.mediafeature.md) | |
| [Metrics](./puppeteer.metrics.md) | |
| [MouseClickOptions](./puppeteer.mouseclickoptions.md) | |
| [MouseMoveOptions](./puppeteer.mousemoveoptions.md) | |
| [MouseOptions](./puppeteer.mouseoptions.md) | |
| [MouseWheelOptions](./puppeteer.mousewheeloptions.md) | |
| [NetworkConditions](./puppeteer.networkconditions.md) | |
@ -135,6 +137,7 @@ sidebar_label: API
| [executablePath](./puppeteer.executablepath.md) | |
| [KnownDevices](./puppeteer.knowndevices.md) | A list of devices to be used with [Page.emulate()](./puppeteer.page.emulate.md). |
| [launch](./puppeteer.launch.md) | |
| [MouseButton](./puppeteer.mousebutton.md) | Enum of valid mouse buttons. |
| [networkConditions](./puppeteer.networkconditions.md) | |
| [PredefinedNetworkConditions](./puppeteer.predefinednetworkconditions.md) | A list of network conditions to be used with [Page.emulateNetworkConditions()](./puppeteer.page.emulatenetworkconditions.md). |
| [puppeteer](./puppeteer.puppeteer.md) | |

View File

@ -10,23 +10,17 @@ Shortcut for `mouse.move`, `mouse.down` and `mouse.up`.
```typescript
class Mouse {
click(
x: number,
y: number,
options?: MouseOptions & {
delay?: number;
}
): Promise<void>;
click(x: number, y: number, options?: MouseClickOptions): Promise<void>;
}
```
## Parameters
| Parameter | Type | Description |
| --------- | --------------------------------------------------------------------- | ------------------------------------------------ |
| --------- | ----------------------------------------------------- | ------------------------------------------- |
| x | number | Horizontal position of the mouse. |
| y | number | Vertical position of the mouse. |
| options | [MouseOptions](./puppeteer.mouseoptions.md) &amp; { delay?: number; } | _(Optional)_ Optional <code>MouseOptions</code>. |
| options | [MouseClickOptions](./puppeteer.mouseclickoptions.md) | _(Optional)_ Options to configure behavior. |
**Returns:**

View File

@ -4,7 +4,7 @@ sidebar_label: Mouse.down
# Mouse.down() method
Dispatches a `mousedown` event.
Presses the mouse.
#### Signature:
@ -17,8 +17,8 @@ class Mouse {
## Parameters
| Parameter | Type | Description |
| --------- | ------------------------------------------- | ------------------------------------------------ |
| options | [MouseOptions](./puppeteer.mouseoptions.md) | _(Optional)_ Optional <code>MouseOptions</code>. |
| --------- | ------------------------------------------- | ------------------------------------------- |
| options | [MouseOptions](./puppeteer.mouseoptions.md) | _(Optional)_ Options to configure behavior. |
**Returns:**

View File

@ -80,12 +80,12 @@ await browser
| Method | Modifiers | Description |
| ----------------------------------------------------------------------- | --------- | ---------------------------------------------------------------------------------------- |
| [click(x, y, options)](./puppeteer.mouse.click.md) | | Shortcut for <code>mouse.move</code>, <code>mouse.down</code> and <code>mouse.up</code>. |
| [down(options)](./puppeteer.mouse.down.md) | | Dispatches a <code>mousedown</code> event. |
| [down(options)](./puppeteer.mouse.down.md) | | Presses the mouse. |
| [drag(start, target)](./puppeteer.mouse.drag.md) | | Dispatches a <code>drag</code> event. |
| [dragAndDrop(start, target, options)](./puppeteer.mouse.draganddrop.md) | | Performs a drag, dragenter, dragover, and drop in sequence. |
| [dragEnter(target, data)](./puppeteer.mouse.dragenter.md) | | Dispatches a <code>dragenter</code> event. |
| [dragOver(target, data)](./puppeteer.mouse.dragover.md) | | Dispatches a <code>dragover</code> event. |
| [drop(target, data)](./puppeteer.mouse.drop.md) | | Performs a dragenter, dragover, and drop in sequence. |
| [move(x, y, options)](./puppeteer.mouse.move.md) | | Dispatches a <code>mousemove</code> event. |
| [up(options)](./puppeteer.mouse.up.md) | | Dispatches a <code>mouseup</code> event. |
| [move(x, y, options)](./puppeteer.mouse.move.md) | | Moves the mouse to the given coordinate. |
| [up(options)](./puppeteer.mouse.up.md) | | Releases the mouse. |
| [wheel(options)](./puppeteer.mouse.wheel.md) | | Dispatches a <code>mousewheel</code> event. |

View File

@ -4,29 +4,23 @@ sidebar_label: Mouse.move
# Mouse.move() method
Dispatches a `mousemove` event.
Moves the mouse to the given coordinate.
#### Signature:
```typescript
class Mouse {
move(
x: number,
y: number,
options?: {
steps?: number;
}
): Promise<void>;
move(x: number, y: number, options?: MouseMoveOptions): Promise<void>;
}
```
## Parameters
| Parameter | Type | Description |
| --------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| --------- | --------------------------------------------------- | ------------------------------------------- |
| x | number | Horizontal position of the mouse. |
| y | number | Vertical position of the mouse. |
| options | { steps?: number; } | _(Optional)_ Optional object. If specified, the <code>steps</code> property sends intermediate <code>mousemove</code> events when set to <code>1</code> (default). |
| options | [MouseMoveOptions](./puppeteer.mousemoveoptions.md) | _(Optional)_ Options to configure behavior. |
**Returns:**

View File

@ -4,7 +4,7 @@ sidebar_label: Mouse.up
# Mouse.up() method
Dispatches a `mouseup` event.
Releases the mouse.
#### Signature:
@ -17,8 +17,8 @@ class Mouse {
## Parameters
| Parameter | Type | Description |
| --------- | ------------------------------------------- | ------------------------------------------------ |
| options | [MouseOptions](./puppeteer.mouseoptions.md) | _(Optional)_ Optional <code>MouseOptions</code>. |
| --------- | ------------------------------------------- | ------------------------------------------- |
| options | [MouseOptions](./puppeteer.mouseoptions.md) | _(Optional)_ Options to configure behavior. |
**Returns:**

View File

@ -2,10 +2,18 @@
sidebar_label: MouseButton
---
# MouseButton type
# MouseButton variable
Enum of valid mouse buttons.
#### Signature:
```typescript
export type MouseButton = 'left' | 'right' | 'middle' | 'back' | 'forward';
MouseButton: Readonly<{
Left: 'left';
Right: 'right';
Middle: 'middle';
Back: 'back';
Forward: 'forward';
}>;
```

View File

@ -0,0 +1,19 @@
---
sidebar_label: MouseClickOptions
---
# MouseClickOptions interface
#### Signature:
```typescript
export interface MouseClickOptions extends MouseOptions
```
**Extends:** [MouseOptions](./puppeteer.mouseoptions.md)
## Properties
| Property | Modifiers | Type | Description | Default |
| -------- | --------------------- | ------ | -------------------------------------------------------------- | ------- |
| delay | <code>optional</code> | number | Time (in ms) to delay the mouse release after the mouse press. | |

View File

@ -0,0 +1,17 @@
---
sidebar_label: MouseMoveOptions
---
# MouseMoveOptions interface
#### Signature:
```typescript
export interface MouseMoveOptions
```
## Properties
| Property | Modifiers | Type | Description | Default |
| -------- | --------------------- | ------ | ------------------------------------------------------------------------------------------ | -------------- |
| steps | <code>optional</code> | number | Determines the number of movements to make from the current mouse position to the new one. | <code>1</code> |

View File

@ -13,6 +13,6 @@ export interface MouseOptions
## Properties
| Property | Modifiers | Type | Description | Default |
| ---------- | --------------------- | ----------------------------------------- | ----------- | ------- |
| button | <code>optional</code> | [MouseButton](./puppeteer.mousebutton.md) | | |
| clickCount | <code>optional</code> | number | | |
| ---------- | --------------------- | ----------------------------------------- | ----------------------------------------- | ------------------- |
| button | <code>optional</code> | [MouseButton](./puppeteer.mousebutton.md) | Determines which button will be pressed. | <code>'left'</code> |
| clickCount | <code>optional</code> | number | Determines the click count for the mouse. | <code>1</code> |

View File

@ -334,14 +334,29 @@ export class Keyboard {
/**
* @public
*/
export type MouseButton = 'left' | 'right' | 'middle' | 'back' | 'forward';
export interface MouseOptions {
/**
* Determines which button will be pressed.
*
* @defaultValue `'left'`
*/
button?: MouseButton;
/**
* Determines the click count for the mouse.
*
* @defaultValue `1`
*/
clickCount?: number;
}
/**
* @public
*/
export interface MouseOptions {
button?: MouseButton;
clickCount?: number;
export interface MouseClickOptions extends MouseOptions {
/**
* Time (in ms) to delay the mouse release after the mouse press.
*/
delay?: number;
}
/**
@ -352,6 +367,69 @@ export interface MouseWheelOptions {
deltaY?: number;
}
/**
* @public
*/
export interface MouseMoveOptions {
/**
* Determines the number of movements to make from the current mouse position
* to the new one.
*
* @defaultValue `1`
*/
steps?: number;
}
/**
* Enum of valid mouse buttons.
*
* @public
*/
export const MouseButton = Object.freeze({
Left: 'left',
Right: 'right',
Middle: 'middle',
Back: 'back',
Forward: 'forward',
});
/**
* @public
*/
export type MouseButton = (typeof MouseButton)[keyof typeof MouseButton];
/**
* This must follow {@link Protocol.Input.DispatchMouseEventRequest.buttons}.
*/
const enum MouseButtonFlag {
None = 0,
Left = 1,
Right = 1 << 1,
Middle = 1 << 2,
Back = 1 << 3,
Forward = 1 << 4,
}
const getFlag = (button: MouseButton): MouseButtonFlag => {
switch (button) {
case MouseButton.Left:
return MouseButtonFlag.Left;
case MouseButton.Right:
return MouseButtonFlag.Right;
case MouseButton.Middle:
return MouseButtonFlag.Middle;
case MouseButton.Back:
return MouseButtonFlag.Back;
case MouseButton.Forward:
return MouseButtonFlag.Forward;
}
};
interface MouseState {
position: Point;
buttons: number;
}
/**
* The Mouse class operates in main-frame CSS pixels
* relative to the top-left corner of the viewport.
@ -426,9 +504,6 @@ export interface MouseWheelOptions {
export class Mouse {
#client: CDPSession;
#keyboard: Keyboard;
#x = 0;
#y = 0;
#button: MouseButton | 'none' = 'none';
/**
* @internal
@ -438,88 +513,176 @@ export class Mouse {
this.#keyboard = keyboard;
}
#_state: Readonly<MouseState> = {
position: {x: 0, y: 0},
buttons: MouseButtonFlag.None,
};
get #state(): MouseState {
return Object.assign({...this.#_state}, ...this.#transactions);
}
// Transactions can run in parallel, so we store each of thme in this array.
#transactions: Array<Partial<MouseState>> = [];
#createTransaction(): {
update: (updates: Partial<MouseState>) => void;
commit: () => void;
rollback: () => void;
} {
const transaction: Partial<MouseState> = {};
this.#transactions.push(transaction);
const popTransaction = () => {
this.#transactions.splice(this.#transactions.indexOf(transaction), 1);
};
return {
update: (updates: Partial<MouseState>) => {
Object.assign(transaction, updates);
},
commit: () => {
this.#_state = {...this.#_state, ...transaction};
popTransaction();
},
rollback: popTransaction,
};
}
/**
* Dispatches a `mousemove` event.
* This is a shortcut for a typical update, commit/rollback lifecycle based on
* the error of the action.
*/
async #withTransaction(
action: (update: (updates: Partial<MouseState>) => void) => Promise<unknown>
): Promise<void> {
const {update, commit, rollback} = this.#createTransaction();
try {
await action(update);
commit();
} catch (error) {
rollback();
throw error;
}
}
/**
* Moves the mouse to the given coordinate.
*
* @param x - Horizontal position of the mouse.
* @param y - Vertical position of the mouse.
* @param options - Optional object. If specified, the `steps` property
* sends intermediate `mousemove` events when set to `1` (default).
* @param options - Options to configure behavior.
*/
async move(
x: number,
y: number,
options: {steps?: number} = {}
options: MouseMoveOptions = {}
): Promise<void> {
const {steps = 1} = options;
const fromX = this.#x,
fromY = this.#y;
this.#x = x;
this.#y = y;
const from = this.#state.position;
const to = {x, y};
for (let i = 1; i <= steps; i++) {
await this.#client.send('Input.dispatchMouseEvent', {
await this.#withTransaction(updateState => {
updateState({
position: {
x: from.x + (to.x - from.x) * (i / steps),
y: from.y + (to.y - from.y) * (i / steps),
},
});
const {buttons, position} = this.#state;
return this.#client.send('Input.dispatchMouseEvent', {
type: 'mouseMoved',
button: this.#button,
x: fromX + (this.#x - fromX) * (i / steps),
y: fromY + (this.#y - fromY) * (i / steps),
modifiers: this.#keyboard._modifiers,
buttons,
// This should always be 0 (i.e. 'left'). See
// https://w3c.github.io/uievents/#event-type-mousemove
button: MouseButton.Left,
...position,
});
});
}
}
/**
* Presses the mouse.
*
* @param options - Options to configure behavior.
*/
async down(options: MouseOptions = {}): Promise<void> {
const {button = MouseButton.Left, clickCount = 1} = options;
const flag = getFlag(button);
if (!flag) {
throw new Error(`Unsupported mouse button: ${button}`);
}
if (this.#state.buttons & flag) {
throw new Error(`'${button}' is already pressed.`);
}
await this.#withTransaction(updateState => {
updateState({buttons: this.#state.buttons | flag});
const {buttons, position} = this.#state;
return this.#client.send('Input.dispatchMouseEvent', {
type: 'mousePressed',
modifiers: this.#keyboard._modifiers,
clickCount,
buttons,
button,
...position,
});
});
}
/**
* Releases the mouse.
*
* @param options - Options to configure behavior.
*/
async up(options: MouseOptions = {}): Promise<void> {
const {button = MouseButton.Left, clickCount = 1} = options;
const flag = getFlag(button);
if (!flag) {
throw new Error(`Unsupported mouse button: ${button}`);
}
if (!(this.#state.buttons & flag)) {
throw new Error(`'${button}' is not pressed.`);
}
await this.#withTransaction(updateState => {
updateState({buttons: this.#state.buttons & ~flag});
const {buttons, position} = this.#state;
return this.#client.send('Input.dispatchMouseEvent', {
type: 'mouseReleased',
modifiers: this.#keyboard._modifiers,
clickCount,
buttons,
button,
...position,
});
});
}
/**
* Shortcut for `mouse.move`, `mouse.down` and `mouse.up`.
*
* @param x - Horizontal position of the mouse.
* @param y - Vertical position of the mouse.
* @param options - Optional `MouseOptions`.
* @param options - Options to configure behavior.
*/
async click(
x: number,
y: number,
options: MouseOptions & {delay?: number} = {}
options: MouseClickOptions = {}
): Promise<void> {
const {delay = null} = options;
await this.move(x, y);
await this.down(options);
if (delay !== null) {
await new Promise(f => {
return setTimeout(f, delay);
const {delay} = options;
const actions: Array<Promise<void>> = [];
const {position} = this.#state;
if (position.x !== x || position.y !== y) {
actions.push(this.move(x, y));
}
actions.push(this.down(options));
if (typeof delay === 'number') {
await Promise.all(actions);
actions.length = 0;
await new Promise(resolve => {
setTimeout(resolve, delay);
});
}
await this.up(options);
}
/**
* Dispatches a `mousedown` event.
* @param options - Optional `MouseOptions`.
*/
async down(options: MouseOptions = {}): Promise<void> {
const {button = 'left', clickCount = 1} = options;
this.#button = button;
await this.#client.send('Input.dispatchMouseEvent', {
type: 'mousePressed',
button,
x: this.#x,
y: this.#y,
modifiers: this.#keyboard._modifiers,
clickCount,
});
}
/**
* Dispatches a `mouseup` event.
* @param options - Optional `MouseOptions`.
*/
async up(options: MouseOptions = {}): Promise<void> {
const {button = 'left', clickCount = 1} = options;
this.#button = 'none';
await this.#client.send('Input.dispatchMouseEvent', {
type: 'mouseReleased',
button,
x: this.#x,
y: this.#y,
modifiers: this.#keyboard._modifiers,
clickCount,
});
actions.push(this.up(options));
await Promise.all(actions);
}
/**
@ -546,14 +709,15 @@ export class Mouse {
*/
async wheel(options: MouseWheelOptions = {}): Promise<void> {
const {deltaX = 0, deltaY = 0} = options;
const {position, buttons} = this.#state;
await this.#client.send('Input.dispatchMouseEvent', {
type: 'mouseWheel',
x: this.#x,
y: this.#y,
deltaX,
deltaY,
modifiers: this.#keyboard._modifiers,
pointerType: 'mouse',
modifiers: this.#keyboard._modifiers,
deltaY,
deltaX,
buttons,
...position,
});
}

View File

@ -259,4 +259,96 @@ describe('Mouse', function () {
expect(await page.evaluate('result')).toEqual({x: 30, y: 40});
});
it('should throw if buttons are pressed incorrectly', async () => {
const {page, server} = getTestState();
await page.goto(server.EMPTY_PAGE);
await page.mouse.down();
await expect(page.mouse.down()).rejects.toBeInstanceOf(Error);
});
it('should not throw if clicking in parallel', async () => {
const {page, server} = getTestState();
await page.goto(server.EMPTY_PAGE);
interface ClickData {
type: string;
detail: number;
clientX: number;
clientY: number;
isTrusted: boolean;
button: number;
buttons: number;
}
await page.evaluate(() => {
const clicks: ClickData[] = [];
const mouseEventListener = (event: MouseEvent) => {
clicks.push({
type: event.type,
detail: event.detail,
clientX: event.clientX,
clientY: event.clientY,
isTrusted: event.isTrusted,
button: event.button,
buttons: event.buttons,
});
};
document.addEventListener('mousedown', mouseEventListener);
document.addEventListener('mouseup', mouseEventListener);
document.addEventListener('click', mouseEventListener);
(window as unknown as {clicks: ClickData[]}).clicks = clicks;
});
await Promise.all([page.mouse.click(0, 5), page.mouse.click(6, 10)]);
const data = await page.evaluate(() => {
return (window as unknown as {clicks: ClickData[]}).clicks;
});
const commonAttrs = {
isTrusted: true,
detail: 1,
clientY: 5,
clientX: 0,
button: 0,
};
expect(data.splice(0, 3)).toMatchObject({
0: {
type: 'mousedown',
buttons: 1,
...commonAttrs,
},
1: {
type: 'mouseup',
buttons: 0,
...commonAttrs,
},
2: {
type: 'click',
buttons: 0,
...commonAttrs,
},
});
Object.assign(commonAttrs, {
clientX: 6,
clientY: 10,
});
expect(data).toMatchObject({
0: {
type: 'mousedown',
buttons: 1,
...commonAttrs,
},
1: {
type: 'mouseup',
buttons: 0,
...commonAttrs,
},
2: {
type: 'click',
buttons: 0,
...commonAttrs,
},
});
});
});