Input.ts (16721B)
1 /** 2 * @license 3 * Copyright 2017 Google Inc. 4 * SPDX-License-Identifier: Apache-2.0 5 */ 6 7 import type {Protocol} from 'devtools-protocol'; 8 9 import type {CDPSession} from '../api/CDPSession.js'; 10 import type {Point} from '../api/ElementHandle.js'; 11 import { 12 Keyboard, 13 Mouse, 14 MouseButton, 15 Touchscreen, 16 type TouchHandle, 17 type KeyDownOptions, 18 type KeyPressOptions, 19 type KeyboardTypeOptions, 20 type MouseClickOptions, 21 type MouseMoveOptions, 22 type MouseOptions, 23 type MouseWheelOptions, 24 } from '../api/Input.js'; 25 import {TouchError} from '../common/Errors.js'; 26 import { 27 _keyDefinitions, 28 type KeyDefinition, 29 type KeyInput, 30 } from '../common/USKeyboardLayout.js'; 31 import {assert} from '../util/assert.js'; 32 33 type KeyDescription = Required< 34 Pick<KeyDefinition, 'keyCode' | 'key' | 'text' | 'code' | 'location'> 35 >; 36 37 /** 38 * @internal 39 */ 40 export class CdpKeyboard extends Keyboard { 41 #client: CDPSession; 42 #pressedKeys = new Set<string>(); 43 44 _modifiers = 0; 45 46 constructor(client: CDPSession) { 47 super(); 48 this.#client = client; 49 } 50 51 updateClient(client: CDPSession): void { 52 this.#client = client; 53 } 54 55 override async down( 56 key: KeyInput, 57 options: Readonly<KeyDownOptions> = { 58 text: undefined, 59 commands: [], 60 }, 61 ): Promise<void> { 62 const description = this.#keyDescriptionForString(key); 63 64 const autoRepeat = this.#pressedKeys.has(description.code); 65 this.#pressedKeys.add(description.code); 66 this._modifiers |= this.#modifierBit(description.key); 67 68 const text = options.text === undefined ? description.text : options.text; 69 await this.#client.send('Input.dispatchKeyEvent', { 70 type: text ? 'keyDown' : 'rawKeyDown', 71 modifiers: this._modifiers, 72 windowsVirtualKeyCode: description.keyCode, 73 code: description.code, 74 key: description.key, 75 text: text, 76 unmodifiedText: text, 77 autoRepeat, 78 location: description.location, 79 isKeypad: description.location === 3, 80 commands: options.commands, 81 }); 82 } 83 84 #modifierBit(key: string): number { 85 if (key === 'Alt') { 86 return 1; 87 } 88 if (key === 'Control') { 89 return 2; 90 } 91 if (key === 'Meta') { 92 return 4; 93 } 94 if (key === 'Shift') { 95 return 8; 96 } 97 return 0; 98 } 99 100 #keyDescriptionForString(keyString: KeyInput): KeyDescription { 101 const shift = this._modifiers & 8; 102 const description = { 103 key: '', 104 keyCode: 0, 105 code: '', 106 text: '', 107 location: 0, 108 }; 109 110 const definition = _keyDefinitions[keyString]; 111 assert(definition, `Unknown key: "${keyString}"`); 112 113 if (definition.key) { 114 description.key = definition.key; 115 } 116 if (shift && definition.shiftKey) { 117 description.key = definition.shiftKey; 118 } 119 120 if (definition.keyCode) { 121 description.keyCode = definition.keyCode; 122 } 123 if (shift && definition.shiftKeyCode) { 124 description.keyCode = definition.shiftKeyCode; 125 } 126 127 if (definition.code) { 128 description.code = definition.code; 129 } 130 131 if (definition.location) { 132 description.location = definition.location; 133 } 134 135 if (description.key.length === 1) { 136 description.text = description.key; 137 } 138 139 if (definition.text) { 140 description.text = definition.text; 141 } 142 if (shift && definition.shiftText) { 143 description.text = definition.shiftText; 144 } 145 146 // if any modifiers besides shift are pressed, no text should be sent 147 if (this._modifiers & ~8) { 148 description.text = ''; 149 } 150 151 return description; 152 } 153 154 override async up(key: KeyInput): Promise<void> { 155 const description = this.#keyDescriptionForString(key); 156 157 this._modifiers &= ~this.#modifierBit(description.key); 158 this.#pressedKeys.delete(description.code); 159 await this.#client.send('Input.dispatchKeyEvent', { 160 type: 'keyUp', 161 modifiers: this._modifiers, 162 key: description.key, 163 windowsVirtualKeyCode: description.keyCode, 164 code: description.code, 165 location: description.location, 166 }); 167 } 168 169 override async sendCharacter(char: string): Promise<void> { 170 await this.#client.send('Input.insertText', {text: char}); 171 } 172 173 private charIsKey(char: string): char is KeyInput { 174 return !!_keyDefinitions[char as KeyInput]; 175 } 176 177 override async type( 178 text: string, 179 options: Readonly<KeyboardTypeOptions> = {}, 180 ): Promise<void> { 181 const delay = options.delay || undefined; 182 for (const char of text) { 183 if (this.charIsKey(char)) { 184 await this.press(char, {delay}); 185 } else { 186 if (delay) { 187 await new Promise(f => { 188 return setTimeout(f, delay); 189 }); 190 } 191 await this.sendCharacter(char); 192 } 193 } 194 } 195 196 override async press( 197 key: KeyInput, 198 options: Readonly<KeyPressOptions> = {}, 199 ): Promise<void> { 200 const {delay = null} = options; 201 await this.down(key, options); 202 if (delay) { 203 await new Promise(f => { 204 return setTimeout(f, options.delay); 205 }); 206 } 207 await this.up(key); 208 } 209 } 210 211 /** 212 * This must follow {@link Protocol.Input.DispatchMouseEventRequest.buttons}. 213 */ 214 const enum MouseButtonFlag { 215 None = 0, 216 Left = 1, 217 Right = 1 << 1, 218 Middle = 1 << 2, 219 Back = 1 << 3, 220 Forward = 1 << 4, 221 } 222 223 const getFlag = (button: MouseButton): MouseButtonFlag => { 224 switch (button) { 225 case MouseButton.Left: 226 return MouseButtonFlag.Left; 227 case MouseButton.Right: 228 return MouseButtonFlag.Right; 229 case MouseButton.Middle: 230 return MouseButtonFlag.Middle; 231 case MouseButton.Back: 232 return MouseButtonFlag.Back; 233 case MouseButton.Forward: 234 return MouseButtonFlag.Forward; 235 } 236 }; 237 238 /** 239 * This should match 240 * https://source.chromium.org/chromium/chromium/src/+/refs/heads/main:content/browser/renderer_host/input/web_input_event_builders_mac.mm;drc=a61b95c63b0b75c1cfe872d9c8cdf927c226046e;bpv=1;bpt=1;l=221. 241 */ 242 const getButtonFromPressedButtons = ( 243 buttons: number, 244 ): Protocol.Input.MouseButton => { 245 if (buttons & MouseButtonFlag.Left) { 246 return MouseButton.Left; 247 } else if (buttons & MouseButtonFlag.Right) { 248 return MouseButton.Right; 249 } else if (buttons & MouseButtonFlag.Middle) { 250 return MouseButton.Middle; 251 } else if (buttons & MouseButtonFlag.Back) { 252 return MouseButton.Back; 253 } else if (buttons & MouseButtonFlag.Forward) { 254 return MouseButton.Forward; 255 } 256 return 'none'; 257 }; 258 259 interface MouseState { 260 /** 261 * The current position of the mouse. 262 */ 263 position: Point; 264 /** 265 * The buttons that are currently being pressed. 266 */ 267 buttons: number; 268 } 269 270 /** 271 * @internal 272 */ 273 export class CdpMouse extends Mouse { 274 #client: CDPSession; 275 #keyboard: CdpKeyboard; 276 277 constructor(client: CDPSession, keyboard: CdpKeyboard) { 278 super(); 279 this.#client = client; 280 this.#keyboard = keyboard; 281 } 282 283 updateClient(client: CDPSession): void { 284 this.#client = client; 285 } 286 287 #_state: Readonly<MouseState> = { 288 position: {x: 0, y: 0}, 289 buttons: MouseButtonFlag.None, 290 }; 291 get #state(): MouseState { 292 return Object.assign({...this.#_state}, ...this.#transactions); 293 } 294 295 // Transactions can run in parallel, so we store each of thme in this array. 296 #transactions: Array<Partial<MouseState>> = []; 297 #createTransaction(): { 298 update: (updates: Partial<MouseState>) => void; 299 commit: () => void; 300 rollback: () => void; 301 } { 302 const transaction: Partial<MouseState> = {}; 303 this.#transactions.push(transaction); 304 const popTransaction = () => { 305 this.#transactions.splice(this.#transactions.indexOf(transaction), 1); 306 }; 307 return { 308 update: (updates: Partial<MouseState>) => { 309 Object.assign(transaction, updates); 310 }, 311 commit: () => { 312 this.#_state = {...this.#_state, ...transaction}; 313 popTransaction(); 314 }, 315 rollback: popTransaction, 316 }; 317 } 318 319 /** 320 * This is a shortcut for a typical update, commit/rollback lifecycle based on 321 * the error of the action. 322 */ 323 async #withTransaction( 324 action: ( 325 update: (updates: Partial<MouseState>) => void, 326 ) => Promise<unknown>, 327 ): Promise<void> { 328 const {update, commit, rollback} = this.#createTransaction(); 329 try { 330 await action(update); 331 commit(); 332 } catch (error) { 333 rollback(); 334 throw error; 335 } 336 } 337 338 override async reset(): Promise<void> { 339 const actions = []; 340 for (const [flag, button] of [ 341 [MouseButtonFlag.Left, MouseButton.Left], 342 [MouseButtonFlag.Middle, MouseButton.Middle], 343 [MouseButtonFlag.Right, MouseButton.Right], 344 [MouseButtonFlag.Forward, MouseButton.Forward], 345 [MouseButtonFlag.Back, MouseButton.Back], 346 ] as const) { 347 if (this.#state.buttons & flag) { 348 actions.push(this.up({button: button})); 349 } 350 } 351 if (this.#state.position.x !== 0 || this.#state.position.y !== 0) { 352 actions.push(this.move(0, 0)); 353 } 354 await Promise.all(actions); 355 } 356 357 override async move( 358 x: number, 359 y: number, 360 options: Readonly<MouseMoveOptions> = {}, 361 ): Promise<void> { 362 const {steps = 1} = options; 363 const from = this.#state.position; 364 const to = {x, y}; 365 for (let i = 1; i <= steps; i++) { 366 await this.#withTransaction(updateState => { 367 updateState({ 368 position: { 369 x: from.x + (to.x - from.x) * (i / steps), 370 y: from.y + (to.y - from.y) * (i / steps), 371 }, 372 }); 373 const {buttons, position} = this.#state; 374 return this.#client.send('Input.dispatchMouseEvent', { 375 type: 'mouseMoved', 376 modifiers: this.#keyboard._modifiers, 377 buttons, 378 button: getButtonFromPressedButtons(buttons), 379 ...position, 380 }); 381 }); 382 } 383 } 384 385 override async down(options: Readonly<MouseOptions> = {}): Promise<void> { 386 const {button = MouseButton.Left, clickCount = 1} = options; 387 const flag = getFlag(button); 388 if (!flag) { 389 throw new Error(`Unsupported mouse button: ${button}`); 390 } 391 if (this.#state.buttons & flag) { 392 throw new Error(`'${button}' is already pressed.`); 393 } 394 await this.#withTransaction(updateState => { 395 updateState({ 396 buttons: this.#state.buttons | flag, 397 }); 398 const {buttons, position} = this.#state; 399 return this.#client.send('Input.dispatchMouseEvent', { 400 type: 'mousePressed', 401 modifiers: this.#keyboard._modifiers, 402 clickCount, 403 buttons, 404 button, 405 ...position, 406 }); 407 }); 408 } 409 410 override async up(options: Readonly<MouseOptions> = {}): Promise<void> { 411 const {button = MouseButton.Left, clickCount = 1} = options; 412 const flag = getFlag(button); 413 if (!flag) { 414 throw new Error(`Unsupported mouse button: ${button}`); 415 } 416 if (!(this.#state.buttons & flag)) { 417 throw new Error(`'${button}' is not pressed.`); 418 } 419 await this.#withTransaction(updateState => { 420 updateState({ 421 buttons: this.#state.buttons & ~flag, 422 }); 423 const {buttons, position} = this.#state; 424 return this.#client.send('Input.dispatchMouseEvent', { 425 type: 'mouseReleased', 426 modifiers: this.#keyboard._modifiers, 427 clickCount, 428 buttons, 429 button, 430 ...position, 431 }); 432 }); 433 } 434 435 override async click( 436 x: number, 437 y: number, 438 options: Readonly<MouseClickOptions> = {}, 439 ): Promise<void> { 440 const {delay, count = 1, clickCount = count} = options; 441 if (count < 1) { 442 throw new Error('Click must occur a positive number of times.'); 443 } 444 const actions: Array<Promise<void>> = [this.move(x, y)]; 445 if (clickCount === count) { 446 for (let i = 1; i < count; ++i) { 447 actions.push( 448 this.down({...options, clickCount: i}), 449 this.up({...options, clickCount: i}), 450 ); 451 } 452 } 453 actions.push(this.down({...options, clickCount})); 454 if (typeof delay === 'number') { 455 await Promise.all(actions); 456 actions.length = 0; 457 await new Promise(resolve => { 458 setTimeout(resolve, delay); 459 }); 460 } 461 actions.push(this.up({...options, clickCount})); 462 await Promise.all(actions); 463 } 464 465 override async wheel( 466 options: Readonly<MouseWheelOptions> = {}, 467 ): Promise<void> { 468 const {deltaX = 0, deltaY = 0} = options; 469 const {position, buttons} = this.#state; 470 await this.#client.send('Input.dispatchMouseEvent', { 471 type: 'mouseWheel', 472 pointerType: 'mouse', 473 modifiers: this.#keyboard._modifiers, 474 deltaY, 475 deltaX, 476 buttons, 477 ...position, 478 }); 479 } 480 481 override async drag( 482 start: Point, 483 target: Point, 484 ): Promise<Protocol.Input.DragData> { 485 const promise = new Promise<Protocol.Input.DragData>(resolve => { 486 this.#client.once('Input.dragIntercepted', event => { 487 return resolve(event.data); 488 }); 489 }); 490 await this.move(start.x, start.y); 491 await this.down(); 492 await this.move(target.x, target.y); 493 return await promise; 494 } 495 496 override async dragEnter( 497 target: Point, 498 data: Protocol.Input.DragData, 499 ): Promise<void> { 500 await this.#client.send('Input.dispatchDragEvent', { 501 type: 'dragEnter', 502 x: target.x, 503 y: target.y, 504 modifiers: this.#keyboard._modifiers, 505 data, 506 }); 507 } 508 509 override async dragOver( 510 target: Point, 511 data: Protocol.Input.DragData, 512 ): Promise<void> { 513 await this.#client.send('Input.dispatchDragEvent', { 514 type: 'dragOver', 515 x: target.x, 516 y: target.y, 517 modifiers: this.#keyboard._modifiers, 518 data, 519 }); 520 } 521 522 override async drop( 523 target: Point, 524 data: Protocol.Input.DragData, 525 ): Promise<void> { 526 await this.#client.send('Input.dispatchDragEvent', { 527 type: 'drop', 528 x: target.x, 529 y: target.y, 530 modifiers: this.#keyboard._modifiers, 531 data, 532 }); 533 } 534 535 override async dragAndDrop( 536 start: Point, 537 target: Point, 538 options: {delay?: number} = {}, 539 ): Promise<void> { 540 const {delay = null} = options; 541 const data = await this.drag(start, target); 542 await this.dragEnter(target, data); 543 await this.dragOver(target, data); 544 if (delay) { 545 await new Promise(resolve => { 546 return setTimeout(resolve, delay); 547 }); 548 } 549 await this.drop(target, data); 550 await this.up(); 551 } 552 } 553 554 /** 555 * @internal 556 */ 557 class CdpTouchHandle implements TouchHandle { 558 #started = false; 559 #touchScreen: CdpTouchscreen; 560 #touchPoint: Protocol.Input.TouchPoint; 561 #client: CDPSession; 562 #keyboard: CdpKeyboard; 563 564 constructor( 565 client: CDPSession, 566 touchScreen: CdpTouchscreen, 567 keyboard: CdpKeyboard, 568 touchPoint: Protocol.Input.TouchPoint, 569 ) { 570 this.#client = client; 571 this.#touchScreen = touchScreen; 572 this.#keyboard = keyboard; 573 this.#touchPoint = touchPoint; 574 } 575 576 updateClient(client: CDPSession): void { 577 this.#client = client; 578 } 579 580 async start(): Promise<void> { 581 if (this.#started) { 582 throw new TouchError('Touch has already started'); 583 } 584 await this.#client.send('Input.dispatchTouchEvent', { 585 type: 'touchStart', 586 touchPoints: [this.#touchPoint], 587 modifiers: this.#keyboard._modifiers, 588 }); 589 this.#started = true; 590 } 591 592 move(x: number, y: number): Promise<void> { 593 this.#touchPoint.x = Math.round(x); 594 this.#touchPoint.y = Math.round(y); 595 return this.#client.send('Input.dispatchTouchEvent', { 596 type: 'touchMove', 597 touchPoints: [this.#touchPoint], 598 modifiers: this.#keyboard._modifiers, 599 }); 600 } 601 602 async end(): Promise<void> { 603 await this.#client.send('Input.dispatchTouchEvent', { 604 type: 'touchEnd', 605 touchPoints: [this.#touchPoint], 606 modifiers: this.#keyboard._modifiers, 607 }); 608 this.#touchScreen.removeHandle(this); 609 } 610 } 611 612 /** 613 * @internal 614 */ 615 export class CdpTouchscreen extends Touchscreen { 616 #client: CDPSession; 617 #keyboard: CdpKeyboard; 618 declare touches: CdpTouchHandle[]; 619 620 constructor(client: CDPSession, keyboard: CdpKeyboard) { 621 super(); 622 this.#client = client; 623 this.#keyboard = keyboard; 624 } 625 626 updateClient(client: CDPSession): void { 627 this.#client = client; 628 this.touches.forEach(t => { 629 t.updateClient(client); 630 }); 631 } 632 633 override async touchStart(x: number, y: number): Promise<TouchHandle> { 634 const id = this.idGenerator(); 635 const touchPoint: Protocol.Input.TouchPoint = { 636 x: Math.round(x), 637 y: Math.round(y), 638 radiusX: 0.5, 639 radiusY: 0.5, 640 force: 0.5, 641 id, 642 }; 643 const touch = new CdpTouchHandle( 644 this.#client, 645 this, 646 this.#keyboard, 647 touchPoint, 648 ); 649 await touch.start(); 650 this.touches.push(touch); 651 return touch; 652 } 653 }