/** * @license * Copyright 2017 Google Inc. * SPDX-License-Identifier: Apache-2.0 */ import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; import type {Point} from '../api/ElementHandle.js'; import { Keyboard, Mouse, MouseButton, Touchscreen, type KeyboardTypeOptions, type KeyDownOptions, type KeyPressOptions, type MouseClickOptions, type MouseMoveOptions, type MouseOptions, type MouseWheelOptions, } from '../api/Input.js'; import {UnsupportedOperation} from '../common/Errors.js'; import type {KeyInput} from '../common/USKeyboardLayout.js'; import type {BidiPage} from './Page.js'; const enum InputId { Mouse = '__puppeteer_mouse', Keyboard = '__puppeteer_keyboard', Wheel = '__puppeteer_wheel', Finger = '__puppeteer_finger', } enum SourceActionsType { None = 'none', Key = 'key', Pointer = 'pointer', Wheel = 'wheel', } enum ActionType { Pause = 'pause', KeyDown = 'keyDown', KeyUp = 'keyUp', PointerUp = 'pointerUp', PointerDown = 'pointerDown', PointerMove = 'pointerMove', Scroll = 'scroll', } const getBidiKeyValue = (key: KeyInput) => { switch (key) { case '\r': case '\n': key = 'Enter'; break; } // Measures the number of code points rather than UTF-16 code units. if ([...key].length === 1) { return key; } switch (key) { case 'Cancel': return '\uE001'; case 'Help': return '\uE002'; case 'Backspace': return '\uE003'; case 'Tab': return '\uE004'; case 'Clear': return '\uE005'; case 'Enter': return '\uE007'; case 'Shift': case 'ShiftLeft': return '\uE008'; case 'Control': case 'ControlLeft': return '\uE009'; case 'Alt': case 'AltLeft': return '\uE00A'; case 'Pause': return '\uE00B'; case 'Escape': return '\uE00C'; case 'PageUp': return '\uE00E'; case 'PageDown': return '\uE00F'; case 'End': return '\uE010'; case 'Home': return '\uE011'; case 'ArrowLeft': return '\uE012'; case 'ArrowUp': return '\uE013'; case 'ArrowRight': return '\uE014'; case 'ArrowDown': return '\uE015'; case 'Insert': return '\uE016'; case 'Delete': return '\uE017'; case 'NumpadEqual': return '\uE019'; case 'Numpad0': return '\uE01A'; case 'Numpad1': return '\uE01B'; case 'Numpad2': return '\uE01C'; case 'Numpad3': return '\uE01D'; case 'Numpad4': return '\uE01E'; case 'Numpad5': return '\uE01F'; case 'Numpad6': return '\uE020'; case 'Numpad7': return '\uE021'; case 'Numpad8': return '\uE022'; case 'Numpad9': return '\uE023'; case 'NumpadMultiply': return '\uE024'; case 'NumpadAdd': return '\uE025'; case 'NumpadSubtract': return '\uE027'; case 'NumpadDecimal': return '\uE028'; case 'NumpadDivide': return '\uE029'; case 'F1': return '\uE031'; case 'F2': return '\uE032'; case 'F3': return '\uE033'; case 'F4': return '\uE034'; case 'F5': return '\uE035'; case 'F6': return '\uE036'; case 'F7': return '\uE037'; case 'F8': return '\uE038'; case 'F9': return '\uE039'; case 'F10': return '\uE03A'; case 'F11': return '\uE03B'; case 'F12': return '\uE03C'; case 'Meta': case 'MetaLeft': return '\uE03D'; case 'ShiftRight': return '\uE050'; case 'ControlRight': return '\uE051'; case 'AltRight': return '\uE052'; case 'MetaRight': return '\uE053'; case 'Digit0': return '0'; case 'Digit1': return '1'; case 'Digit2': return '2'; case 'Digit3': return '3'; case 'Digit4': return '4'; case 'Digit5': return '5'; case 'Digit6': return '6'; case 'Digit7': return '7'; case 'Digit8': return '8'; case 'Digit9': return '9'; case 'KeyA': return 'a'; case 'KeyB': return 'b'; case 'KeyC': return 'c'; case 'KeyD': return 'd'; case 'KeyE': return 'e'; case 'KeyF': return 'f'; case 'KeyG': return 'g'; case 'KeyH': return 'h'; case 'KeyI': return 'i'; case 'KeyJ': return 'j'; case 'KeyK': return 'k'; case 'KeyL': return 'l'; case 'KeyM': return 'm'; case 'KeyN': return 'n'; case 'KeyO': return 'o'; case 'KeyP': return 'p'; case 'KeyQ': return 'q'; case 'KeyR': return 'r'; case 'KeyS': return 's'; case 'KeyT': return 't'; case 'KeyU': return 'u'; case 'KeyV': return 'v'; case 'KeyW': return 'w'; case 'KeyX': return 'x'; case 'KeyY': return 'y'; case 'KeyZ': return 'z'; case 'Semicolon': return ';'; case 'Equal': return '='; case 'Comma': return ','; case 'Minus': return '-'; case 'Period': return '.'; case 'Slash': return '/'; case 'Backquote': return '`'; case 'BracketLeft': return '['; case 'Backslash': return '\\'; case 'BracketRight': return ']'; case 'Quote': return '"'; default: throw new Error(`Unknown key: "${key}"`); } }; /** * @internal */ export class BidiKeyboard extends Keyboard { #page: BidiPage; constructor(page: BidiPage) { super(); this.#page = page; } override async down( key: KeyInput, _options?: Readonly<KeyDownOptions> ): Promise<void> { await this.#page.mainFrame().browsingContext.performActions([ { type: SourceActionsType.Key, id: InputId.Keyboard, actions: [ { type: ActionType.KeyDown, value: getBidiKeyValue(key), }, ], }, ]); } override async up(key: KeyInput): Promise<void> { await this.#page.mainFrame().browsingContext.performActions([ { type: SourceActionsType.Key, id: InputId.Keyboard, actions: [ { type: ActionType.KeyUp, value: getBidiKeyValue(key), }, ], }, ]); } override async press( key: KeyInput, options: Readonly<KeyPressOptions> = {} ): Promise<void> { const {delay = 0} = options; const actions: Bidi.Input.KeySourceAction[] = [ { type: ActionType.KeyDown, value: getBidiKeyValue(key), }, ]; if (delay > 0) { actions.push({ type: ActionType.Pause, duration: delay, }); } actions.push({ type: ActionType.KeyUp, value: getBidiKeyValue(key), }); await this.#page.mainFrame().browsingContext.performActions([ { type: SourceActionsType.Key, id: InputId.Keyboard, actions, }, ]); } override async type( text: string, options: Readonly<KeyboardTypeOptions> = {} ): Promise<void> { const {delay = 0} = options; // This spread separates the characters into code points rather than UTF-16 // code units. const values = ([...text] as KeyInput[]).map(getBidiKeyValue); const actions: Bidi.Input.KeySourceAction[] = []; if (delay <= 0) { for (const value of values) { actions.push( { type: ActionType.KeyDown, value, }, { type: ActionType.KeyUp, value, } ); } } else { for (const value of values) { actions.push( { type: ActionType.KeyDown, value, }, { type: ActionType.Pause, duration: delay, }, { type: ActionType.KeyUp, value, } ); } } await this.#page.mainFrame().browsingContext.performActions([ { type: SourceActionsType.Key, id: InputId.Keyboard, actions, }, ]); } override async sendCharacter(char: string): Promise<void> { // Measures the number of code points rather than UTF-16 code units. if ([...char].length > 1) { throw new Error('Cannot send more than 1 character.'); } const frame = await this.#page.focusedFrame(); await frame.isolatedRealm().evaluate(async char => { document.execCommand('insertText', false, char); }, char); } } /** * @internal */ export interface BidiMouseClickOptions extends MouseClickOptions { origin?: Bidi.Input.Origin; } /** * @internal */ export interface BidiMouseMoveOptions extends MouseMoveOptions { origin?: Bidi.Input.Origin; } /** * @internal */ export interface BidiTouchMoveOptions { origin?: Bidi.Input.Origin; } const getBidiButton = (button: MouseButton) => { switch (button) { case MouseButton.Left: return 0; case MouseButton.Middle: return 1; case MouseButton.Right: return 2; case MouseButton.Back: return 3; case MouseButton.Forward: return 4; } }; /** * @internal */ export class BidiMouse extends Mouse { #page: BidiPage; #lastMovePoint: Point = {x: 0, y: 0}; constructor(page: BidiPage) { super(); this.#page = page; } override async reset(): Promise<void> { this.#lastMovePoint = {x: 0, y: 0}; await this.#page.mainFrame().browsingContext.releaseActions(); } override async move( x: number, y: number, options: Readonly<BidiMouseMoveOptions> = {} ): Promise<void> { const from = this.#lastMovePoint; const to = { x: Math.round(x), y: Math.round(y), }; const actions: Bidi.Input.PointerSourceAction[] = []; const steps = options.steps ?? 0; for (let i = 0; i < steps; ++i) { actions.push({ type: ActionType.PointerMove, x: from.x + (to.x - from.x) * (i / steps), y: from.y + (to.y - from.y) * (i / steps), origin: options.origin, }); } actions.push({ type: ActionType.PointerMove, ...to, origin: options.origin, }); // https://w3c.github.io/webdriver-bidi/#command-input-performActions:~:text=input.PointerMoveAction%20%3D%20%7B%0A%20%20type%3A%20%22pointerMove%22%2C%0A%20%20x%3A%20js%2Dint%2C this.#lastMovePoint = to; await this.#page.mainFrame().browsingContext.performActions([ { type: SourceActionsType.Pointer, id: InputId.Mouse, actions, }, ]); } override async down(options: Readonly<MouseOptions> = {}): Promise<void> { await this.#page.mainFrame().browsingContext.performActions([ { type: SourceActionsType.Pointer, id: InputId.Mouse, actions: [ { type: ActionType.PointerDown, button: getBidiButton(options.button ?? MouseButton.Left), }, ], }, ]); } override async up(options: Readonly<MouseOptions> = {}): Promise<void> { await this.#page.mainFrame().browsingContext.performActions([ { type: SourceActionsType.Pointer, id: InputId.Mouse, actions: [ { type: ActionType.PointerUp, button: getBidiButton(options.button ?? MouseButton.Left), }, ], }, ]); } override async click( x: number, y: number, options: Readonly<BidiMouseClickOptions> = {} ): Promise<void> { const actions: Bidi.Input.PointerSourceAction[] = [ { type: ActionType.PointerMove, x: Math.round(x), y: Math.round(y), origin: options.origin, }, ]; const pointerDownAction = { type: ActionType.PointerDown, button: getBidiButton(options.button ?? MouseButton.Left), } as const; const pointerUpAction = { type: ActionType.PointerUp, button: pointerDownAction.button, } as const; for (let i = 1; i < (options.count ?? 1); ++i) { actions.push(pointerDownAction, pointerUpAction); } actions.push(pointerDownAction); if (options.delay) { actions.push({ type: ActionType.Pause, duration: options.delay, }); } actions.push(pointerUpAction); await this.#page.mainFrame().browsingContext.performActions([ { type: SourceActionsType.Pointer, id: InputId.Mouse, actions, }, ]); } override async wheel( options: Readonly<MouseWheelOptions> = {} ): Promise<void> { await this.#page.mainFrame().browsingContext.performActions([ { type: SourceActionsType.Wheel, id: InputId.Wheel, actions: [ { type: ActionType.Scroll, ...(this.#lastMovePoint ?? { x: 0, y: 0, }), deltaX: options.deltaX ?? 0, deltaY: options.deltaY ?? 0, }, ], }, ]); } override drag(): never { throw new UnsupportedOperation(); } override dragOver(): never { throw new UnsupportedOperation(); } override dragEnter(): never { throw new UnsupportedOperation(); } override drop(): never { throw new UnsupportedOperation(); } override dragAndDrop(): never { throw new UnsupportedOperation(); } } /** * @internal */ export class BidiTouchscreen extends Touchscreen { #page: BidiPage; constructor(page: BidiPage) { super(); this.#page = page; } override async touchStart( x: number, y: number, options: BidiTouchMoveOptions = {} ): Promise<void> { await this.#page.mainFrame().browsingContext.performActions([ { type: SourceActionsType.Pointer, id: InputId.Finger, parameters: { pointerType: Bidi.Input.PointerType.Touch, }, actions: [ { type: ActionType.PointerMove, x: Math.round(x), y: Math.round(y), origin: options.origin, }, { type: ActionType.PointerDown, button: 0, width: 0.5 * 2, // 2 times default touch radius. height: 0.5 * 2, // 2 times default touch radius. pressure: 0.5, altitudeAngle: Math.PI / 2, }, ], }, ]); } override async touchMove( x: number, y: number, options: BidiTouchMoveOptions = {} ): Promise<void> { await this.#page.mainFrame().browsingContext.performActions([ { type: SourceActionsType.Pointer, id: InputId.Finger, parameters: { pointerType: Bidi.Input.PointerType.Touch, }, actions: [ { type: ActionType.PointerMove, x: Math.round(x), y: Math.round(y), origin: options.origin, width: 0.5 * 2, // 2 times default touch radius. height: 0.5 * 2, // 2 times default touch radius. pressure: 0.5, altitudeAngle: Math.PI / 2, }, ], }, ]); } override async touchEnd(): Promise<void> { await this.#page.mainFrame().browsingContext.performActions([ { type: SourceActionsType.Pointer, id: InputId.Finger, parameters: { pointerType: Bidi.Input.PointerType.Touch, }, actions: [ { type: ActionType.PointerUp, button: 0, }, ], }, ]); } }