"use strict"; /** * Copyright 2023 Google LLC. * 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. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.ActionDispatcher = void 0; const protocol_js_1 = require("../../../protocol/protocol.js"); const assert_js_1 = require("../../../utils/assert.js"); const GraphemeTools_1 = require("../../../utils/GraphemeTools"); const InputSource_js_1 = require("./InputSource.js"); const keyUtils_js_1 = require("./keyUtils.js"); const USKeyboardLayout_js_1 = require("./USKeyboardLayout.js"); /** https://w3c.github.io/webdriver/#dfn-center-point */ const CALCULATE_IN_VIEW_CENTER_PT_DECL = ((i) => { const t = i.getClientRects()[0], e = Math.max(0, Math.min(t.x, t.x + t.width)), n = Math.min(window.innerWidth, Math.max(t.x, t.x + t.width)), h = Math.max(0, Math.min(t.y, t.y + t.height)), m = Math.min(window.innerHeight, Math.max(t.y, t.y + t.height)); return [e + ((n - e) >> 1), h + ((m - h) >> 1)]; }).toString(); const IS_MAC_DECL = (() => { return navigator.platform.toLowerCase().includes('mac'); }).toString(); async function getElementCenter(context, element) { const sandbox = await context.getOrCreateSandbox(undefined); const result = await sandbox.callFunction(CALCULATE_IN_VIEW_CENTER_PT_DECL, false, { type: 'undefined' }, [element]); if (result.type === 'exception') { throw new protocol_js_1.NoSuchElementException(`Origin element ${element.sharedId} was not found`); } (0, assert_js_1.assert)(result.result.type === 'array'); (0, assert_js_1.assert)(result.result.value?.[0]?.type === 'number'); (0, assert_js_1.assert)(result.result.value?.[1]?.type === 'number'); const { result: { value: [{ value: x }, { value: y }], }, } = result; return { x: x, y: y }; } class ActionDispatcher { static isMacOS = async (context) => { const result = await (await context.getOrCreateSandbox(undefined)).callFunction(IS_MAC_DECL, false); (0, assert_js_1.assert)(result.type !== 'exception'); (0, assert_js_1.assert)(result.result.type === 'boolean'); return result.result.value; }; #tickStart = 0; #tickDuration = 0; #inputState; #context; #isMacOS; constructor(inputState, context, isMacOS) { this.#inputState = inputState; this.#context = context; this.#isMacOS = isMacOS; } async dispatchActions(optionsByTick) { await this.#inputState.queue.run(async () => { for (const options of optionsByTick) { await this.dispatchTickActions(options); } }); } async dispatchTickActions(options) { this.#tickStart = performance.now(); this.#tickDuration = 0; for (const { action } of options) { if ('duration' in action && action.duration !== undefined) { this.#tickDuration = Math.max(this.#tickDuration, action.duration); } } const promises = [ new Promise((resolve) => setTimeout(resolve, this.#tickDuration)), ]; for (const option of options) { // In theory we have to wait for each action to happen, but CDP is serial, // so as an optimization, we queue all CDP commands at once and await all // of them. promises.push(this.#dispatchAction(option)); } await Promise.all(promises); } async #dispatchAction({ id, action }) { const source = this.#inputState.get(id); const keyState = this.#inputState.getGlobalKeyState(); switch (action.type) { case 'keyDown': { // SAFETY: The source is validated before. await this.#dispatchKeyDownAction(source, action); this.#inputState.cancelList.push({ id, action: { ...action, type: 'keyUp', }, }); break; } case 'keyUp': { // SAFETY: The source is validated before. await this.#dispatchKeyUpAction(source, action); break; } case 'pause': { // TODO: Implement waiting on the input source. break; } case 'pointerDown': { // SAFETY: The source is validated before. await this.#dispatchPointerDownAction(source, keyState, action); this.#inputState.cancelList.push({ id, action: { ...action, type: 'pointerUp', }, }); break; } case 'pointerMove': { // SAFETY: The source is validated before. await this.#dispatchPointerMoveAction(source, keyState, action); break; } case 'pointerUp': { // SAFETY: The source is validated before. await this.#dispatchPointerUpAction(source, keyState, action); break; } case 'scroll': { // SAFETY: The source is validated before. await this.#dispatchScrollAction(source, keyState, action); break; } } } async #dispatchPointerDownAction(source, keyState, action) { const { button } = action; if (source.pressed.has(button)) { return; } source.pressed.add(button); const { x, y, subtype: pointerType } = source; const { width, height, pressure, twist, tangentialPressure } = action; const { tiltX, tiltY } = getTilt(action); // --- Platform-specific code begins here --- const { modifiers } = keyState; const { radiusX, radiusY } = getRadii(width ?? 1, height ?? 1); switch (pointerType) { case "mouse" /* Input.PointerType.Mouse */: case "pen" /* Input.PointerType.Pen */: // TODO: Implement width and height when available. await this.#context.cdpTarget.cdpClient.sendCommand('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, modifiers, button: getCdpButton(button), buttons: source.buttons, clickCount: source.setClickCount(button, new InputSource_js_1.PointerSource.ClickContext(x, y, performance.now())), pointerType, tangentialPressure, tiltX, tiltY, twist, force: pressure, }); break; case "touch" /* Input.PointerType.Touch */: await this.#context.cdpTarget.cdpClient.sendCommand('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [ { x, y, radiusX, radiusY, tangentialPressure, tiltX, tiltY, twist, force: pressure, id: source.pointerId, }, ], modifiers, }); break; } source.radiusX = radiusX; source.radiusY = radiusY; source.force = pressure; // --- Platform-specific code ends here --- } #dispatchPointerUpAction(source, keyState, action) { const { button } = action; if (!source.pressed.has(button)) { return; } source.pressed.delete(button); const { x, y, force, radiusX, radiusY, subtype: pointerType } = source; // --- Platform-specific code begins here --- const { modifiers } = keyState; switch (pointerType) { case "mouse" /* Input.PointerType.Mouse */: case "pen" /* Input.PointerType.Pen */: // TODO: Implement width and height when available. return this.#context.cdpTarget.cdpClient.sendCommand('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, modifiers, button: getCdpButton(button), buttons: source.buttons, clickCount: source.getClickCount(button), pointerType, }); case "touch" /* Input.PointerType.Touch */: return this.#context.cdpTarget.cdpClient.sendCommand('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [ { x, y, id: source.pointerId, force, radiusX, radiusY, }, ], modifiers, }); } // --- Platform-specific code ends here --- } async #dispatchPointerMoveAction(source, keyState, action) { const { x: startX, y: startY, subtype: pointerType } = source; const { width, height, pressure, twist, tangentialPressure, x: offsetX, y: offsetY, origin = 'viewport', duration = this.#tickDuration, } = action; const { tiltX, tiltY } = getTilt(action); const { radiusX, radiusY } = getRadii(width ?? 1, height ?? 1); const { targetX, targetY } = await this.#getCoordinateFromOrigin(origin, offsetX, offsetY, startX, startY); if (targetX < 0 || targetY < 0) { throw new protocol_js_1.MoveTargetOutOfBoundsException(`Cannot move beyond viewport (x: ${targetX}, y: ${targetY})`); } let last; do { const ratio = duration > 0 ? (performance.now() - this.#tickStart) / duration : 1; last = ratio >= 1; let x; let y; if (last) { x = targetX; y = targetY; } else { x = Math.round(ratio * (targetX - startX) + startX); y = Math.round(ratio * (targetY - startY) + startY); } if (source.x !== x || source.y !== y) { // --- Platform-specific code begins here --- const { modifiers } = keyState; switch (pointerType) { case "mouse" /* Input.PointerType.Mouse */: // TODO: Implement width and height when available. await this.#context.cdpTarget.cdpClient.sendCommand('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y, modifiers, clickCount: 0, button: getCdpButton(source.pressed.values().next().value ?? 5), buttons: source.buttons, pointerType, tangentialPressure, tiltX, tiltY, twist, force: pressure, }); break; case "pen" /* Input.PointerType.Pen */: if (source.pressed.size !== 0) { // Empty `source.pressed.size` means the pen is not detected by digitizer. // Dispatch a mouse event for the pen only if either: // 1. the pen is hovering over the digitizer (0); // 2. the pen is in contact with the digitizer (1); // 3. the pen has at least one button pressed (2, 4, etc). // https://www.w3.org/TR/pointerevents/#the-buttons-property // TODO: Implement width and height when available. await this.#context.cdpTarget.cdpClient.sendCommand('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y, modifiers, clickCount: 0, button: getCdpButton(source.pressed.values().next().value ?? 5), buttons: source.buttons, pointerType, tangentialPressure, tiltX, tiltY, twist, force: pressure ?? 0.5, }); } break; case "touch" /* Input.PointerType.Touch */: if (source.pressed.size !== 0) { await this.#context.cdpTarget.cdpClient.sendCommand('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [ { x, y, radiusX, radiusY, tangentialPressure, tiltX, tiltY, twist, force: pressure, id: source.pointerId, }, ], modifiers, }); } break; } // --- Platform-specific code ends here --- source.x = x; source.y = y; source.radiusX = radiusX; source.radiusY = radiusY; source.force = pressure; } } while (!last); } async #getCoordinateFromOrigin(origin, offsetX, offsetY, startX, startY) { let targetX; let targetY; switch (origin) { case 'viewport': targetX = offsetX; targetY = offsetY; break; case 'pointer': targetX = startX + offsetX; targetY = startY + offsetY; break; default: { const { x: posX, y: posY } = await getElementCenter(this.#context, origin.element); // SAFETY: These can never be special numbers. targetX = posX + offsetX; targetY = posY + offsetY; break; } } return { targetX, targetY }; } async #dispatchScrollAction(_source, keyState, action) { const { deltaX: targetDeltaX, deltaY: targetDeltaY, x: offsetX, y: offsetY, origin = 'viewport', duration = this.#tickDuration, } = action; if (origin === 'pointer') { throw new protocol_js_1.InvalidArgumentException('"pointer" origin is invalid for scrolling.'); } const { targetX, targetY } = await this.#getCoordinateFromOrigin(origin, offsetX, offsetY, 0, 0); if (targetX < 0 || targetY < 0) { throw new protocol_js_1.MoveTargetOutOfBoundsException(`Cannot move beyond viewport (x: ${targetX}, y: ${targetY})`); } let currentDeltaX = 0; let currentDeltaY = 0; let last; do { const ratio = duration > 0 ? (performance.now() - this.#tickStart) / duration : 1; last = ratio >= 1; let deltaX; let deltaY; if (last) { deltaX = targetDeltaX - currentDeltaX; deltaY = targetDeltaY - currentDeltaY; } else { deltaX = Math.round(ratio * targetDeltaX - currentDeltaX); deltaY = Math.round(ratio * targetDeltaY - currentDeltaY); } if (deltaX !== 0 || deltaY !== 0) { // --- Platform-specific code begins here --- const { modifiers } = keyState; await this.#context.cdpTarget.cdpClient.sendCommand('Input.dispatchMouseEvent', { type: 'mouseWheel', deltaX, deltaY, x: targetX, y: targetY, modifiers, }); // --- Platform-specific code ends here --- currentDeltaX += deltaX; currentDeltaY += deltaY; } } while (!last); } async #dispatchKeyDownAction(source, action) { const rawKey = action.value; if (!(0, GraphemeTools_1.isSingleGrapheme)(rawKey)) { // https://w3c.github.io/webdriver/#dfn-process-a-key-action // WebDriver spec allows a grapheme to be used. throw new protocol_js_1.InvalidArgumentException(`Invalid key value: ${rawKey}`); } const isGrapheme = (0, GraphemeTools_1.isSingleComplexGrapheme)(rawKey); const key = (0, keyUtils_js_1.getNormalizedKey)(rawKey); const repeat = source.pressed.has(key); const code = (0, keyUtils_js_1.getKeyCode)(rawKey); const location = (0, keyUtils_js_1.getKeyLocation)(rawKey); switch (key) { case 'Alt': source.alt = true; break; case 'Shift': source.shift = true; break; case 'Control': source.ctrl = true; break; case 'Meta': source.meta = true; break; } source.pressed.add(key); const { modifiers } = source; // --- Platform-specific code begins here --- // The spread is a little hack so JS gives us an array of unicode characters // to measure. const unmodifiedText = getKeyEventUnmodifiedText(key, source, isGrapheme); const text = getKeyEventText(code ?? '', source) ?? unmodifiedText; let command; // The following commands need to be declared because Chromium doesn't // handle them. See // https://source.chromium.org/chromium/chromium/src/+/refs/heads/main:third_party/blink/renderer/core/editing/editing_behavior.cc;l=169;drc=b8143cf1dfd24842890fcd831c4f5d909bef4fc4;bpv=0;bpt=1. if (this.#isMacOS && source.meta) { switch (code) { case 'KeyA': command = 'SelectAll'; break; case 'KeyC': command = 'Copy'; break; case 'KeyV': command = source.shift ? 'PasteAndMatchStyle' : 'Paste'; break; case 'KeyX': command = 'Cut'; break; case 'KeyZ': command = source.shift ? 'Redo' : 'Undo'; break; default: // Intentionally empty. } } const promises = [ this.#context.cdpTarget.cdpClient.sendCommand('Input.dispatchKeyEvent', { type: text ? 'keyDown' : 'rawKeyDown', windowsVirtualKeyCode: USKeyboardLayout_js_1.KeyToKeyCode[key], key, code, text, unmodifiedText, autoRepeat: repeat, isSystemKey: source.alt || undefined, location: location < 3 ? location : undefined, isKeypad: location === 3, modifiers, commands: command ? [command] : undefined, }), ]; // Drag cancelling happens on escape. if (key === 'Escape') { if (!source.alt && ((this.#isMacOS && !source.ctrl && !source.meta) || !this.#isMacOS)) { promises.push(this.#context.cdpTarget.cdpClient.sendCommand('Input.cancelDragging')); } } await Promise.all(promises); // --- Platform-specific code ends here --- } #dispatchKeyUpAction(source, action) { const rawKey = action.value; if (!(0, GraphemeTools_1.isSingleGrapheme)(rawKey)) { // https://w3c.github.io/webdriver/#dfn-process-a-key-action // WebDriver spec allows a grapheme to be used. throw new protocol_js_1.InvalidArgumentException(`Invalid key value: ${rawKey}`); } const isGrapheme = (0, GraphemeTools_1.isSingleComplexGrapheme)(rawKey); const key = (0, keyUtils_js_1.getNormalizedKey)(rawKey); if (!source.pressed.has(key)) { return; } const code = (0, keyUtils_js_1.getKeyCode)(rawKey); const location = (0, keyUtils_js_1.getKeyLocation)(rawKey); switch (key) { case 'Alt': source.alt = false; break; case 'Shift': source.shift = false; break; case 'Control': source.ctrl = false; break; case 'Meta': source.meta = false; break; } source.pressed.delete(key); const { modifiers } = source; // --- Platform-specific code begins here --- // The spread is a little hack so JS gives us an array of unicode characters // to measure. const unmodifiedText = getKeyEventUnmodifiedText(key, source, isGrapheme); const text = getKeyEventText(code ?? '', source) ?? unmodifiedText; return this.#context.cdpTarget.cdpClient.sendCommand('Input.dispatchKeyEvent', { type: 'keyUp', windowsVirtualKeyCode: USKeyboardLayout_js_1.KeyToKeyCode[key], key, code, text, unmodifiedText, location: location < 3 ? location : undefined, isSystemKey: source.alt || undefined, isKeypad: location === 3, modifiers, }); // --- Platform-specific code ends here --- } } exports.ActionDispatcher = ActionDispatcher; /** * Translates a non-grapheme key to either an `undefined` for a special keys, or a single * character modified by shift if needed. */ const getKeyEventUnmodifiedText = (key, source, isGrapheme) => { if (isGrapheme) { // Graphemes should be presented as text in the CDP command. return key; } if (key === 'Enter') { return '\r'; } // If key is not a single character, it is a normalized key value, and should be // presented as key, not text in the CDP command. return [...key].length === 1 ? source.shift ? key.toLocaleUpperCase('en-US') : key : undefined; }; const getKeyEventText = (code, source) => { if (source.ctrl) { switch (code) { case 'Digit2': if (source.shift) { return '\x00'; } break; case 'KeyA': return '\x01'; case 'KeyB': return '\x02'; case 'KeyC': return '\x03'; case 'KeyD': return '\x04'; case 'KeyE': return '\x05'; case 'KeyF': return '\x06'; case 'KeyG': return '\x07'; case 'KeyH': return '\x08'; case 'KeyI': return '\x09'; case 'KeyJ': return '\x0A'; case 'KeyK': return '\x0B'; case 'KeyL': return '\x0C'; case 'KeyM': return '\x0D'; case 'KeyN': return '\x0E'; case 'KeyO': return '\x0F'; case 'KeyP': return '\x10'; case 'KeyQ': return '\x11'; case 'KeyR': return '\x12'; case 'KeyS': return '\x13'; case 'KeyT': return '\x14'; case 'KeyU': return '\x15'; case 'KeyV': return '\x16'; case 'KeyW': return '\x17'; case 'KeyX': return '\x18'; case 'KeyY': return '\x19'; case 'KeyZ': return '\x1A'; case 'BracketLeft': return '\x1B'; case 'Backslash': return '\x1C'; case 'BracketRight': return '\x1D'; case 'Digit6': if (source.shift) { return '\x1E'; } break; case 'Minus': return '\x1F'; } return ''; } if (source.alt) { return ''; } return; }; function getCdpButton(button) { // https://www.w3.org/TR/pointerevents/#the-button-property switch (button) { case 0: return 'left'; case 1: return 'middle'; case 2: return 'right'; case 3: return 'back'; case 4: return 'forward'; default: return 'none'; } } function getTilt(action) { // https://w3c.github.io/pointerevents/#converting-between-tiltx-tilty-and-altitudeangle-azimuthangle const altitudeAngle = action.altitudeAngle ?? Math.PI / 2; const azimuthAngle = action.azimuthAngle ?? 0; let tiltXRadians = 0; let tiltYRadians = 0; if (altitudeAngle === 0) { // the pen is in the X-Y plane if (azimuthAngle === 0 || azimuthAngle === 2 * Math.PI) { // pen is on positive X axis tiltXRadians = Math.PI / 2; } if (azimuthAngle === Math.PI / 2) { // pen is on positive Y axis tiltYRadians = Math.PI / 2; } if (azimuthAngle === Math.PI) { // pen is on negative X axis tiltXRadians = -Math.PI / 2; } if (azimuthAngle === (3 * Math.PI) / 2) { // pen is on negative Y axis tiltYRadians = -Math.PI / 2; } if (azimuthAngle > 0 && azimuthAngle < Math.PI / 2) { tiltXRadians = Math.PI / 2; tiltYRadians = Math.PI / 2; } if (azimuthAngle > Math.PI / 2 && azimuthAngle < Math.PI) { tiltXRadians = -Math.PI / 2; tiltYRadians = Math.PI / 2; } if (azimuthAngle > Math.PI && azimuthAngle < (3 * Math.PI) / 2) { tiltXRadians = -Math.PI / 2; tiltYRadians = -Math.PI / 2; } if (azimuthAngle > (3 * Math.PI) / 2 && azimuthAngle < 2 * Math.PI) { tiltXRadians = Math.PI / 2; tiltYRadians = -Math.PI / 2; } } if (altitudeAngle !== 0) { const tanAlt = Math.tan(altitudeAngle); tiltXRadians = Math.atan(Math.cos(azimuthAngle) / tanAlt); tiltYRadians = Math.atan(Math.sin(azimuthAngle) / tanAlt); } const factor = 180 / Math.PI; return { tiltX: Math.round(tiltXRadians * factor), tiltY: Math.round(tiltYRadians * factor), }; } function getRadii(width, height) { return { radiusX: width ? width / 2 : 0.5, radiusY: height ? height / 2 : 0.5, }; } //# sourceMappingURL=ActionDispatcher.js.map