/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this file, * You can obtain one at http://mozilla.org/MPL/2.0/. */ "use strict"; const {classes: Cc, interfaces: Ci, utils: Cu} = Components; Cu.import("resource://gre/modules/Task.jsm"); Cu.import("chrome://marionette/content/assert.js"); Cu.import("chrome://marionette/content/element.js"); Cu.import("chrome://marionette/content/error.js"); Cu.import("chrome://marionette/content/event.js"); this.EXPORTED_SYMBOLS = ["action"]; // TODO? With ES 2016 and Symbol you can make a safer approximation // to an enum e.g. https://gist.github.com/xmlking/e86e4f15ec32b12c4689 /** * Implements WebDriver Actions API: a low-level interface for providing * virtualised device input to the web browser. */ this.action = { Pause: "pause", KeyDown: "keyDown", KeyUp: "keyUp", PointerDown: "pointerDown", PointerUp: "pointerUp", PointerMove: "pointerMove", PointerCancel: "pointerCancel", }; const ACTIONS = { none: new Set([action.Pause]), key: new Set([action.Pause, action.KeyDown, action.KeyUp]), pointer: new Set([ action.Pause, action.PointerDown, action.PointerUp, action.PointerMove, action.PointerCancel, ]), }; /** Map from normalized key value to UI Events modifier key name */ const MODIFIER_NAME_LOOKUP = { "Alt": "alt", "Shift": "shift", "Control": "ctrl", "Meta": "meta", }; /** Map from raw key (codepoint) to normalized key value */ const NORMALIZED_KEY_LOOKUP = { "\uE000": "Unidentified", "\uE001": "Cancel", "\uE002": "Help", "\uE003": "Backspace", "\uE004": "Tab", "\uE005": "Clear", "\uE006": "Return", "\uE007": "Enter", "\uE008": "Shift", "\uE009": "Control", "\uE00A": "Alt", "\uE00B": "Pause", "\uE00C": "Escape", "\uE00D": " ", "\uE00E": "PageUp", "\uE00F": "PageDown", "\uE010": "End", "\uE011": "Home", "\uE012": "ArrowLeft", "\uE013": "ArrowUp", "\uE014": "ArrowRight", "\uE015": "ArrowDown", "\uE016": "Insert", "\uE017": "Delete", "\uE018": ";", "\uE019": "=", "\uE01A": "0", "\uE01B": "1", "\uE01C": "2", "\uE01D": "3", "\uE01E": "4", "\uE01F": "5", "\uE020": "6", "\uE021": "7", "\uE022": "8", "\uE023": "9", "\uE024": "*", "\uE025": "+", "\uE026": ",", "\uE027": "-", "\uE028": ".", "\uE029": "/", "\uE031": "F1", "\uE032": "F2", "\uE033": "F3", "\uE034": "F4", "\uE035": "F5", "\uE036": "F6", "\uE037": "F7", "\uE038": "F8", "\uE039": "F9", "\uE03A": "F10", "\uE03B": "F11", "\uE03C": "F12", "\uE03D": "Meta", "\uE040": "ZenkakuHankaku", "\uE050": "Shift", "\uE051": "Control", "\uE052": "Alt", "\uE053": "Meta", "\uE054": "PageUp", "\uE055": "PageDown", "\uE056": "End", "\uE057": "Home", "\uE058": "ArrowLeft", "\uE059": "ArrowUp", "\uE05A": "ArrowRight", "\uE05B": "ArrowDown", "\uE05C": "Insert", "\uE05D": "Delete", }; /** Map from raw key (codepoint) to key location */ const KEY_LOCATION_LOOKUP = { "\uE007": 1, "\uE008": 1, "\uE009": 1, "\uE00A": 1, "\uE01A": 3, "\uE01B": 3, "\uE01C": 3, "\uE01D": 3, "\uE01E": 3, "\uE01F": 3, "\uE020": 3, "\uE021": 3, "\uE022": 3, "\uE023": 3, "\uE024": 3, "\uE025": 3, "\uE026": 3, "\uE027": 3, "\uE028": 3, "\uE029": 3, "\uE03D": 1, "\uE050": 2, "\uE051": 2, "\uE052": 2, "\uE053": 2, "\uE054": 3, "\uE055": 3, "\uE056": 3, "\uE057": 3, "\uE058": 3, "\uE059": 3, "\uE05A": 3, "\uE05B": 3, "\uE05C": 3, "\uE05D": 3, }; const KEY_CODE_LOOKUP = { "\uE00A": "AltLeft", "\uE052": "AltRight", "\uE015": "ArrowDown", "\uE012": "ArrowLeft", "\uE014": "ArrowRight", "\uE013": "ArrowUp", "`": "Backquote", "~": "Backquote", "\\": "Backslash", "|": "Backslash", "\uE003": "Backspace", "[": "BracketLeft", "{": "BracketLeft", "]": "BracketRight", "}": "BracketRight", ",": "Comma", "<": "Comma", "\uE009": "ControlLeft", "\uE051": "ControlRight", "\uE017": "Delete", ")": "Digit0", "0": "Digit0", "!": "Digit1", "1": "Digit1", "2": "Digit2", "@": "Digit2", "#": "Digit3", "3": "Digit3", "$": "Digit4", "4": "Digit4", "%": "Digit5", "5": "Digit5", "6": "Digit6", "^": "Digit6", "&": "Digit7", "7": "Digit7", "*": "Digit8", "8": "Digit8", "(": "Digit9", "9": "Digit9", "\uE010": "End", "\uE006": "Enter", "+": "Equal", "=": "Equal", "\uE00C": "Escape", "\uE031": "F1", "\uE03A": "F10", "\uE03B": "F11", "\uE03C": "F12", "\uE032": "F2", "\uE033": "F3", "\uE034": "F4", "\uE035": "F5", "\uE036": "F6", "\uE037": "F7", "\uE038": "F8", "\uE039": "F9", "\uE002": "Help", "\uE011": "Home", "\uE016": "Insert", "<": "IntlBackslash", ">": "IntlBackslash", "A": "KeyA", "a": "KeyA", "B": "KeyB", "b": "KeyB", "C": "KeyC", "c": "KeyC", "D": "KeyD", "d": "KeyD", "E": "KeyE", "e": "KeyE", "F": "KeyF", "f": "KeyF", "G": "KeyG", "g": "KeyG", "H": "KeyH", "h": "KeyH", "I": "KeyI", "i": "KeyI", "J": "KeyJ", "j": "KeyJ", "K": "KeyK", "k": "KeyK", "L": "KeyL", "l": "KeyL", "M": "KeyM", "m": "KeyM", "N": "KeyN", "n": "KeyN", "O": "KeyO", "o": "KeyO", "P": "KeyP", "p": "KeyP", "Q": "KeyQ", "q": "KeyQ", "R": "KeyR", "r": "KeyR", "S": "KeyS", "s": "KeyS", "T": "KeyT", "t": "KeyT", "U": "KeyU", "u": "KeyU", "V": "KeyV", "v": "KeyV", "W": "KeyW", "w": "KeyW", "X": "KeyX", "x": "KeyX", "Y": "KeyY", "y": "KeyY", "Z": "KeyZ", "z": "KeyZ", "-": "Minus", "_": "Minus", "\uE01A": "Numpad0", "\uE05C": "Numpad0", "\uE01B": "Numpad1", "\uE056": "Numpad1", "\uE01C": "Numpad2", "\uE05B": "Numpad2", "\uE01D": "Numpad3", "\uE055": "Numpad3", "\uE01E": "Numpad4", "\uE058": "Numpad4", "\uE01F": "Numpad5", "\uE020": "Numpad6", "\uE05A": "Numpad6", "\uE021": "Numpad7", "\uE057": "Numpad7", "\uE022": "Numpad8", "\uE059": "Numpad8", "\uE023": "Numpad9", "\uE054": "Numpad9", "\uE024": "NumpadAdd", "\uE026": "NumpadComma", "\uE028": "NumpadDecimal", "\uE05D": "NumpadDecimal", "\uE029": "NumpadDivide", "\uE007": "NumpadEnter", "\uE024": "NumpadMultiply", "\uE026": "NumpadSubtract", "\uE03D": "OSLeft", "\uE053": "OSRight", "\uE01E": "PageDown", "\uE01F": "PageUp", ".": "Period", ">": "Period", "\"": "Quote", "'": "Quote", ":": "Semicolon", ";": "Semicolon", "\uE008": "ShiftLeft", "\uE050": "ShiftRight", "/": "Slash", "?": "Slash", "\uE00D": "Space", " ": "Space", "\uE004": "Tab", }; /** Represents possible values for a pointer-move origin. */ action.PointerOrigin = { Viewport: "viewport", Pointer: "pointer", }; /** * Look up a PointerOrigin. * * @param {?} obj * Origin for a pointerMove action. * * @return {?} * A pointer origin that is either "viewport" (default), "pointer", or a * web-element reference. * * @throws {InvalidArgumentError} * If |obj| is not a valid origin. */ action.PointerOrigin.get = function(obj) { let origin = obj; if (typeof obj == "undefined") { origin = this.Viewport; } else if (typeof obj == "string") { let name = capitalize(obj); assert.in(name, this, error.pprint`Unknown pointer-move origin: ${obj}`); origin = this[name]; } else if (!element.isWebElementReference(obj)) { throw new InvalidArgumentError("Expected 'origin' to be a string or a " + `web element reference, got: ${obj}`); } return origin; }; /** Represents possible subtypes for a pointer input source. */ action.PointerType = { Mouse: "mouse", // TODO For now, only mouse is supported //Pen: "pen", //Touch: "touch", }; /** * Look up a PointerType. * * @param {string} str * Name of pointer type. * * @return {string} * A pointer type for processing pointer parameters. * * @throws {InvalidArgumentError} * If |str| is not a valid pointer type. */ action.PointerType.get = function (str) { let name = capitalize(str); assert.in(name, this, error.pprint`Unknown pointerType: ${str}`); return this[name]; }; /** * Input state associated with current session. This is a map between input ID and * the device state for that input source, with one entry for each active input source. * * Initialized in listener.js */ action.inputStateMap = undefined; /** * List of |action.Action| associated with current session. Used to manage dispatching * events when resetting the state of the input sources. Reset operations are assumed * to be idempotent. * * Initialized in listener.js */ action.inputsToCancel = undefined; /** * Represents device state for an input source. */ class InputState { constructor() { this.type = this.constructor.name.toLowerCase(); } /** * Check equality of this InputState object with another. * * @para{?} other * Object representing an input state. * @return {boolean} * True if |this| has the same |type| as |other|. */ is(other) { if (typeof other == "undefined") { return false; } return this.type === other.type; } toString() { return `[object ${this.constructor.name}InputState]`; } /** * @param {?} obj * Object with property |type| and optionally |parameters| or |pointerType|, * representing an action sequence or an action item. * * @return {action.InputState} * An |action.InputState| object for the type of the |actionSequence|. * * @throws {InvalidArgumentError} * If |actionSequence.type| is not valid. */ static fromJson(obj) { let type = obj.type; assert.in(type, ACTIONS, error.pprint`Unknown action type: ${type}`); let name = type == "none" ? "Null" : capitalize(type); if (name == "Pointer") { if (!obj.pointerType && (!obj.parameters || !obj.parameters.pointerType)) { throw new InvalidArgumentError( error.pprint`Expected obj to have pointerType, got: ${obj}`); } let pointerType = obj.pointerType || obj.parameters.pointerType; return new action.InputState[name](pointerType); } else { return new action.InputState[name](); } } } /** Possible kinds of |InputState| for supported input sources. */ action.InputState = {}; /** * Input state associated with a keyboard-type device. */ action.InputState.Key = class Key extends InputState { constructor() { super(); this.pressed = new Set(); this.alt = false; this.shift = false; this.ctrl = false; this.meta = false; } /** * Update modifier state according to |key|. * * @param {string} key * Normalized key value of a modifier key. * @param {boolean} value * Value to set the modifier attribute to. * * @throws {InvalidArgumentError} * If |key| is not a modifier. */ setModState(key, value) { if (key in MODIFIER_NAME_LOOKUP) { this[MODIFIER_NAME_LOOKUP[key]] = value; } else { throw new InvalidArgumentError("Expected 'key' to be one of " + `${Object.keys(MODIFIER_NAME_LOOKUP)}; got: ${key}`); } } /** * Check whether |key| is pressed. * * @param {string} key * Normalized key value. * * @return {boolean} * True if |key| is in set of pressed keys. */ isPressed(key) { return this.pressed.has(key); } /** * Add |key| to the set of pressed keys. * * @param {string} key * Normalized key value. * * @return {boolean} * True if |key| is in list of pressed keys. */ press(key) { return this.pressed.add(key); } /** * Remove |key| from the set of pressed keys. * * @param {string} key * Normalized key value. * * @return {boolean} * True if |key| was present before removal, false otherwise. */ release(key) { return this.pressed.delete(key); } }; /** * Input state not associated with a specific physical device. */ action.InputState.Null = class Null extends InputState { constructor() { super(); this.type = "none"; } }; /** * Input state associated with a pointer-type input device. * * @param {string} subtype * Kind of pointing device: mouse, pen, touch. * * @throws {InvalidArgumentError} * If subtype is undefined or an invalid pointer type. */ action.InputState.Pointer = class Pointer extends InputState { constructor(subtype) { super(); this.pressed = new Set(); assert.defined(subtype, error.pprint`Expected subtype to be defined, got: ${subtype}`); this.subtype = action.PointerType.get(subtype); this.x = 0; this.y = 0; } /** * Check whether |button| is pressed. * * @param {number} button * Positive integer that refers to a mouse button. * * @return {boolean} * True if |button| is in set of pressed buttons. */ isPressed(button) { assert.positiveInteger(button); return this.pressed.has(button); } /** * Add |button| to the set of pressed keys. * * @param {number} button * Positive integer that refers to a mouse button. * * @return {Set} * Set of pressed buttons. */ press(button) { assert.positiveInteger(button); return this.pressed.add(button); } /** * Remove |button| from the set of pressed buttons. * * @param {number} button * A positive integer that refers to a mouse button. * * @return {boolean} * True if |button| was present before removals, false otherwise. */ release(button) { assert.positiveInteger(button); return this.pressed.delete(button); } }; /** * Repesents an action for dispatch. Used in |action.Chain| and |action.Sequence|. * * @param {string} id * Input source ID. * @param {string} type * Action type: none, key, pointer. * @param {string} subtype * Action subtype: pause, keyUp, keyDown, pointerUp, pointerDown, pointerMove, pointerCancel. * * @throws {InvalidArgumentError} * If any parameters are undefined. */ action.Action = class { constructor(id, type, subtype) { if ([id, type, subtype].includes(undefined)) { throw new InvalidArgumentError("Missing id, type or subtype"); } for (let attr of [id, type, subtype]) { assert.string(attr, error.pprint`Expected string, got: ${attr}`); } this.id = id; this.type = type; this.subtype = subtype; }; toString() { return `[action ${this.type}]`; } /** * @param {?} actionSequence * Object representing sequence of actions from one input source. * @param {?} actionItem * Object representing a single action from |actionSequence|. * * @return {action.Action} * An action that can be dispatched; corresponds to |actionItem|. * * @throws {InvalidArgumentError} * If any |actionSequence| or |actionItem| attributes are invalid. * @throws {UnsupportedOperationError} * If |actionItem.type| is |pointerCancel|. */ static fromJson(actionSequence, actionItem) { let type = actionSequence.type; let id = actionSequence.id; let subtypes = ACTIONS[type]; if (!subtypes) { throw new InvalidArgumentError("Unknown type: " + type); } let subtype = actionItem.type; if (!subtypes.has(subtype)) { throw new InvalidArgumentError(`Unknown subtype for ${type} action: ${subtype}`); } let item = new action.Action(id, type, subtype); if (type === "pointer") { action.processPointerAction(id, action.PointerParameters.fromJson(actionSequence.parameters), item); } switch (item.subtype) { case action.KeyUp: case action.KeyDown: let key = actionItem.value; // TODO countGraphemes // TODO key.value could be a single code point like "\uE012" (see rawKey) // or "grapheme cluster" assert.string(key, error.pprint("Expected 'value' to be a string that represents single code point " + `or grapheme cluster, got: ${key}`)); item.value = key; break; case action.PointerDown: case action.PointerUp: assert.positiveInteger(actionItem.button, error.pprint`Expected 'button' (${actionItem.button}) to be >= 0`); item.button = actionItem.button; break; case action.PointerMove: item.duration = actionItem.duration; if (typeof item.duration != "undefined"){ assert.positiveInteger(item.duration, error.pprint`Expected 'duration' (${item.duration}) to be >= 0`); } item.origin = action.PointerOrigin.get(actionItem.origin); item.x = actionItem.x; if (typeof item.x != "undefined") { assert.integer(item.x, error.pprint`Expected 'x' (${item.x}) to be an Integer`); } item.y = actionItem.y; if (typeof item.y != "undefined") { assert.integer(item.y, error.pprint`Expected 'y' (${item.y}) to be an Integer`); } break; case action.PointerCancel: throw new UnsupportedOperationError(); break; case action.Pause: item.duration = actionItem.duration; if (typeof item.duration != "undefined") { assert.positiveInteger(item.duration, error.pprint`Expected 'duration' (${item.duration}) to be >= 0`); } break; } return item; } }; /** * Represents a series of ticks, specifying which actions to perform at each tick. */ action.Chain = class extends Array { toString() { return `[chain ${super.toString()}]`; } /** * @param {Array.} actions * Array of objects that each represent an action sequence. * * @return {action.Chain} * Transpose of |actions| such that actions to be performed in a single tick * are grouped together. * * @throws {InvalidArgumentError} * If |actions| is not an Array. */ static fromJson(actions) { assert.array(actions, error.pprint`Expected 'actions' to be an Array, got: ${actions}`); let actionsByTick = new action.Chain(); // TODO check that each actionSequence in actions refers to a different input ID for (let actionSequence of actions) { let inputSourceActions = action.Sequence.fromJson(actionSequence); for (let i = 0; i < inputSourceActions.length; i++) { // new tick if (actionsByTick.length < (i + 1)) { actionsByTick.push([]); } actionsByTick[i].push(inputSourceActions[i]); } } return actionsByTick; } }; /** * Represents one input source action sequence; this is essentially an |Array.|. */ action.Sequence = class extends Array { toString() { return `[sequence ${super.toString()}]`; } /** * @param {?} actionSequence * Object that represents a sequence action items for one input source. * * @return {action.Sequence} * Sequence of actions that can be dispatched. * * @throws {InvalidArgumentError} * If |actionSequence.id| is not a string or it's aleady mapped * to an |action.InputState} incompatible with |actionSequence.type|. * If |actionSequence.actions| is not an Array. */ static fromJson(actionSequence) { // used here to validate 'type' in addition to InputState type below let inputSourceState = InputState.fromJson(actionSequence); let id = actionSequence.id; assert.defined(id, "Expected 'id' to be defined"); assert.string(id, error.pprint`Expected 'id' to be a string, got: ${id}`); let actionItems = actionSequence.actions; assert.array(actionItems, error.pprint("Expected 'actionSequence.actions' to be an Array, " + `got: ${actionSequence.actions}`)); if (!action.inputStateMap.has(id)) { action.inputStateMap.set(id, inputSourceState); } else if (!action.inputStateMap.get(id).is(inputSourceState)) { throw new InvalidArgumentError( `Expected ${id} to be mapped to ${inputSourceState}, ` + `got: ${action.inputStateMap.get(id)}`); } let actions = new action.Sequence(); for (let actionItem of actionItems) { actions.push(action.Action.fromJson(actionSequence, actionItem)); } return actions; } }; /** * Represents parameters in an action for a pointer input source. * * @param {string=} pointerType * Type of pointing device. If the parameter is undefined, "mouse" is used. */ action.PointerParameters = class { constructor(pointerType = "mouse") { this.pointerType = action.PointerType.get(pointerType); } toString() { return `[pointerParameters ${this.pointerType}]`; } /** * @param {?} parametersData * Object that represents pointer parameters. * * @return {action.PointerParameters} * Validated pointer paramters. */ static fromJson(parametersData) { if (typeof parametersData == "undefined") { return new action.PointerParameters(); } else { return new action.PointerParameters(parametersData.pointerType); } } }; /** * Adds |pointerType| attribute to Action |act|. Helper function * for |action.Action.fromJson|. * * @param {string} id * Input source ID. * @param {action.PointerParams} pointerParams * Input source pointer parameters. * @param {action.Action} act * Action to be updated. * * @throws {InvalidArgumentError} * If |id| is already mapped to an |action.InputState| that is * not compatible with |act.type| or |pointerParams.pointerType|. */ action.processPointerAction = function processPointerAction(id, pointerParams, act) { if (action.inputStateMap.has(id) && action.inputStateMap.get(id).type !== act.type) { throw new InvalidArgumentError( `Expected 'id' ${id} to be mapped to InputState whose type is ` + `${action.inputStateMap.get(id).type}, got: ${act.type}`); } let pointerType = pointerParams.pointerType; if (action.inputStateMap.has(id) && action.inputStateMap.get(id).subtype !== pointerType) { throw new InvalidArgumentError( `Expected 'id' ${id} to be mapped to InputState whose subtype is ` + `${action.inputStateMap.get(id).subtype}, got: ${pointerType}`); } act.pointerType = pointerParams.pointerType; }; /** Collect properties associated with KeyboardEvent */ action.Key = class { constructor(rawKey) { this.key = NORMALIZED_KEY_LOOKUP[rawKey] || rawKey; this.code = KEY_CODE_LOOKUP[rawKey]; this.location = KEY_LOCATION_LOOKUP[rawKey] || 0; this.altKey = false; this.shiftKey = false; this.ctrlKey = false; this.metaKey = false; this.repeat = false; this.isComposing = false; // Prevent keyCode from being guessed in event.js; we don't want to use it anyway. this.keyCode = 0; } update(inputState) { this.altKey = inputState.alt; this.shiftKey = inputState.shift; this.ctrlKey = inputState.ctrl; this.metaKey = inputState.meta; } }; /** Collect properties associated with MouseEvent */ action.Mouse = class { constructor(type, button = 0) { this.type = type; assert.positiveInteger(button); this.button = button; this.buttons = 0; } update(inputState) { let allButtons = Array.from(inputState.pressed); this.buttons = allButtons.reduce((a, i) => a + Math.pow(2, i), 0); } }; /** * Dispatch a chain of actions over |chain.length| ticks. * * This is done by creating a Promise for each tick that resolves once all the * Promises for individual tick-actions are resolved. The next tick's actions are * not dispatched until the Promise for the current tick is resolved. * * @param {action.Chain} chain * Actions grouped by tick; each element in |chain| is a sequence of * actions for one tick. * @param {element.Store} seenEls * Element store. * @param {?} container * Object with |frame| attribute of type |nsIDOMWindow|. * * @return {Promise} * Promise for dispatching all actions in |chain|. */ action.dispatch = function(chain, seenEls, container) { let chainEvents = Task.spawn(function*() { for (let tickActions of chain) { yield action.dispatchTickActions( tickActions, action.computeTickDuration(tickActions), seenEls, container); } }); return chainEvents; }; /** * Dispatch sequence of actions for one tick. * * This creates a Promise for one tick that resolves once the Promise for each * tick-action is resolved, which takes at least |tickDuration| milliseconds. * The resolved set of events for each tick is followed by firing of pending DOM events. * * Note that the tick-actions are dispatched in order, but they may have different * durations and therefore may not end in the same order. * * @param {Array.} tickActions * List of actions for one tick. * @param {number} tickDuration * Duration in milliseconds of this tick. * @param {element.Store} seenEls * Element store. * @param {?} container * Object with |frame| attribute of type |nsIDOMWindow|. * * @return {Promise} * Promise for dispatching all tick-actions and pending DOM events. */ action.dispatchTickActions = function(tickActions, tickDuration, seenEls, container) { let pendingEvents = tickActions.map(toEvents(tickDuration, seenEls, container)); return Promise.all(pendingEvents).then(() => flushEvents(container)); }; /** * Compute tick duration in milliseconds for a collection of actions. * * @param {Array.} tickActions * List of actions for one tick. * * @return {number} * Longest action duration in |tickActions| if any, or 0. */ action.computeTickDuration = function(tickActions) { let max = 0; for (let a of tickActions) { let affectsWallClockTime = a.subtype == action.Pause || (a.type == "pointer" && a.subtype == action.PointerMove); if (affectsWallClockTime && a.duration) { max = Math.max(a.duration, max); } } return max; }; /** * Compute viewport coordinates of pointer target based on given origin. * * @param {action.Action} a * Action that specifies pointer origin and x and y coordinates of target. * @param {action.InputState} inputState * Input state that specifies current x and y coordinates of pointer. * @param {Map.=} center * Object representing x and y coordinates of an element center-point. * This is only used if |a.origin| is a web element reference. * * @return {Map.} * x and y coordinates of pointer destination. */ action.computePointerDestination = function(a, inputState, center = undefined) { let {x, y} = a; switch (a.origin) { case action.PointerOrigin.Viewport: break; case action.PointerOrigin.Pointer: x += inputState.x; y += inputState.y; break; default: // origin represents web element assert.defined(center); assert.in("x", center); assert.in("y", center); x += center.x; y += center.y; } return {"x": x, "y": y}; }; /** * Create a closure to use as a map from action definitions to Promise events. * * @param {number} tickDuration * Duration in milliseconds of this tick. * @param {element.Store} seenEls * Element store. * @param {?} container * Object with |frame| attribute of type |nsIDOMWindow|. * * @return {function(action.Action): Promise} * Function that takes an action and returns a Promise for dispatching * the event that corresponds to that action. */ function toEvents(tickDuration, seenEls, container) { return function (a) { let inputState = action.inputStateMap.get(a.id); switch (a.subtype) { case action.KeyUp: return dispatchKeyUp(a, inputState, container.frame); case action.KeyDown: return dispatchKeyDown(a, inputState, container.frame); case action.PointerDown: return dispatchPointerDown(a, inputState, container.frame); case action.PointerUp: return dispatchPointerUp(a, inputState, container.frame); case action.PointerMove: return dispatchPointerMove(a, inputState, tickDuration, seenEls, container); case action.PointerCancel: throw new UnsupportedOperationError(); case action.Pause: return dispatchPause(a, tickDuration); } }; } /** * Dispatch a keyDown action equivalent to pressing a key on a keyboard. * * @param {action.Action} a * Action to dispatch. * @param {action.InputState} inputState * Input state for this action's input source. * @param {nsIDOMWindow} win * Current window. * * @return {Promise} * Promise to dispatch at least a keydown event, and keypress if appropriate. */ function dispatchKeyDown(a, inputState, win) { return new Promise(resolve => { let keyEvent = new action.Key(a.value); keyEvent.repeat = inputState.isPressed(keyEvent.key); inputState.press(keyEvent.key); if (keyEvent.key in MODIFIER_NAME_LOOKUP) { inputState.setModState(keyEvent.key, true); } // Append a copy of |a| with keyUp subtype action.inputsToCancel.push(Object.assign({}, a, {subtype: action.KeyUp})); keyEvent.update(inputState); event.sendKeyDown(keyEvent.key, keyEvent, win); resolve(); }); } /** * Dispatch a keyUp action equivalent to releasing a key on a keyboard. * * @param {action.Action} a * Action to dispatch. * @param {action.InputState} inputState * Input state for this action's input source. * @param {nsIDOMWindow} win * Current window. * * @return {Promise} * Promise to dispatch a keyup event. */ function dispatchKeyUp(a, inputState, win) { return new Promise(resolve => { let keyEvent = new action.Key(a.value); if (!inputState.isPressed(keyEvent.key)) { resolve(); return; } if (keyEvent.key in MODIFIER_NAME_LOOKUP) { inputState.setModState(keyEvent.key, false); } inputState.release(keyEvent.key); keyEvent.update(inputState); event.sendKeyUp(keyEvent.key, keyEvent, win); resolve(); }); } /** * Dispatch a pointerDown action equivalent to pressing a pointer-device * button. * * @param {action.Action} a * Action to dispatch. * @param {action.InputState} inputState * Input state for this action's input source. * @param {nsIDOMWindow} win * Current window. * * @return {Promise} * Promise to dispatch at least a pointerdown event. */ function dispatchPointerDown(a, inputState, win) { return new Promise(resolve => { if (inputState.isPressed(a.button)) { resolve(); return; } inputState.press(a.button); // Append a copy of |a| with pointerUp subtype action.inputsToCancel.push(Object.assign({}, a, {subtype: action.PointerUp})); switch (inputState.subtype) { case action.PointerType.Mouse: let mouseEvent = new action.Mouse("mousedown", a.button); mouseEvent.update(inputState); event.synthesizeMouseAtPoint(inputState.x, inputState.y, mouseEvent, win); break; case action.PointerType.Pen: case action.PointerType.Touch: throw new UnsupportedOperationError("Only 'mouse' pointer type is supported"); break; default: throw new TypeError(`Unknown pointer type: ${inputState.subtype}`); } resolve(); }); } /** * Dispatch a pointerUp action equivalent to releasing a pointer-device * button. * * @param {action.Action} a * Action to dispatch. * @param {action.InputState} inputState * Input state for this action's input source. * @param {nsIDOMWindow} win * Current window. * * @return {Promise} * Promise to dispatch at least a pointerup event. */ function dispatchPointerUp(a, inputState, win) { return new Promise(resolve => { if (!inputState.isPressed(a.button)) { resolve(); return; } inputState.release(a.button); switch (inputState.subtype) { case action.PointerType.Mouse: let mouseEvent = new action.Mouse("mouseup", a.button); mouseEvent.update(inputState); event.synthesizeMouseAtPoint(inputState.x, inputState.y, mouseEvent, win); break; case action.PointerType.Pen: case action.PointerType.Touch: throw new UnsupportedOperationError("Only 'mouse' pointer type is supported"); default: throw new TypeError(`Unknown pointer type: ${inputState.subtype}`); } resolve(); }); } /** * Dispatch a pointerMove action equivalent to moving pointer device in a line. * * If the action duration is 0, the pointer jumps immediately to the target coordinates. * Otherwise, events are synthesized to mimic a pointer travelling in a discontinuous, * approximately straight line, with the pointer coordinates being updated around 60 * times per second. * * @param {action.Action} a * Action to dispatch. * @param {action.InputState} inputState * Input state for this action's input source. * @param {element.Store} seenEls * Element store. * @param {?} container * Object with |frame| attribute of type |nsIDOMWindow|. * * @return {Promise} * Promise to dispatch at least one pointermove event, as well as mousemove events * as appropriate. */ function dispatchPointerMove(a, inputState, tickDuration, seenEls, container) { const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); // interval between pointermove increments in ms, based on common vsync const fps60 = 17; return new Promise(resolve => { const start = Date.now(); const [startX, startY] = [inputState.x, inputState.y]; let target = action.computePointerDestination(a, inputState, getElementCenter(a.origin, seenEls, container)); const [targetX, targetY] = [target.x, target.y]; if (!inViewPort(targetX, targetY, container.frame)) { throw new MoveTargetOutOfBoundsError( `(${targetX}, ${targetY}) is out of bounds of viewport ` + `width (${container.frame.innerWidth}) and height (${container.frame.innerHeight})`); } const duration = typeof a.duration == "undefined" ? tickDuration : a.duration; if (duration === 0) { // move pointer to destination in one step performOnePointerMove(inputState, targetX, targetY, container.frame); resolve(); return; } const distanceX = targetX - startX; const distanceY = targetY - startY; const ONE_SHOT = Ci.nsITimer.TYPE_ONE_SHOT; let intermediatePointerEvents = Task.spawn(function* () { // wait |fps60| ms before performing first incremental pointer move yield new Promise(resolveTimer => timer.initWithCallback(resolveTimer, fps60, ONE_SHOT) ); let durationRatio = Math.floor(Date.now() - start) / duration; const epsilon = fps60 / duration / 10; while ((1 - durationRatio) > epsilon) { let x = Math.floor(durationRatio * distanceX + startX); let y = Math.floor(durationRatio * distanceY + startY); performOnePointerMove(inputState, x, y, container.frame); // wait |fps60| ms before performing next pointer move yield new Promise(resolveTimer => timer.initWithCallback(resolveTimer, fps60, ONE_SHOT)); durationRatio = Math.floor(Date.now() - start) / duration; } }); // perform last pointer move after all incremental moves are resolved and // durationRatio is close enough to 1 intermediatePointerEvents.then(() => { performOnePointerMove(inputState, targetX, targetY, container.frame); resolve(); }); }); } function performOnePointerMove(inputState, targetX, targetY, win) { if (targetX == inputState.x && targetY == inputState.y) { return; } switch (inputState.subtype) { case action.PointerType.Mouse: let mouseEvent = new action.Mouse("mousemove"); mouseEvent.update(inputState); //TODO both pointermove (if available) and mousemove event.synthesizeMouseAtPoint(targetX, targetY, mouseEvent, win); break; case action.PointerType.Pen: case action.PointerType.Touch: throw new UnsupportedOperationError("Only 'mouse' pointer type is supported"); default: throw new TypeError(`Unknown pointer type: ${inputState.subtype}`); } inputState.x = targetX; inputState.y = targetY; } /** * Dispatch a pause action equivalent waiting for |a.duration| milliseconds, or a * default time interval of |tickDuration|. * * @param {action.Action} a * Action to dispatch. * @param {number} tickDuration * Duration in milliseconds of this tick. * * @return {Promise} * Promise that is resolved after the specified time interval. */ function dispatchPause(a, tickDuration) { const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); let duration = typeof a.duration == "undefined" ? tickDuration : a.duration; return new Promise(resolve => timer.initWithCallback(resolve, duration, Ci.nsITimer.TYPE_ONE_SHOT) ); } // helpers /** * Force any pending DOM events to fire. * * @param {?} container * Object with |frame| attribute of type |nsIDOMWindow|. * * @return {Promise} * Promise to flush DOM events. */ function flushEvents(container) { return new Promise(resolve => container.frame.requestAnimationFrame(resolve)); } function capitalize(str) { assert.string(str); return str.charAt(0).toUpperCase() + str.slice(1); } function inViewPort(x, y, win) { assert.number(x, `Expected x to be finite number`); assert.number(y, `Expected y to be finite number`); // Viewport includes scrollbars if rendered. return !(x < 0 || y < 0 || x > win.innerWidth || y > win.innerHeight); } function getElementCenter(elementReference, seenEls, container) { if (element.isWebElementReference(elementReference)) { let uuid = elementReference[element.Key] || elementReference[element.LegacyKey]; let el = seenEls.get(uuid, container); return element.coordinates(el); } }