1/**
2 * Copyright 2017 Google Inc. All rights reserved.
3 *
4 * Licensed under the Apache License, Version 2.0 (the 'License');
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *     http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an 'AS IS' BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17import { assert } from './assert.js';
18import { CDPSession } from './Connection.js';
19import { keyDefinitions, KeyDefinition, KeyInput } from './USKeyboardLayout.js';
20
21type KeyDescription = Required<
22  Pick<KeyDefinition, 'keyCode' | 'key' | 'text' | 'code' | 'location'>
23>;
24
25/**
26 * Keyboard provides an api for managing a virtual keyboard.
27 * The high level api is {@link Keyboard."type"},
28 * which takes raw characters and generates proper keydown, keypress/input,
29 * and keyup events on your page.
30 *
31 * @remarks
32 * For finer control, you can use {@link Keyboard.down},
33 * {@link Keyboard.up}, and {@link Keyboard.sendCharacter}
34 * to manually fire events as if they were generated from a real keyboard.
35 *
36 * On MacOS, keyboard shortcuts like `⌘ A` -\> Select All do not work.
37 * See {@link https://github.com/puppeteer/puppeteer/issues/1313 | #1313}.
38 *
39 * @example
40 * An example of holding down `Shift` in order to select and delete some text:
41 * ```js
42 * await page.keyboard.type('Hello World!');
43 * await page.keyboard.press('ArrowLeft');
44 *
45 * await page.keyboard.down('Shift');
46 * for (let i = 0; i < ' World'.length; i++)
47 *   await page.keyboard.press('ArrowLeft');
48 * await page.keyboard.up('Shift');
49 *
50 * await page.keyboard.press('Backspace');
51 * // Result text will end up saying 'Hello!'
52 * ```
53 *
54 * @example
55 * An example of pressing `A`
56 * ```js
57 * await page.keyboard.down('Shift');
58 * await page.keyboard.press('KeyA');
59 * await page.keyboard.up('Shift');
60 * ```
61 *
62 * @public
63 */
64export class Keyboard {
65  private _client: CDPSession;
66  /** @internal */
67  _modifiers = 0;
68  private _pressedKeys = new Set<string>();
69
70  /** @internal */
71  constructor(client: CDPSession) {
72    this._client = client;
73  }
74
75  /**
76   * Dispatches a `keydown` event.
77   *
78   * @remarks
79   * If `key` is a single character and no modifier keys besides `Shift`
80   * are being held down, a `keypress`/`input` event will also generated.
81   * The `text` option can be specified to force an input event to be generated.
82   * If `key` is a modifier key, `Shift`, `Meta`, `Control`, or `Alt`,
83   * subsequent key presses will be sent with that modifier active.
84   * To release the modifier key, use {@link Keyboard.up}.
85   *
86   * After the key is pressed once, subsequent calls to
87   * {@link Keyboard.down} will have
88   * {@link https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/repeat | repeat}
89   * set to true. To release the key, use {@link Keyboard.up}.
90   *
91   * Modifier keys DO influence {@link Keyboard.down}.
92   * Holding down `Shift` will type the text in upper case.
93   *
94   * @param key - Name of key to press, such as `ArrowLeft`.
95   * See {@link KeyInput} for a list of all key names.
96   *
97   * @param options - An object of options. Accepts text which, if specified,
98   * generates an input event with this text.
99   */
100  async down(
101    key: KeyInput,
102    options: { text?: string } = { text: undefined }
103  ): Promise<void> {
104    const description = this._keyDescriptionForString(key);
105
106    const autoRepeat = this._pressedKeys.has(description.code);
107    this._pressedKeys.add(description.code);
108    this._modifiers |= this._modifierBit(description.key);
109
110    const text = options.text === undefined ? description.text : options.text;
111    await this._client.send('Input.dispatchKeyEvent', {
112      type: text ? 'keyDown' : 'rawKeyDown',
113      modifiers: this._modifiers,
114      windowsVirtualKeyCode: description.keyCode,
115      code: description.code,
116      key: description.key,
117      text: text,
118      unmodifiedText: text,
119      autoRepeat,
120      location: description.location,
121      isKeypad: description.location === 3,
122    });
123  }
124
125  private _modifierBit(key: string): number {
126    if (key === 'Alt') return 1;
127    if (key === 'Control') return 2;
128    if (key === 'Meta') return 4;
129    if (key === 'Shift') return 8;
130    return 0;
131  }
132
133  private _keyDescriptionForString(keyString: KeyInput): KeyDescription {
134    const shift = this._modifiers & 8;
135    const description = {
136      key: '',
137      keyCode: 0,
138      code: '',
139      text: '',
140      location: 0,
141    };
142
143    const definition = keyDefinitions[keyString];
144    assert(definition, `Unknown key: "${keyString}"`);
145
146    if (definition.key) description.key = definition.key;
147    if (shift && definition.shiftKey) description.key = definition.shiftKey;
148
149    if (definition.keyCode) description.keyCode = definition.keyCode;
150    if (shift && definition.shiftKeyCode)
151      description.keyCode = definition.shiftKeyCode;
152
153    if (definition.code) description.code = definition.code;
154
155    if (definition.location) description.location = definition.location;
156
157    if (description.key.length === 1) description.text = description.key;
158
159    if (definition.text) description.text = definition.text;
160    if (shift && definition.shiftText) description.text = definition.shiftText;
161
162    // if any modifiers besides shift are pressed, no text should be sent
163    if (this._modifiers & ~8) description.text = '';
164
165    return description;
166  }
167
168  /**
169   * Dispatches a `keyup` event.
170   *
171   * @param key - Name of key to release, such as `ArrowLeft`.
172   * See {@link KeyInput | KeyInput}
173   * for a list of all key names.
174   */
175  async up(key: KeyInput): Promise<void> {
176    const description = this._keyDescriptionForString(key);
177
178    this._modifiers &= ~this._modifierBit(description.key);
179    this._pressedKeys.delete(description.code);
180    await this._client.send('Input.dispatchKeyEvent', {
181      type: 'keyUp',
182      modifiers: this._modifiers,
183      key: description.key,
184      windowsVirtualKeyCode: description.keyCode,
185      code: description.code,
186      location: description.location,
187    });
188  }
189
190  /**
191   * Dispatches a `keypress` and `input` event.
192   * This does not send a `keydown` or `keyup` event.
193   *
194   * @remarks
195   * Modifier keys DO NOT effect {@link Keyboard.sendCharacter | Keyboard.sendCharacter}.
196   * Holding down `Shift` will not type the text in upper case.
197   *
198   * @example
199   * ```js
200   * page.keyboard.sendCharacter('嗨');
201   * ```
202   *
203   * @param char - Character to send into the page.
204   */
205  async sendCharacter(char: string): Promise<void> {
206    await this._client.send('Input.insertText', { text: char });
207  }
208
209  private charIsKey(char: string): char is KeyInput {
210    return !!keyDefinitions[char];
211  }
212
213  /**
214   * Sends a `keydown`, `keypress`/`input`,
215   * and `keyup` event for each character in the text.
216   *
217   * @remarks
218   * To press a special key, like `Control` or `ArrowDown`,
219   * use {@link Keyboard.press}.
220   *
221   * Modifier keys DO NOT effect `keyboard.type`.
222   * Holding down `Shift` will not type the text in upper case.
223   *
224   * @example
225   * ```js
226   * await page.keyboard.type('Hello'); // Types instantly
227   * await page.keyboard.type('World', {delay: 100}); // Types slower, like a user
228   * ```
229   *
230   * @param text - A text to type into a focused element.
231   * @param options - An object of options. Accepts delay which,
232   * if specified, is the time to wait between `keydown` and `keyup` in milliseconds.
233   * Defaults to 0.
234   */
235  async type(text: string, options: { delay?: number } = {}): Promise<void> {
236    const delay = options.delay || null;
237    for (const char of text) {
238      if (this.charIsKey(char)) {
239        await this.press(char, { delay });
240      } else {
241        if (delay) await new Promise((f) => setTimeout(f, delay));
242        await this.sendCharacter(char);
243      }
244    }
245  }
246
247  /**
248   * Shortcut for {@link Keyboard.down}
249   * and {@link Keyboard.up}.
250   *
251   * @remarks
252   * If `key` is a single character and no modifier keys besides `Shift`
253   * are being held down, a `keypress`/`input` event will also generated.
254   * The `text` option can be specified to force an input event to be generated.
255   *
256   * Modifier keys DO effect {@link Keyboard.press}.
257   * Holding down `Shift` will type the text in upper case.
258   *
259   * @param key - Name of key to press, such as `ArrowLeft`.
260   * See {@link KeyInput} for a list of all key names.
261   *
262   * @param options - An object of options. Accepts text which, if specified,
263   * generates an input event with this text. Accepts delay which,
264   * if specified, is the time to wait between `keydown` and `keyup` in milliseconds.
265   * Defaults to 0.
266   */
267  async press(
268    key: KeyInput,
269    options: { delay?: number; text?: string } = {}
270  ): Promise<void> {
271    const { delay = null } = options;
272    await this.down(key, options);
273    if (delay) await new Promise((f) => setTimeout(f, options.delay));
274    await this.up(key);
275  }
276}
277
278/**
279 * @public
280 */
281export type MouseButton = 'left' | 'right' | 'middle';
282
283/**
284 * @public
285 */
286export interface MouseOptions {
287  button?: MouseButton;
288  clickCount?: number;
289}
290
291/**
292 * @public
293 */
294export interface MouseWheelOptions {
295  deltaX?: number;
296  deltaY?: number;
297}
298
299/**
300 * The Mouse class operates in main-frame CSS pixels
301 * relative to the top-left corner of the viewport.
302 * @remarks
303 * Every `page` object has its own Mouse, accessible with [`page.mouse`](#pagemouse).
304 *
305 * @example
306 * ```js
307 * // Using ‘page.mouse’ to trace a 100x100 square.
308 * await page.mouse.move(0, 0);
309 * await page.mouse.down();
310 * await page.mouse.move(0, 100);
311 * await page.mouse.move(100, 100);
312 * await page.mouse.move(100, 0);
313 * await page.mouse.move(0, 0);
314 * await page.mouse.up();
315 * ```
316 *
317 * **Note**: The mouse events trigger synthetic `MouseEvent`s.
318 * This means that it does not fully replicate the functionality of what a normal user
319 * would be able to do with their mouse.
320 *
321 * For example, dragging and selecting text is not possible using `page.mouse`.
322 * Instead, you can use the {@link https://developer.mozilla.org/en-US/docs/Web/API/DocumentOrShadowRoot/getSelection | `DocumentOrShadowRoot.getSelection()`} functionality implemented in the platform.
323 *
324 * @example
325 * For example, if you want to select all content between nodes:
326 * ```js
327 * await page.evaluate((from, to) => {
328 *   const selection = from.getRootNode().getSelection();
329 *   const range = document.createRange();
330 *   range.setStartBefore(from);
331 *   range.setEndAfter(to);
332 *   selection.removeAllRanges();
333 *   selection.addRange(range);
334 * }, fromJSHandle, toJSHandle);
335 * ```
336 * If you then would want to copy-paste your selection, you can use the clipboard api:
337 * ```js
338 * // The clipboard api does not allow you to copy, unless the tab is focused.
339 * await page.bringToFront();
340 * await page.evaluate(() => {
341 *   // Copy the selected content to the clipboard
342 *   document.execCommand('copy');
343 *   // Obtain the content of the clipboard as a string
344 *   return navigator.clipboard.readText();
345 * });
346 * ```
347 * **Note**: If you want access to the clipboard API,
348 * you have to give it permission to do so:
349 * ```js
350 * await browser.defaultBrowserContext().overridePermissions(
351 *   '<your origin>', ['clipboard-read', 'clipboard-write']
352 * );
353 * ```
354 * @public
355 */
356export class Mouse {
357  private _client: CDPSession;
358  private _keyboard: Keyboard;
359  private _x = 0;
360  private _y = 0;
361  private _button: MouseButton | 'none' = 'none';
362
363  /**
364   * @internal
365   */
366  constructor(client: CDPSession, keyboard: Keyboard) {
367    this._client = client;
368    this._keyboard = keyboard;
369  }
370
371  /**
372   * Dispatches a `mousemove` event.
373   * @param x - Horizontal position of the mouse.
374   * @param y - Vertical position of the mouse.
375   * @param options - Optional object. If specified, the `steps` property
376   * sends intermediate `mousemove` events when set to `1` (default).
377   */
378  async move(
379    x: number,
380    y: number,
381    options: { steps?: number } = {}
382  ): Promise<void> {
383    const { steps = 1 } = options;
384    const fromX = this._x,
385      fromY = this._y;
386    this._x = x;
387    this._y = y;
388    for (let i = 1; i <= steps; i++) {
389      await this._client.send('Input.dispatchMouseEvent', {
390        type: 'mouseMoved',
391        button: this._button,
392        x: fromX + (this._x - fromX) * (i / steps),
393        y: fromY + (this._y - fromY) * (i / steps),
394        modifiers: this._keyboard._modifiers,
395      });
396    }
397  }
398
399  /**
400   * Shortcut for `mouse.move`, `mouse.down` and `mouse.up`.
401   * @param x - Horizontal position of the mouse.
402   * @param y - Vertical position of the mouse.
403   * @param options - Optional `MouseOptions`.
404   */
405  async click(
406    x: number,
407    y: number,
408    options: MouseOptions & { delay?: number } = {}
409  ): Promise<void> {
410    const { delay = null } = options;
411    if (delay !== null) {
412      await this.move(x, y);
413      await this.down(options);
414      await new Promise((f) => setTimeout(f, delay));
415      await this.up(options);
416    } else {
417      await this.move(x, y);
418      await this.down(options);
419      await this.up(options);
420    }
421  }
422
423  /**
424   * Dispatches a `mousedown` event.
425   * @param options - Optional `MouseOptions`.
426   */
427  async down(options: MouseOptions = {}): Promise<void> {
428    const { button = 'left', clickCount = 1 } = options;
429    this._button = button;
430    await this._client.send('Input.dispatchMouseEvent', {
431      type: 'mousePressed',
432      button,
433      x: this._x,
434      y: this._y,
435      modifiers: this._keyboard._modifiers,
436      clickCount,
437    });
438  }
439
440  /**
441   * Dispatches a `mouseup` event.
442   * @param options - Optional `MouseOptions`.
443   */
444  async up(options: MouseOptions = {}): Promise<void> {
445    const { button = 'left', clickCount = 1 } = options;
446    this._button = 'none';
447    await this._client.send('Input.dispatchMouseEvent', {
448      type: 'mouseReleased',
449      button,
450      x: this._x,
451      y: this._y,
452      modifiers: this._keyboard._modifiers,
453      clickCount,
454    });
455  }
456
457  /**
458   * Dispatches a `mousewheel` event.
459   * @param options - Optional: `MouseWheelOptions`.
460   *
461   * @example
462   * An example of zooming into an element:
463   * ```js
464   * await page.goto('https://mdn.mozillademos.org/en-US/docs/Web/API/Element/wheel_event$samples/Scaling_an_element_via_the_wheel?revision=1587366');
465   *
466   * const elem = await page.$('div');
467   * const boundingBox = await elem.boundingBox();
468   * await page.mouse.move(
469   *   boundingBox.x + boundingBox.width / 2,
470   *   boundingBox.y + boundingBox.height / 2
471   * );
472   *
473   * await page.mouse.wheel({ deltaY: -100 })
474   * ```
475   */
476  async wheel(options: MouseWheelOptions = {}): Promise<void> {
477    const { deltaX = 0, deltaY = 0 } = options;
478    await this._client.send('Input.dispatchMouseEvent', {
479      type: 'mouseWheel',
480      x: this._x,
481      y: this._y,
482      deltaX,
483      deltaY,
484      modifiers: this._keyboard._modifiers,
485      pointerType: 'mouse',
486    });
487  }
488}
489
490/**
491 * The Touchscreen class exposes touchscreen events.
492 * @public
493 */
494export class Touchscreen {
495  private _client: CDPSession;
496  private _keyboard: Keyboard;
497
498  /**
499   * @internal
500   */
501  constructor(client: CDPSession, keyboard: Keyboard) {
502    this._client = client;
503    this._keyboard = keyboard;
504  }
505
506  /**
507   * Dispatches a `touchstart` and `touchend` event.
508   * @param x - Horizontal position of the tap.
509   * @param y - Vertical position of the tap.
510   */
511  async tap(x: number, y: number): Promise<void> {
512    const touchPoints = [{ x: Math.round(x), y: Math.round(y) }];
513    await this._client.send('Input.dispatchTouchEvent', {
514      type: 'touchStart',
515      touchPoints,
516      modifiers: this._keyboard._modifiers,
517    });
518    await this._client.send('Input.dispatchTouchEvent', {
519      type: 'touchEnd',
520      touchPoints: [],
521      modifiers: this._keyboard._modifiers,
522    });
523  }
524}
525