summaryrefslogtreecommitdiffstats
path: root/remote/shared/webdriver/Actions.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'remote/shared/webdriver/Actions.sys.mjs')
-rw-r--r--remote/shared/webdriver/Actions.sys.mjs2137
1 files changed, 2137 insertions, 0 deletions
diff --git a/remote/shared/webdriver/Actions.sys.mjs b/remote/shared/webdriver/Actions.sys.mjs
new file mode 100644
index 0000000000..e0d96a1ece
--- /dev/null
+++ b/remote/shared/webdriver/Actions.sys.mjs
@@ -0,0 +1,2137 @@
+/* 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/. */
+
+/* eslint no-dupe-keys:off */
+/* eslint-disable no-restricted-globals */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs",
+ assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs",
+ element: "chrome://remote/content/marionette/element.sys.mjs",
+ error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
+ event: "chrome://remote/content/marionette/event.sys.mjs",
+ keyData: "chrome://remote/content/shared/webdriver/KeyData.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+ pprint: "chrome://remote/content/shared/Format.sys.mjs",
+ Sleep: "chrome://remote/content/marionette/sync.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "logger", () =>
+ lazy.Log.get(lazy.Log.TYPES.MARIONETTE)
+);
+
+// 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.
+ *
+ * Typical usage is to construct an action chain and then dispatch it:
+ * const state = new action.State();
+ * const chain = new action.Chain.fromJSON(state, protocolData);
+ * await chain.dispatch(state, window);
+ *
+ * @namespace
+ */
+export const action = {};
+
+/** Map from normalized key value to UI Events modifier key name */
+const MODIFIER_NAME_LOOKUP = {
+ Alt: "alt",
+ Shift: "shift",
+ Control: "ctrl",
+ Meta: "meta",
+};
+
+/**
+ * State associated with actions
+ *
+ * Typically each top-level browsing context in a session should have a single State object
+ */
+action.State = class {
+ constructor(options = {}) {
+ const { specCompatPointerOrigin = true } = options;
+
+ /** Flag for WebDriver spec conforming pointer origin calculation. */
+ this.specCompatPointerOrigin = specCompatPointerOrigin;
+
+ /**
+ * A map between input ID and the device state for that input
+ * source, with one entry for each active input source.
+ *
+ * Maps string => InputSource
+ */
+ this.inputStateMap = new Map();
+
+ /**
+ * List of {@link 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.
+ */
+ this.inputsToCancel = new TickActions();
+
+ /**
+ * Map between string input id and numeric pointer id
+ */
+ this.pointerIdMap = new Map();
+ }
+
+ toString() {
+ return `[object ${this.constructor.name} ${JSON.stringify(this)}]`;
+ }
+
+ /**
+ * Reset state stored in this object.
+ * It is an error to use the State object after calling release().
+ *
+ * @param {WindowProxy} win Current window global.
+ */
+ async release(win) {
+ this.inputsToCancel.reverse();
+ await this.inputsToCancel.dispatch(this, win);
+ lazy.event.DoubleClickTracker.resetClick();
+ }
+
+ /**
+ * Get the state for a given input source.
+ *
+ * @param {string} id Input source id.
+ * @returns {InputSource} Input source state.
+ */
+ getInputSource(id) {
+ return this.inputStateMap.get(id);
+ }
+
+ /**
+ * Find or add state for an input source. The caller should verify
+ * that the returned state is the expected type.
+ *
+ * @param {string} id Input source id.
+ * @param {InputSource} newInputSource Input source state.
+ */
+ getOrAddInputSource(id, newInputSource) {
+ let inputSource = this.getInputSource(id);
+ if (inputSource === undefined) {
+ this.inputStateMap.set(id, newInputSource);
+ inputSource = newInputSource;
+ }
+ return inputSource;
+ }
+
+ /**
+ * Iterate over all input states of a given type
+ *
+ * @param {string} type Input source type name (e.g. "pointer").
+ * @returns {Iterator} Iterator over [id, input source].
+ */
+ *inputSourcesByType(type) {
+ for (const [id, inputSource] of this.inputStateMap) {
+ if (inputSource.type === type) {
+ yield [id, inputSource];
+ }
+ }
+ }
+
+ /**
+ * Get a numerical pointer id for a given pointer
+ *
+ * Pointer ids are positive integers. Mouse pointers are typically
+ * ids 0 or 1. Non-mouse pointers never get assigned id < 2. Each
+ * pointer gets a unique id.
+ *
+ * @param {string} id Pointer id.
+ * @param {string} type Pointer type.
+ * @returns {number} Numerical pointer id.
+ */
+ getPointerId(id, type) {
+ let pointerId = this.pointerIdMap.get(id);
+ if (pointerId === undefined) {
+ // Reserve pointer ids 0 and 1 for mouse pointers
+ const idValues = Array.from(this.pointerIdMap.values());
+ if (type === "mouse") {
+ for (const mouseId of [0, 1]) {
+ if (!idValues.includes(mouseId)) {
+ pointerId = mouseId;
+ break;
+ }
+ }
+ }
+ if (pointerId === undefined) {
+ pointerId = Math.max(1, ...idValues) + 1;
+ }
+ this.pointerIdMap.set(id, pointerId);
+ }
+ return pointerId;
+ }
+};
+
+/**
+ * Device state for an input source.
+ */
+class InputSource {
+ #id;
+ static type = null;
+
+ constructor(id) {
+ this.#id = id;
+ this.type = this.constructor.type;
+ }
+
+ toString() {
+ return `[object ${this.constructor.name} id: ${this.#id} type: ${
+ this.type
+ }]`;
+ }
+
+ /**
+ * @param {State} state Actions state.
+ * @param {Sequence} actionSequence Actions for a specific input source.
+ *
+ * @returns {InputSource}
+ * An {@link InputSource} object for the type of the
+ * {@link actionSequence}.
+ *
+ * @throws {InvalidArgumentError}
+ * If {@link actionSequence.type} is not valid.
+ */
+ static fromJSON(state, actionSequence) {
+ const { id, type } = actionSequence;
+ lazy.assert.string(
+ id,
+ lazy.pprint`Expected 'id' to be a string, got ${id}`
+ );
+ const cls = inputSourceTypes.get(type);
+ if (cls === undefined) {
+ throw new lazy.error.InvalidArgumentError(
+ lazy.pprint`Unknown action type: ${type}`
+ );
+ }
+
+ const sequenceInputSource = cls.fromJSON(state, actionSequence);
+ const inputSource = state.getOrAddInputSource(id, sequenceInputSource);
+ if (inputSource.type !== type) {
+ throw new lazy.error.InvalidArgumentError(
+ `Expected input source ${id} to be type ${inputSource.type}, ` +
+ `got ${type}`
+ );
+ }
+ }
+}
+
+/**
+ * Input state not associated with a specific physical device.
+ */
+class NullInputSource extends InputSource {
+ static type = "none";
+
+ static fromJSON(state, actionSequence) {
+ const { id } = actionSequence;
+ return new this(id);
+ }
+}
+
+/**
+ * Input state associated with a keyboard-type device.
+ */
+class KeyInputSource extends InputSource {
+ static type = "key";
+
+ constructor(id) {
+ super(id);
+ this.pressed = new Set();
+ this.alt = false;
+ this.shift = false;
+ this.ctrl = false;
+ this.meta = false;
+ }
+
+ static fromJSON(state, actionSequence) {
+ const { id } = actionSequence;
+ return new this(id);
+ }
+
+ /**
+ * 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 lazy.error.InvalidArgumentError(
+ "Expected 'key' to be one of " +
+ Object.keys(MODIFIER_NAME_LOOKUP) +
+ lazy.pprint`, got ${key}`
+ );
+ }
+ }
+
+ /**
+ * Check whether |key| is pressed.
+ *
+ * @param {string} key
+ * Normalized key value.
+ *
+ * @returns {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.
+ *
+ * @returns {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.
+ *
+ * @returns {boolean}
+ * True if |key| was present before removal, false otherwise.
+ */
+ release(key) {
+ return this.pressed.delete(key);
+ }
+}
+
+/**
+ * Input state associated with a pointer-type device.
+ */
+class PointerInputSource extends InputSource {
+ static type = "pointer";
+
+ /**
+ * @param {string} id InputSource id.
+ * @param {Pointer} pointer Object representing the specific pointer
+ * type associated with this input source.
+ */
+ constructor(id, pointer) {
+ super(id);
+ this.pointer = pointer;
+ this.x = 0;
+ this.y = 0;
+ this.pressed = new Set();
+ }
+
+ /**
+ * Check whether |button| is pressed.
+ *
+ * @param {number} button
+ * Positive integer that refers to a mouse button.
+ *
+ * @returns {boolean}
+ * True if |button| is in set of pressed buttons.
+ */
+ isPressed(button) {
+ lazy.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.
+ *
+ * @returns {Set}
+ * Set of pressed buttons.
+ */
+ press(button) {
+ lazy.assert.positiveInteger(button);
+ this.pressed.add(button);
+ }
+
+ /**
+ * Remove |button| from the set of pressed buttons.
+ *
+ * @param {number} button
+ * A positive integer that refers to a mouse button.
+ *
+ * @returns {boolean}
+ * True if |button| was present before removals, false otherwise.
+ */
+ release(button) {
+ lazy.assert.positiveInteger(button);
+ return this.pressed.delete(button);
+ }
+
+ static fromJSON(state, actionSequence) {
+ const { id, parameters } = actionSequence;
+
+ const pointerType = parameters?.pointerType ?? "mouse";
+ const pointerId = state.getPointerId(id, pointerType);
+ const pointer = Pointer.fromJSON(pointerId, pointerType);
+ return new this(id, pointer);
+ }
+}
+
+/**
+ * Input state associated with a wheel-type device.
+ */
+class WheelInputSource extends InputSource {
+ static type = "wheel";
+
+ static fromJSON(state, actionSequence) {
+ const { id } = actionSequence;
+ return new this(id);
+ }
+}
+
+const inputSourceTypes = new Map();
+for (const cls of [
+ NullInputSource,
+ KeyInputSource,
+ PointerInputSource,
+ WheelInputSource,
+]) {
+ inputSourceTypes.set(cls.type, cls);
+}
+
+/**
+ * Representation of a coordinate origin
+ */
+class Origin {
+ /**
+ * Viewport coordinates of the origin of this coordinate system.
+ *
+ * This is overridden in subclasses to provide a class-specific origin.
+ *
+ * @param {State} state - Actions state.
+ * @param {InputSource} inputSource - State of current input device.
+ * @param {WindowProxy} win - Current window global
+ */
+ getOriginCoordinates(state, inputSource, win) {
+ throw new Error(
+ `originCoordinates not defined for ${this.constructor.name}`
+ );
+ }
+
+ /**
+ * Convert [x, y] coordinates to viewport coordinates
+ *
+ * @param {State} state - Actions state
+ * @param {InputSource} inputSource - State of the current input device
+ * @param {Array<number>} coords - [x, y] coordinate of target relative to origin
+ * @param {WindowProxy} win - Current window global
+ */
+ getTargetCoordinates(state, inputSource, coords, win) {
+ const [x, y] = coords;
+ const origin = this.getOriginCoordinates(state, inputSource, win);
+ return [origin.x + x, origin.y + y];
+ }
+
+ /**
+ * @param {Element|string=} origin - Type of orgin, one of "viewport", "pointer", element or undefined.
+ *
+ * @returns {Origin} - An origin object representing the origin.
+ *
+ * @throws {InvalidArgumentError}
+ * If <code>origin</code> isn't a valid origin.
+ */
+ static fromJSON(origin) {
+ if (origin === undefined || origin === "viewport") {
+ return new ViewportOrigin();
+ }
+ if (origin === "pointer") {
+ return new PointerOrigin();
+ }
+ if (lazy.element.isElement(origin)) {
+ return new ElementOrigin(origin);
+ }
+
+ throw new lazy.error.InvalidArgumentError(
+ `Expected 'origin' to be undefined, "viewport", "pointer", ` +
+ lazy.pprint`or an element, got: ${origin}`
+ );
+ }
+}
+
+class ViewportOrigin extends Origin {
+ getOriginCoordinates(state, inputSource, win) {
+ return { x: 0, y: 0 };
+ }
+}
+
+class PointerOrigin extends Origin {
+ getOriginCoordinates(state, inputSource, win) {
+ return { x: inputSource.x, y: inputSource.y };
+ }
+}
+
+class ElementOrigin extends Origin {
+ /**
+ * @param {Element} element - The element providing the coordinate origin.
+ */
+ constructor(element) {
+ super();
+ this.element = element;
+ }
+
+ getOriginCoordinates(state, inputSource, win) {
+ if (state.specCompatPointerOrigin) {
+ const clientRects = this.element.getClientRects();
+ // The spec doesn't handle this case; https://github.com/w3c/webdriver/issues/1642
+ if (!clientRects.length) {
+ throw new lazy.error.MoveTargetOutOfBoundsError(
+ `Origin element is not displayed`
+ );
+ }
+ return lazy.element.getInViewCentrePoint(clientRects[0], win);
+ }
+ return lazy.element.coordinates(this.element);
+ }
+}
+
+/**
+ * Repesents the behaviour of a single input source at a single
+ * point in time.
+ *
+ * @param {string} id - Input source ID.
+ */
+class Action {
+ /** Type of the input source associated with this action */
+ static type = null;
+ /** Type of action specific to the input source */
+ static subtype = null;
+ /** Whether this kind of action affects the overall duration of a tick */
+ affectsWallClockTime = false;
+
+ constructor(id) {
+ this.id = id;
+ this.type = this.constructor.type;
+ this.subtype = this.constructor.subtype;
+ }
+
+ toString() {
+ return `[${this.constructor.name} ${this.type}:${this.subtype}]`;
+ }
+
+ /**
+ * Dispatch the action to the relevant window.
+ *
+ * This is overridden by subclasses to implement the type-specific
+ * dispatch of the action.
+ *
+ * @param {State} state - Actions state.
+ * @param {InputSource} inputSource - State of the current input device.
+ * @param {number} tickDuration - Length of the current tick, in ms.
+ * @param {WindowProxy} win - Current window global.
+ * @returns {Promise} - Promise that is resolved once the action is complete.
+ */
+ dispatch(state, inputSource, tickDuration, win) {
+ throw new Error(
+ `Action subclass ${this.constructor.name} must override dispatch()`
+ );
+ }
+
+ /**
+ * @param {string} type - Input source type.
+ * @param {string} id - Input source id.
+ * @param {object} actionItem - Object representing a single action.
+ *
+ * @returns {Action} - An action that can be dispatched.
+ *
+ * @throws {InvalidArgumentError}
+ * If any <code>actionSequence</code> or <code>actionItem</code>
+ * attributes are invalid.
+ */
+ static fromJSON(type, id, actionItem) {
+ const subtype = actionItem.type;
+ const subtypeMap = actionTypes.get(type);
+ if (subtypeMap === undefined) {
+ throw new lazy.error.InvalidArgumentError(`Unknown action type: ${type}`);
+ }
+ let cls = subtypeMap.get(subtype);
+ // Non-device specific actions can happen for any action type
+ if (cls === undefined) {
+ cls = actionTypes.get("none").get(subtype);
+ }
+ if (cls === undefined) {
+ throw new lazy.error.InvalidArgumentError(
+ `Unknown subtype ${subtype} for type ${type}`
+ );
+ }
+ return cls.fromJSON(id, actionItem);
+ }
+}
+
+/**
+ * Action not associated with a specific input device.
+ */
+class NullAction extends Action {
+ static type = "none";
+}
+
+/**
+ * Action that waits for a given duration.
+ *
+ * @param {string} id - Input source ID.
+ * @param {object} options - Named arguments.
+ * @param {number} options.duration - Time to pause, in ms.
+ */
+class PauseAction extends NullAction {
+ static subtype = "pause";
+ affectsWallClockTime = true;
+
+ constructor(id, options) {
+ super(id);
+ const { duration } = options;
+ this.duration = duration;
+ }
+
+ /**
+ * Dispatch pause action
+ *
+ * @param {State} state - Actions state.
+ * @param {InputSource} inputSource - State of the current input device.
+ * @param {number} tickDuration - Length of the current tick, in ms.
+ * @param {WindowProxy} win - Current window global.
+ * @returns {Promise} - Promise that is resolved once the action is complete.
+ */
+ dispatch(state, inputSource, tickDuration, win) {
+ const ms = this.duration ?? tickDuration;
+ lazy.logger.trace(
+ ` Dispatch ${this.constructor.name} with ${this.id} ${ms}`
+ );
+ return lazy.Sleep(ms);
+ }
+
+ static fromJSON(id, actionItem) {
+ const { duration } = actionItem;
+ if (duration !== undefined) {
+ lazy.assert.positiveInteger(
+ duration,
+ lazy.pprint`Expected 'duration' (${duration}) to be >= 0`
+ );
+ }
+ return new this(id, { duration });
+ }
+}
+
+/**
+ * Action associated with a keyboard input device
+ *
+ * @param {string} id - Input source ID.
+ * @param {object} options - Named arguments.
+ * @param {string} options.value - Key character.
+ */
+class KeyAction extends Action {
+ static type = "key";
+
+ constructor(id, options) {
+ super(id);
+ const { value } = options;
+ this.value = value;
+ }
+
+ getEventData(inputSource) {
+ let value = this.value;
+ if (inputSource.shift) {
+ value = lazy.keyData.getShiftedKey(value);
+ }
+ return new KeyEventData(value);
+ }
+
+ static fromJSON(id, actionItem) {
+ // TODO countGraphemes
+ // TODO key.value could be a single code point like "\uE012"
+ // (see rawKey) or "grapheme cluster"
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=1496323
+ const value = actionItem.value;
+ lazy.assert.string(
+ value,
+ "Expected 'value' to be a string that represents single code point " +
+ lazy.pprint`or grapheme cluster, got ${value}`
+ );
+ return new this(id, { value });
+ }
+}
+
+/**
+ * Action equivalent to pressing a key on a keyboard.
+ *
+ * @param {string} id - Input source ID.
+ * @param {string} value - Key character.
+ */
+class KeyDownAction extends KeyAction {
+ static subtype = "keyDown";
+
+ dispatch(state, inputSource, tickDuration, win) {
+ lazy.logger.trace(
+ `Dispatch ${this.constructor.name} with ${this.id} ${this.value}`
+ );
+ return new Promise(resolve => {
+ const keyEvent = this.getEventData(inputSource);
+ keyEvent.repeat = inputSource.isPressed(keyEvent.key);
+ inputSource.press(keyEvent.key);
+ if (keyEvent.key in MODIFIER_NAME_LOOKUP) {
+ inputSource.setModState(keyEvent.key, true);
+ }
+
+ // Append a copy of |a| with keyUp subtype
+ state.inputsToCancel.push(new KeyUpAction(this.id, this));
+ keyEvent.update(state, inputSource);
+ lazy.event.sendKeyDown(keyEvent, win);
+
+ resolve();
+ });
+ }
+}
+
+/**
+ * Action equivalent to releasing a key on a keyboard.
+ *
+ * @param {string} id - Input source ID.
+ * @param {string} value - Key character.
+ */
+class KeyUpAction extends KeyAction {
+ static subtype = "keyUp";
+
+ dispatch(state, inputSource, tickDuration, win) {
+ lazy.logger.trace(
+ `Dispatch ${this.constructor.name} with ${this.id} ${this.value}`
+ );
+ return new Promise(resolve => {
+ const keyEvent = this.getEventData(inputSource);
+ if (!inputSource.isPressed(keyEvent.key)) {
+ resolve();
+ return;
+ }
+ if (keyEvent.key in MODIFIER_NAME_LOOKUP) {
+ inputSource.setModState(keyEvent.key, false);
+ }
+ inputSource.release(keyEvent.key);
+ keyEvent.update(state, inputSource);
+
+ lazy.event.sendKeyUp(keyEvent, win);
+ resolve();
+ });
+ }
+}
+
+/**
+ * Action associated with a pointer input device
+ *
+ * @param {string} id - Input source ID.
+ * @param {object} options - Named arguments.
+ * @param {number=} options.width - Pointer width in pixels.
+ * @param {number=} options.height - Pointer height in pixels.
+ * @param {number=} options.pressure - Pointer pressure.
+ * @param {number=} options.tangentialPressure - Pointer tangential pressure.
+ * @param {number=} options.tiltX - Pointer X tilt angle.
+ * @param {number=} options.tiltX - Pointer Y tilt angle.
+ * @param {number=} options.twist - Pointer twist angle.
+ * @param {number=} options.altitudeAngle - Pointer altitude angle.
+ * @param {number=} options.azimuthAngle - Pointer azimuth angle.
+ */
+class PointerAction extends Action {
+ static type = "pointer";
+
+ constructor(id, options) {
+ super(id);
+ const {
+ width,
+ height,
+ pressure,
+ tangentialPressure,
+ tiltX,
+ tiltY,
+ twist,
+ altitudeAngle,
+ azimuthAngle,
+ } = options;
+ this.width = width;
+ this.height = height;
+ this.pressure = pressure;
+ this.tangentialPressure = tangentialPressure;
+ this.tiltX = tiltX;
+ this.tiltY = tiltY;
+ this.twist = twist;
+ this.altitudeAngle = altitudeAngle;
+ this.azimuthAngle = azimuthAngle;
+ }
+
+ /**
+ * Validate properties common to all pointer types
+ *
+ * @param {object} actionItem - Object representing a single action.
+ */
+ static validateCommon(actionItem) {
+ const {
+ width,
+ height,
+ pressure,
+ tangentialPressure,
+ tiltX,
+ tiltY,
+ twist,
+ altitudeAngle,
+ azimuthAngle,
+ } = actionItem;
+ if (width !== undefined) {
+ lazy.assert.positiveInteger(
+ width,
+ lazy.pprint`Expected 'width' (${width}) to be >= 0`
+ );
+ }
+ if (height !== undefined) {
+ lazy.assert.positiveInteger(
+ height,
+ lazy.pprint`Expected 'height' (${height}) to be >= 0`
+ );
+ }
+ if (pressure !== undefined) {
+ lazy.assert.numberInRange(
+ pressure,
+ [0, 1],
+ lazy.pprint`Expected 'pressure' (${pressure}) to be in range 0 to 1`
+ );
+ }
+ if (tangentialPressure !== undefined) {
+ lazy.assert.numberInRange(
+ tangentialPressure,
+ [-1, 1],
+ lazy.pprint`Expected 'tangentialPressure' (${tangentialPressure}) to be in range -1 to 1`
+ );
+ }
+ if (tiltX !== undefined) {
+ lazy.assert.integerInRange(
+ tiltX,
+ [-90, 90],
+ lazy.pprint`Expected 'tiltX' (${tiltX}) to be in range -90 to 90`
+ );
+ }
+ if (tiltY !== undefined) {
+ lazy.assert.integerInRange(
+ tiltY,
+ [-90, 90],
+ lazy.pprint`Expected 'tiltY' (${tiltY}) to be in range -90 to 90`
+ );
+ }
+ if (twist !== undefined) {
+ lazy.assert.integerInRange(
+ twist,
+ [0, 359],
+ lazy.pprint`Expected 'twist' (${twist}) to be in range 0 to 359`
+ );
+ }
+ if (altitudeAngle !== undefined) {
+ lazy.assert.numberInRange(
+ altitudeAngle,
+ [0, Math.PI / 2],
+ lazy.pprint`Expected 'altitudeAngle' (${altitudeAngle}) to be in range 0 to ${
+ Math.PI / 2
+ }`
+ );
+ }
+ if (azimuthAngle !== undefined) {
+ lazy.assert.numberInRange(
+ azimuthAngle,
+ [0, 2 * Math.PI],
+ lazy.pprint`Expected 'azimuthAngle' (${azimuthAngle}) to be in range 0 to ${
+ 2 * Math.PI
+ }`
+ );
+ }
+ return {
+ width,
+ height,
+ pressure,
+ tangentialPressure,
+ tiltX,
+ tiltY,
+ twist,
+ altitudeAngle,
+ azimuthAngle,
+ };
+ }
+}
+
+/**
+ * Action associated with a pointer input device being depressed.
+ *
+ * @param {string} id - Input source ID.
+ * @param {object} options - Named arguments.
+ * @param {number} options.button - Button being pressed. For devices without buttons (e.g. touch), this should be 0.
+ * @param {number=} options.width - Pointer width in pixels.
+ * @param {number=} options.height - Pointer height in pixels.
+ * @param {number=} options.pressure - Pointer pressure.
+ * @param {number=} options.tangentialPressure - Pointer tangential pressure.
+ * @param {number=} options.tiltX - Pointer X tilt angle.
+ * @param {number=} options.tiltX - Pointer Y tilt angle.
+ * @param {number=} options.twist - Pointer twist angle.
+ * @param {number=} options.altitudeAngle - Pointer altitude angle.
+ * @param {number=} options.azimuthAngle - Pointer azimuth angle.
+ */
+class PointerDownAction extends PointerAction {
+ static subtype = "pointerDown";
+
+ constructor(id, options) {
+ super(id, options);
+ const { button } = options;
+ this.button = button;
+ }
+
+ dispatch(state, inputSource, tickDuration, win) {
+ lazy.logger.trace(
+ `Dispatch ${this.constructor.name} ${inputSource.pointer.type} with id: ${this.id} button: ${this.button}`
+ );
+ return new Promise(resolve => {
+ if (inputSource.isPressed(this.button)) {
+ resolve();
+ return;
+ }
+
+ inputSource.press(this.button);
+ // Append a copy of |a| with pointerUp subtype
+ state.inputsToCancel.push(new PointerUpAction(this.id, this));
+ inputSource.pointer.pointerDown(state, inputSource, this, win);
+ resolve();
+ });
+ }
+
+ static fromJSON(id, actionItem) {
+ const props = PointerAction.validateCommon(actionItem);
+ const { button } = actionItem;
+ lazy.assert.positiveInteger(
+ button,
+ lazy.pprint`Expected 'button' (${button}) to be >= 0`
+ );
+ props.button = button;
+ return new this(id, props);
+ }
+}
+
+/**
+ * Action associated with a pointer input device being released.
+ *
+ * @param {string} id - Input source ID.
+ * @param {object} options - Named arguments.
+ * @param {number} options.button - Button being released. For devices without buttons (e.g. touch), this should be 0.
+ * @param {number=} options.width - Pointer width in pixels.
+ * @param {number=} options.height - Pointer height in pixels.
+ * @param {number=} options.pressure - Pointer pressure.
+ * @param {number=} options.tangentialPressure - Pointer tangential pressure.
+ * @param {number=} options.tiltX - Pointer X tilt angle.
+ * @param {number=} options.tiltX - Pointer Y tilt angle.
+ * @param {number=} options.twist - Pointer twist angle.
+ * @param {number=} options.altitudeAngle - Pointer altitude angle.
+ * @param {number=} options.azimuthAngle - Pointer azimuth angle.
+ */
+class PointerUpAction extends PointerAction {
+ static subtype = "pointerUp";
+
+ constructor(id, options) {
+ super(id, options);
+ const { button } = options;
+ this.button = button;
+ }
+
+ dispatch(state, inputSource, tickDuration, win) {
+ lazy.logger.trace(
+ `Dispatch ${this.constructor.name} ${inputSource.pointer.type} with id: ${this.id} button: ${this.button}`
+ );
+ return new Promise(resolve => {
+ if (!inputSource.isPressed(this.button)) {
+ resolve();
+ return;
+ }
+
+ inputSource.release(this.button);
+ inputSource.pointer.pointerUp(state, inputSource, this, win);
+
+ resolve();
+ });
+ }
+
+ static fromJSON(id, actionItem) {
+ const props = PointerAction.validateCommon(actionItem);
+ const { button } = actionItem;
+ lazy.assert.positiveInteger(
+ button,
+ lazy.pprint`Expected 'button' (${button}) to be >= 0`
+ );
+ props.button = button;
+ return new this(id, props);
+ }
+}
+
+/**
+ * Action associated with a pointer input device being moved.
+ *
+ * @param {string} id - Input source ID.
+ * @param {object} options - Named arguments.
+ * @param {number=} options.width - Pointer width in pixels.
+ * @param {number=} options.height - Pointer height in pixels.
+ * @param {number=} options.pressure - Pointer pressure.
+ * @param {number=} options.tangentialPressure - Pointer tangential pressure.
+ * @param {number=} options.tiltX - Pointer X tilt angle.
+ * @param {number=} options.tiltX - Pointer Y tilt angle.
+ * @param {number=} options.twist - Pointer twist angle.
+ * @param {number=} options.altitudeAngle - Pointer altitude angle.
+ * @param {number=} options.azimuthAngle - Pointer azimuth angle.
+ * @param {number=} options.duration - Duration of move in ms.
+ * @param {Origin} options.origin - Origin of target coordinates.
+ * @param {number} options.x - X value of target coordinates.
+ * @param {number} options.y - Y value of target coordinates.
+ */
+class PointerMoveAction extends PointerAction {
+ static subtype = "pointerMove";
+ affectsWallClockTime = true;
+
+ constructor(id, options) {
+ super(id, options);
+ const { duration, origin, x, y } = options;
+ this.duration = duration;
+ this.origin = origin;
+ this.x = x;
+ this.y = y;
+ }
+
+ dispatch(state, inputSource, tickDuration, win) {
+ lazy.logger.trace(
+ `Dispatch ${this.constructor.name} ${inputSource.pointer.type} with id: ${this.id} x: ${this.x} y: ${this.y}`
+ );
+ const target = this.origin.getTargetCoordinates(
+ state,
+ inputSource,
+ [this.x, this.y],
+ win
+ );
+
+ assertInViewPort(target, win);
+
+ return moveOverTime(
+ [[inputSource.x, inputSource.y]],
+ [target],
+ this.duration ?? tickDuration,
+ target => this.performPointerMoveStep(state, inputSource, target, win)
+ );
+ }
+
+ /**
+ * Perform one part of a pointer move corresponding to a specific emitted event.
+ *
+ * @param {State} state - Actions state.
+ * @param {InputSource} inputSource - State of the current input device.
+ * @param {Array<Array<number>>} targets - Array of [x, y] arrays
+ * specifying the viewport coordinates to move to.
+ * @param {WindowProxy} win - Current window global.
+ */
+ performPointerMoveStep(state, inputSource, targets, win) {
+ if (targets.length !== 1) {
+ throw new Error(
+ "PointerMoveAction.performPointerMoveStep requires a single target"
+ );
+ }
+ const target = targets[0];
+ lazy.logger.trace(
+ `PointerMoveAction.performPointerMoveStep ${JSON.stringify(target)}`
+ );
+ if (target[0] == inputSource.x && target[1] == inputSource.y) {
+ return;
+ }
+
+ inputSource.pointer.pointerMove(
+ state,
+ inputSource,
+ this,
+ target[0],
+ target[1],
+ win
+ );
+
+ inputSource.x = target[0];
+ inputSource.y = target[1];
+ }
+
+ static fromJSON(id, actionItem) {
+ const props = PointerAction.validateCommon(actionItem);
+ const { duration, origin, x, y } = actionItem;
+ if (duration !== undefined) {
+ lazy.assert.positiveInteger(
+ duration,
+ lazy.pprint`Expected 'duration' (${duration}) to be >= 0`
+ );
+ }
+ const originObject = Origin.fromJSON(origin);
+ lazy.assert.integer(x, lazy.pprint`Expected 'x' (${x}) to be an Integer`);
+ lazy.assert.integer(y, lazy.pprint`Expected 'y' (${y}) to be an Integer`);
+ props.duration = duration;
+ props.origin = originObject;
+ props.x = x;
+ props.y = y;
+ return new this(id, props);
+ }
+}
+
+/**
+ * Action associated with a wheel input device
+ *
+ */
+class WheelAction extends Action {
+ static type = "wheel";
+}
+
+/**
+ * Action associated with scrolling a scroll wheel
+ *
+ * @param {number} duration - Duration of scroll in ms.
+ * @param {Origin} origin - Origin of target coordinates.
+ * @param {number} x - X value of scroll coordinates.
+ * @param {number} y - Y value of scroll coordinates.
+ * @param {number} deltaX - Number of CSS pixels to scroll in X direction.
+ * @param {number} deltaY - Number of CSS pixels to scroll in Y direction
+ */
+class WheelScrollAction extends WheelAction {
+ static subtype = "scroll";
+ affectsWallClockTime = true;
+
+ constructor(id, { duration, origin, x, y, deltaX, deltaY }) {
+ super(id);
+ this.duration = duration;
+ this.origin = origin;
+ this.x = x;
+ this.y = y;
+ this.deltaX = deltaX;
+ this.deltaY = deltaY;
+ }
+
+ static fromJSON(id, actionItem) {
+ const { duration, origin, x, y, deltaX, deltaY } = actionItem;
+ if (duration !== undefined) {
+ lazy.assert.positiveInteger(
+ duration,
+ lazy.pprint`Expected 'duration' (${duration}) to be >= 0`
+ );
+ }
+ const originObject = Origin.fromJSON(origin);
+ lazy.assert.integer(x, lazy.pprint`Expected 'x' (${x}) to be an Integer`);
+ lazy.assert.integer(y, lazy.pprint`Expected 'y' (${y}) to be an Integer`);
+ lazy.assert.integer(
+ deltaX,
+ lazy.pprint`Expected 'deltaX' (${deltaX}) to be an Integer`
+ );
+ lazy.assert.integer(
+ deltaY,
+ lazy.pprint`Expected 'deltaY' (${deltaY}) to be an Integer`
+ );
+
+ return new this(id, {
+ duration,
+ origin: originObject,
+ x,
+ y,
+ deltaX,
+ deltaY,
+ });
+ }
+
+ async dispatch(state, inputSource, tickDuration, win) {
+ lazy.logger.trace(
+ `Dispatch ${this.constructor.name} with id: ${this.id} deltaX: ${this.deltaX} deltaY: ${this.deltaY}`
+ );
+ const scrollCoordinates = this.origin.getTargetCoordinates(
+ state,
+ inputSource,
+ [this.x, this.y],
+ win
+ );
+ assertInViewPort(scrollCoordinates, win);
+
+ const startX = 0;
+ const startY = 0;
+ // This is an action-local state that holds the amount of scroll completed
+ const deltaPosition = [startX, startY];
+ await moveOverTime(
+ [[startX, startY]],
+ [[this.deltaX, this.deltaY]],
+ this.duration ?? tickDuration,
+ deltaTarget =>
+ this.performOneWheelScroll(
+ scrollCoordinates,
+ deltaPosition,
+ deltaTarget,
+ win
+ )
+ );
+ }
+
+ /**
+ * Perform one part of a wheel scroll corresponding to a specific emitted event.
+ *
+ * @param {Array<number>} scrollCoordinates - [x, y] viewport coordinates of the scroll.
+ * @param {Array<number>} deltaPosition - [deltaX, deltaY] coordinates of the scroll before this event.
+ * @param {Array<Array<number>>} deltaTargets - Array of [deltaX, deltaY] coordinates to scroll to.
+ * @param {WindowProxy} win - Current window global.
+ */
+ performOneWheelScroll(scrollCoordinates, deltaPosition, deltaTargets, win) {
+ if (deltaTargets.length !== 1) {
+ throw new Error("Can only scroll one wheel at a time");
+ }
+ if (deltaPosition[0] == this.deltaX && deltaPosition[1] == this.deltaY) {
+ return;
+ }
+ const deltaTarget = deltaTargets[0];
+ const deltaX = deltaTarget[0] - deltaPosition[0];
+ const deltaY = deltaTarget[1] - deltaPosition[1];
+ const eventData = new WheelEventData({
+ deltaX,
+ deltaY,
+ deltaZ: 0,
+ });
+ lazy.event.synthesizeWheelAtPoint(
+ scrollCoordinates[0],
+ scrollCoordinates[1],
+ eventData,
+ win
+ );
+
+ // Update the current scroll position for the caller
+ deltaPosition[0] = deltaTarget[0];
+ deltaPosition[1] = deltaTarget[1];
+ }
+}
+
+/**
+ * Group of actions representing behaviour of all touch pointers during a single tick.
+ *
+ * For touch pointers, we need to call into the platform once with all
+ * the actions so that they are regarded as simultaneous. This means
+ * we don't use the `dispatch()` method on the underlying actions, but
+ * instead use one on this group object.
+ */
+class TouchActionGroup {
+ static type = null;
+
+ constructor() {
+ this.type = this.constructor.type;
+ this.actions = new Map();
+ }
+
+ static forType(type) {
+ const cls = touchActionGroupTypes.get(type);
+ return new cls();
+ }
+
+ /**
+ * Add action corresponding to a specific pointer to the group.
+ *
+ * @param {InputSource} inputSource - State of the current input device.
+ * @param {Action} action - Action to add to the group
+ */
+ addPointer(inputSource, action) {
+ if (action.subtype !== this.type) {
+ throw new Error(
+ `Added action of unexpected type, got ${action.subtype}, expected ${this.type}`
+ );
+ }
+ this.actions.set(action.id, [inputSource, action]);
+ }
+
+ /**
+ * Dispatch the action group to the relevant window.
+ *
+ * This is overridden by subclasses to implement the type-specific
+ * dispatch of the action.
+ *
+ * @param {State} state - Actions state.
+ * @param {null} inputSource
+ * This is always null; the argument only exists for compatibility
+ * with {@link Action.dispatch}.
+ * @param {number} tickDuration - Length of the current tick, in ms.
+ * @param {WindowProxy} win - Current window global.
+ * @returns {Promise} - Promise that is resolved once the action is complete.
+ */
+ dispatch(state, inputSource, tickDuration, win) {
+ throw new Error(
+ "TouchActionGroup subclass missing dispatch implementation"
+ );
+ }
+}
+
+/**
+ * Group of actions representing behaviour of all touch pointers
+ * depressed during a single tick.
+ */
+class PointerDownTouchActionGroup extends TouchActionGroup {
+ static type = "pointerDown";
+
+ dispatch(state, inputSource, tickDuration, win) {
+ lazy.logger.trace(
+ `Dispatch ${this.constructor.name} with ${Array.from(
+ this.actions.values()
+ ).map(x => x[1].id)}`
+ );
+ return new Promise(resolve => {
+ if (inputSource !== null) {
+ throw new Error(
+ "Expected null inputSource for PointerDownTouchActionGroup.dispatch"
+ );
+ }
+
+ // Only include pointers that are not already depressed
+ const actions = Array.from(this.actions.values()).filter(
+ ([actionInputSource, action]) =>
+ !actionInputSource.isPressed(action.button)
+ );
+ if (actions.length) {
+ const eventData = new MultiTouchEventData("touchstart");
+ for (const [actionInputSource, action] of actions) {
+ // Skip if already pressed
+ eventData.addPointerEventData(actionInputSource, action);
+ actionInputSource.press(action.button);
+ // Append a copy of |action| with pointerUp subtype
+ state.inputsToCancel.push(new PointerUpAction(action.id, action));
+ eventData.update(state, actionInputSource);
+ }
+
+ // Touch start events must include all depressed touch pointers
+ for (const [id, pointerInputSource] of state.inputSourcesByType(
+ "pointer"
+ )) {
+ if (
+ pointerInputSource.pointer.type === "touch" &&
+ !this.actions.has(id) &&
+ pointerInputSource.isPressed(0)
+ ) {
+ eventData.addPointerEventData(pointerInputSource, {});
+ eventData.update(state, pointerInputSource);
+ }
+ }
+ lazy.event.synthesizeMultiTouch(eventData, win);
+ }
+ resolve();
+ });
+ }
+}
+
+/**
+ * Group of actions representing behaviour of all touch pointers
+ * released during a single tick.
+ */
+class PointerUpTouchActionGroup extends TouchActionGroup {
+ static type = "pointerUp";
+
+ dispatch(state, inputSource, tickDuration, win) {
+ lazy.logger.trace(
+ `Dispatch ${this.constructor.name} with ${Array.from(
+ this.actions.values()
+ ).map(x => x[1].id)}`
+ );
+ return new Promise(resolve => {
+ if (inputSource !== null) {
+ throw new Error(
+ "Expected null inputSource for PointerUpTouchActionGroup.dispatch"
+ );
+ }
+
+ // Only include pointers that are not already depressed
+ const actions = Array.from(this.actions.values()).filter(
+ ([actionInputSource, action]) =>
+ actionInputSource.isPressed(action.button)
+ );
+ if (actions.length) {
+ const eventData = new MultiTouchEventData("touchend");
+ for (const [actionInputSource, action] of actions) {
+ eventData.addPointerEventData(actionInputSource, action);
+ actionInputSource.release(action.button);
+ eventData.update(state, actionInputSource);
+ }
+ lazy.event.synthesizeMultiTouch(eventData, win);
+ }
+ resolve();
+ });
+ }
+}
+
+/**
+ * Group of actions representing behaviour of all touch pointers
+ * moved during a single tick.
+ */
+class PointerMoveTouchActionGroup extends TouchActionGroup {
+ static type = "pointerMove";
+
+ dispatch(state, inputSource, tickDuration, win) {
+ lazy.logger.trace(
+ `Dispatch ${this.constructor.name} with ${Array.from(this.actions).map(
+ x => x[1].id
+ )}`
+ );
+ if (inputSource !== null) {
+ throw new Error(
+ "Expected null inputSource for PointerMoveTouchActionGroup.dispatch"
+ );
+ }
+
+ let startCoords = [];
+ let targetCoords = [];
+ for (const [actionInputSource, action] of this.actions.values()) {
+ const target = action.origin.getTargetCoordinates(
+ state,
+ actionInputSource,
+ [action.x, action.y],
+ win
+ );
+ assertInViewPort(target, win);
+ startCoords.push([actionInputSource.x, actionInputSource.y]);
+ targetCoords.push(target);
+ }
+ // Touch move events must include all depressed touch pointers, even if they are static
+ // This can end up generating pointermove events even for static pointers, but Gecko
+ // seems to generate a lot of pointermove events anyway, so this seems like the lesser
+ // problem.
+ // See https://bugzilla.mozilla.org/show_bug.cgi?id=1779206
+ const staticTouchPointers = [];
+ for (const [id, pointerInputSource] of state.inputSourcesByType(
+ "pointer"
+ )) {
+ if (
+ pointerInputSource.pointer.type === "touch" &&
+ !this.actions.has(id) &&
+ pointerInputSource.isPressed(0)
+ ) {
+ staticTouchPointers.push(pointerInputSource);
+ }
+ }
+
+ return moveOverTime(
+ startCoords,
+ targetCoords,
+ this.duration ?? tickDuration,
+ currentTargetCoords =>
+ this.performPointerMoveStep(
+ state,
+ staticTouchPointers,
+ currentTargetCoords,
+ win
+ )
+ );
+ }
+
+ /**
+ * Perform one part of a pointer move corresponding to a specific emitted event.
+ *
+ * @param {State} state - Actions state.
+ * @param {Array.<PointerInputSource>} staticTouchPointers
+ * Array of PointerInputSource objects for pointers that aren't involved in
+ * the touch move.
+ * @param {Array.<Array>} targetCoords
+ * Array of [x, y] arrays specifying the viewport coordinates to move to.
+ * @param {WindowProxy} win - Current window global.
+ */
+ performPointerMoveStep(state, staticTouchPointers, targetCoords, win) {
+ if (targetCoords.length !== this.actions.size) {
+ throw new Error("Expected one target per pointer");
+ }
+
+ const perPointerData = Array.from(this.actions.values()).map(
+ ([inputSource, action], i) => {
+ const target = targetCoords[i];
+ return [inputSource, action, target];
+ }
+ );
+ const reachedTarget = perPointerData.every(
+ ([inputSource, action, target]) =>
+ target[0] === inputSource.x && target[1] === inputSource.y
+ );
+ if (reachedTarget) {
+ return;
+ }
+
+ const eventData = new MultiTouchEventData("touchmove");
+ for (const [inputSource, action, target] of perPointerData) {
+ inputSource.x = target[0];
+ inputSource.y = target[1];
+ eventData.addPointerEventData(inputSource, action);
+ eventData.update(state, inputSource);
+ }
+ for (const inputSource of staticTouchPointers) {
+ eventData.addPointerEventData(inputSource, {});
+ eventData.update(state, inputSource);
+ }
+ lazy.event.synthesizeMultiTouch(eventData, win);
+ }
+}
+
+const touchActionGroupTypes = new Map();
+for (const cls of [
+ PointerDownTouchActionGroup,
+ PointerUpTouchActionGroup,
+ PointerMoveTouchActionGroup,
+]) {
+ touchActionGroupTypes.set(cls.type, cls);
+}
+
+/**
+ * Split a transition from startCoord to targetCoord linearly over duration.
+ *
+ * startCoords and targetCoords are lists of [x,y] positions in some space
+ * (e.g. screen position or scroll delta). This function will linearly
+ * interpolate intermediate positions, sending out roughly one event
+ * per frame to simulate moving between startCoord and targetCoord in
+ * a time of tickDuration milliseconds. The callback function is
+ * responsible for actually emitting the event, given the current
+ * position in the coordinate space.
+ *
+ * @param {Array.<Array>} startCoords
+ * Array of initial [x, y] coordinates for each input source involved
+ * in the move.
+ * @param {Array.<Array>} targetCoords
+ * Array of target [x, y] coordinates for each input source involved
+ * in the move.
+ * @param {number} duration - Time in ms the move will take.
+ * @param {Function} callback
+ * Function that actually performs the move. This takes a single parameter
+ * which is an array of [x, y] coordinates corresponding to the move
+ * targets.
+ */
+async function moveOverTime(startCoords, targetCoords, duration, callback) {
+ lazy.logger.trace(
+ `moveOverTime start: ${startCoords} target: ${targetCoords} duration: ${duration}`
+ );
+
+ if (startCoords.length !== targetCoords.length) {
+ throw new Error(
+ "Expected equal number of start coordinates and target coordinates"
+ );
+ }
+
+ if (
+ !startCoords.every(item => item.length == 2) ||
+ !targetCoords.every(item => item.length == 2)
+ ) {
+ throw new Error(
+ "Expected start coordinates target coordinates to be Array of multiple [x,y] coordinates."
+ );
+ }
+
+ if (duration === 0) {
+ // transition to destination in one step
+ callback(targetCoords);
+ return;
+ }
+
+ const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ // interval between transitions in ms, based on common vsync
+ const fps60 = 17;
+
+ const distances = targetCoords.map((targetCoord, i) => {
+ const startCoord = startCoords[i];
+ return [targetCoord[0] - startCoord[0], targetCoord[1] - startCoord[1]];
+ });
+ const ONE_SHOT = Ci.nsITimer.TYPE_ONE_SHOT;
+ const startTime = Date.now();
+ const transitions = (async () => {
+ // wait |fps60| ms before performing first incremental transition
+ await new Promise(resolveTimer =>
+ timer.initWithCallback(resolveTimer, fps60, ONE_SHOT)
+ );
+
+ let durationRatio = Math.floor(Date.now() - startTime) / duration;
+ const epsilon = fps60 / duration / 10;
+ while (1 - durationRatio > epsilon) {
+ const intermediateTargets = startCoords.map((startCoord, i) => {
+ let distance = distances[i];
+ return [
+ Math.floor(durationRatio * distance[0] + startCoord[0]),
+ Math.floor(durationRatio * distance[1] + startCoord[1]),
+ ];
+ });
+ callback(intermediateTargets);
+ // wait |fps60| ms before performing next transition
+ await new Promise(resolveTimer =>
+ timer.initWithCallback(resolveTimer, fps60, ONE_SHOT)
+ );
+
+ durationRatio = Math.floor(Date.now() - startTime) / duration;
+ }
+ })();
+
+ await transitions;
+
+ // perform last transitionafter all incremental moves are resolved and
+ // durationRatio is close enough to 1
+ callback(targetCoords);
+}
+
+const actionTypes = new Map();
+for (const cls of [
+ KeyDownAction,
+ KeyUpAction,
+ PauseAction,
+ PointerDownAction,
+ PointerUpAction,
+ PointerMoveAction,
+ WheelScrollAction,
+]) {
+ if (!actionTypes.has(cls.type)) {
+ actionTypes.set(cls.type, new Map());
+ }
+ actionTypes.get(cls.type).set(cls.subtype, cls);
+}
+
+/**
+ * Implementation of the behaviour of a specific type of pointer
+ */
+class Pointer {
+ /** Type of pointer */
+ static type = null;
+
+ constructor(id) {
+ this.id = id;
+ this.type = this.constructor.type;
+ }
+
+ /**
+ * Implementation of depressing the pointer.
+ *
+ * @param {State} state - Actions state.
+ * @param {InputSource} inputSource - State of the current input device.
+ * @param {Action} action - The Action object invoking the pointer
+ * @param {WindowProxy} win - Current window global.
+ */
+ pointerDown(state, inputSource, action, win) {
+ throw new Error(`Unimplemented pointerDown for pointerType ${this.type}`);
+ }
+
+ /**
+ * Implementation of releasing the pointer.
+ *
+ * @param {State} state - Actions state.
+ * @param {InputSource} inputSource - State of the current input device.
+ * @param {Action} action - The Action object invoking the pointer
+ * @param {WindowProxy} win - Current window global.
+ */
+ pointerUp(state, inputSource, action, win) {
+ throw new Error(`Unimplemented pointerUp for pointerType ${this.type}`);
+ }
+
+ /**
+ * Implementation of moving the pointer.
+ *
+ * @param {State} state - Actions state.
+ * @param {InputSource} inputSource - State of the current input device.
+ * @param {number} targetX - Target X coordinate of the pointer move
+ * @param {number} targetY - Target Y coordinate of the pointer move
+ * @param {WindowProxy} win - Current window global.
+ */
+ pointerMove(state, inputSource, targetX, targetY, win) {
+ throw new Error(`Unimplemented pointerMove for pointerType ${this.type}`);
+ }
+
+ /**
+ * @param {number} pointerId - Numeric pointer id.
+ * @param {string} pointerType - Pointer type.
+ * @returns {Pointer} - The pointer class for {@link pointerType}
+ *
+ * @throws {InvalidArgumentError} - If {@link pointerType} is not a valid pointer type.
+ */
+ static fromJSON(pointerId, pointerType) {
+ const cls = pointerTypes.get(pointerType);
+ if (cls === undefined) {
+ throw new lazy.error.InvalidArgumentError(
+ `Unknown pointerType: ${pointerType}`
+ );
+ }
+ return new cls(pointerId);
+ }
+}
+
+/**
+ * Implementation of mouse pointer behaviour
+ */
+class MousePointer extends Pointer {
+ static type = "mouse";
+
+ pointerDown(state, inputSource, action, win) {
+ const mouseEvent = new MouseEventData("mousedown", {
+ button: action.button,
+ });
+ mouseEvent.update(state, inputSource);
+ if (mouseEvent.ctrlKey) {
+ if (lazy.AppInfo.isMac) {
+ mouseEvent.button = 2;
+ lazy.event.DoubleClickTracker.resetClick();
+ }
+ } else if (lazy.event.DoubleClickTracker.isClicked()) {
+ mouseEvent.clickCount = 2;
+ }
+ lazy.event.synthesizeMouseAtPoint(
+ inputSource.x,
+ inputSource.y,
+ mouseEvent,
+ win
+ );
+ if (
+ lazy.event.MouseButton.isSecondary(mouseEvent.button) ||
+ (mouseEvent.ctrlKey && lazy.AppInfo.isMac)
+ ) {
+ const contextMenuEvent = { ...mouseEvent, type: "contextmenu" };
+ lazy.event.synthesizeMouseAtPoint(
+ inputSource.x,
+ inputSource.y,
+ contextMenuEvent,
+ win
+ );
+ }
+ }
+
+ pointerUp(state, inputSource, action, win) {
+ const mouseEvent = new MouseEventData("mouseup", {
+ button: action.button,
+ });
+ mouseEvent.update(state, inputSource);
+ if (lazy.event.DoubleClickTracker.isClicked()) {
+ mouseEvent.clickCount = 2;
+ }
+ lazy.event.synthesizeMouseAtPoint(
+ inputSource.x,
+ inputSource.y,
+ mouseEvent,
+ win
+ );
+ }
+
+ pointerMove(state, inputSource, action, targetX, targetY, win) {
+ const mouseEvent = new MouseEventData("mousemove");
+ mouseEvent.update(state, inputSource);
+ lazy.event.synthesizeMouseAtPoint(targetX, targetY, mouseEvent, win);
+ }
+}
+
+/*
+ * The implementation here is empty because touch actions have to go via the
+ * TouchActionGroup. So if we end up calling these methods that's a bug in
+ * the code.
+ */
+class TouchPointer extends Pointer {
+ static type = "touch";
+}
+
+/*
+ * Placeholder for future pen type pointer support.
+ */
+class PenPointer extends Pointer {
+ static type = "pen";
+}
+
+const pointerTypes = new Map();
+for (const cls of [MousePointer, TouchPointer, PenPointer]) {
+ pointerTypes.set(cls.type, cls);
+}
+
+/**
+ * Represents a series of ticks, specifying which actions to perform at
+ * each tick.
+ */
+action.Chain = class extends Array {
+ toString() {
+ return `[chain ${super.toString()}]`;
+ }
+
+ /**
+ * Dispatch the action chain to the relevant window.
+ *
+ * @param {State} state - Actions state.
+ * @param {WindowProxy} win - Current window global.
+ * @returns {Promise} - Promise that is resolved once the action
+ * chain is complete.
+ */
+ dispatch(state, win) {
+ let i = 1;
+ const chainEvents = (async () => {
+ for (const tickActions of this) {
+ lazy.logger.trace(`Dispatching tick ${i++}/${this.length}`);
+ await tickActions.dispatch(state, win);
+ }
+ })();
+ return chainEvents;
+ }
+
+ /**
+ * @param {State} state - Actions state.
+ * @param {Array.<object>} actions - Array of objects that each
+ * represent an action sequence.
+ * @returns {action.Chain} - Object that allows dispatching a chain
+ * of actions.
+ * @throws {InvalidArgumentError} - If actions doesn't correspond to
+ * a valid action chain.
+ */
+ static fromJSON(state, actions) {
+ lazy.assert.array(
+ actions,
+ lazy.pprint`Expected 'actions' to be an array, got ${actions}`
+ );
+
+ const actionsByTick = new this();
+ for (const actionSequence of actions) {
+ const inputSourceActions = Sequence.fromJSON(state, actionSequence);
+ for (let i = 0; i < inputSourceActions.length; i++) {
+ // new tick
+ if (actionsByTick.length < i + 1) {
+ actionsByTick.push(new TickActions());
+ }
+ actionsByTick[i].push(inputSourceActions[i]);
+ }
+ }
+ return actionsByTick;
+ }
+};
+
+/**
+ * Represents the action for each input device to perform in a single tick.
+ */
+class TickActions extends Array {
+ /**
+ * Tick duration in milliseconds.
+ *
+ * @returns {number} - Longest action duration in |tickActions| if any, or 0.
+ */
+ getDuration() {
+ let max = 0;
+ for (const action of this) {
+ if (action.affectsWallClockTime && action.duration) {
+ max = Math.max(action.duration, max);
+ }
+ }
+ return max;
+ }
+
+ /**
+ * Dispatch sequence of actions for this 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 {State} state - Actions state.
+ * @param {WindowProxy} win - Current window global.
+ *
+ * @returns {Promise} - Promise that resolves when tick is complete.
+ */
+ dispatch(state, win) {
+ const tickDuration = this.getDuration();
+ const tickActions = this.groupTickActions(state);
+ const pendingEvents = tickActions.map(([inputSource, action]) =>
+ action.dispatch(state, inputSource, tickDuration, win)
+ );
+ return Promise.all(pendingEvents);
+ }
+
+ /**
+ * Group together actions from input sources that have to be
+ * dispatched together.
+ *
+ * The actual transformation here is to group together touch pointer
+ * actions into {@link TouchActionGroup} instances.
+ *
+ * @param {State} state - Actions state.
+ * @returns {Array.<Array.<InputSource?,Action|TouchActionGroup>>}
+ * Array of pairs. For ungrouped actions each element is
+ * [InputSource, Action] For touch actions there are multiple
+ * pointers handled at once, so the first item of the array is
+ * null, meaning the group has to perform its own handling of the
+ * relevant state, and the second element is a TouuchActionGroup.
+ */
+ groupTickActions(state) {
+ const touchActions = new Map();
+ const actions = [];
+ for (const action of this) {
+ const inputSource = state.getInputSource(action.id);
+ if (action.type == "pointer" && inputSource.pointer.type === "touch") {
+ lazy.logger.debug(
+ `Grouping action ${action.type} ${action.id} ${action.subtype}`
+ );
+ let group = touchActions.get(action.subtype);
+ if (group === undefined) {
+ group = TouchActionGroup.forType(action.subtype);
+ touchActions.set(action.subtype, group);
+ actions.push([null, group]);
+ }
+ group.addPointer(inputSource, action);
+ } else {
+ actions.push([inputSource, action]);
+ }
+ }
+ return actions;
+ }
+}
+
+/**
+ * Represents one input source action sequence; this is essentially an
+ * |Array.<Action>|.
+ *
+ * This is a temporary object only used when constructing an {@link
+ * action.Chain}.
+ */
+class Sequence extends Array {
+ toString() {
+ return `[sequence ${super.toString()}]`;
+ }
+
+ /**
+ * @param {State} state - Actions state.
+ * @param {object} actionSequence
+ * Protocol representation of the actions for a specific input source.
+ * @returns {Array.<Array>} - Array of [InputSource?,Action|TouchActionGroup]
+ */
+ static fromJSON(state, actionSequence) {
+ // used here to validate 'type' in addition to InputSource type below
+ const { id, type, actions } = actionSequence;
+
+ // type and id get validated in InputSource.fromJSON
+ lazy.assert.array(
+ actions,
+ "Expected 'actionSequence.actions' to be an array, " +
+ lazy.pprint`got ${actionSequence.actions}`
+ );
+
+ // This sets the input state in the global state map, if it's new
+ InputSource.fromJSON(state, actionSequence);
+
+ const sequence = new this();
+ for (const actionItem of actions) {
+ sequence.push(Action.fromJSON(type, id, actionItem));
+ }
+
+ return sequence;
+ }
+}
+
+/**
+ * Representation of an input event
+ */
+class InputEventData {
+ constructor() {
+ this.altKey = false;
+ this.shiftKey = false;
+ this.ctrlKey = false;
+ this.metaKey = false;
+ }
+
+ /**
+ * Update the input data based on global and input state
+ *
+ * @param {State} state - Actions state.
+ * @param {InputSource} inputSource - State of the current input device.
+ */
+ update(state, inputSource) {}
+
+ toString() {
+ return `${this.constructor.name} ${JSON.stringify(this)}`;
+ }
+}
+
+/**
+ * Representation of a key input event
+ *
+ * @param {string} rawKey - Key value.
+ */
+class KeyEventData extends InputEventData {
+ constructor(rawKey) {
+ super();
+ const { key, code, location, printable } = lazy.keyData.getData(rawKey);
+ this.key = key;
+ this.code = code;
+ this.location = location;
+ this.printable = printable;
+ this.repeat = false;
+ // keyCode will be computed by event.sendKeyDown
+ }
+
+ update(state, inputSource) {
+ this.altKey = inputSource.alt;
+ this.shiftKey = inputSource.shift;
+ this.ctrlKey = inputSource.ctrl;
+ this.metaKey = inputSource.meta;
+ }
+}
+
+/**
+ * Representation of a pointer input event
+ *
+ * @param {string} type - Event type.
+ */
+class PointerEventData extends InputEventData {
+ constructor(type) {
+ super();
+ this.type = type;
+ this.buttons = 0;
+ }
+
+ update(state, inputSource) {
+ // set modifier properties based on whether any corresponding keys are
+ // pressed on any key input source
+ for (const [, otherInputSource] of state.inputSourcesByType("key")) {
+ this.altKey = otherInputSource.alt || this.altKey;
+ this.ctrlKey = otherInputSource.ctrl || this.ctrlKey;
+ this.metaKey = otherInputSource.meta || this.metaKey;
+ this.shiftKey = otherInputSource.shift || this.shiftKey;
+ }
+ const allButtons = Array.from(inputSource.pressed);
+ this.buttons = allButtons.reduce((a, i) => a + Math.pow(2, i), 0);
+ }
+}
+
+/**
+ * Representation of a mouse input event
+ *
+ * @param {string} type - Event type.
+ * @param {number} button - Mouse button number.
+ */
+class MouseEventData extends PointerEventData {
+ constructor(type, options = {}) {
+ super(type);
+ const { button = 0 } = options;
+ lazy.assert.positiveInteger(button);
+ this.button = button;
+ this.buttons = 0;
+ }
+
+ update(state, inputSource) {
+ super.update(state, inputSource);
+ this.id = inputSource.pointer.id;
+ }
+}
+
+/**
+ * Representation of a wheel scroll event
+ *
+ * @param {object} options - Named arguments.
+ * @param {number} options.deltaX - Scroll delta X.
+ * @param {number} options.deltaY - Scroll delta Y.
+ * @param {number} options.deltaY - Scroll delta Z (current always 0).
+ * @param {number=} deltaMode - Scroll delta mode (current always 0).
+ */
+class WheelEventData extends InputEventData {
+ constructor(options) {
+ super();
+ const { deltaX, deltaY, deltaZ, deltaMode = 0 } = options;
+ this.deltaX = deltaX;
+ this.deltaY = deltaY;
+ this.deltaZ = deltaZ;
+ this.deltaMode = deltaMode;
+ }
+}
+
+/**
+ * Representation of a multitouch event
+ *
+ * @param {string} type - Event type.
+ */
+class MultiTouchEventData extends PointerEventData {
+ #setGlobalState;
+
+ constructor(type) {
+ super(type);
+ this.id = [];
+ this.x = [];
+ this.y = [];
+ this.rx = [];
+ this.ry = [];
+ this.angle = [];
+ this.force = [];
+ this.tiltx = [];
+ this.tilty = [];
+ this.twist = [];
+ this.#setGlobalState = false;
+ }
+
+ /**
+ * Add the data from one pointer to the event.
+ *
+ * @param {InputSource} inputSource - State of the pointer.
+ * @param {PointerAction} action - Action for the pointer.
+ */
+ addPointerEventData(inputSource, action) {
+ this.x.push(inputSource.x);
+ this.y.push(inputSource.y);
+ this.id.push(inputSource.pointer.id);
+ this.rx.push(action.width || 1);
+ this.ry.push(action.height || 1);
+ this.angle.push(0);
+ this.force.push(action.pressure || (this.type === "touchend" ? 0 : 1));
+ this.tiltx.push(action.tiltX || 0);
+ this.tilty.push(action.tiltY || 0);
+ this.twist.push(action.twist || 0);
+ }
+
+ update(state, inputSource) {
+ // We call update once per input source, but only want to update global state once.
+ // Instead of introducing a new lifecycle method, or changing the API to allow multiple
+ // input sources in a single call, use a small bit of state to avoid repeatedly setting
+ // global state.
+ if (!this.#setGlobalState) {
+ // set modifier properties based on whether any corresponding keys are
+ // pressed on any key input source
+ for (const [, otherInputSource] of state.inputSourcesByType("key")) {
+ this.altKey = otherInputSource.alt || this.altKey;
+ this.ctrlKey = otherInputSource.ctrl || this.ctrlKey;
+ this.metaKey = otherInputSource.meta || this.metaKey;
+ this.shiftKey = otherInputSource.shift || this.shiftKey;
+ }
+ this.#setGlobalState = true;
+ }
+
+ // Note that we currently emit Touch events that don't have this property
+ // but pointer events should have a `buttons` property, so we'll compute it
+ // anyway.
+ const allButtons = Array.from(inputSource.pressed);
+ this.buttons =
+ this.buttons | allButtons.reduce((a, i) => a + Math.pow(2, i), 0);
+ }
+}
+
+// helpers
+
+/**
+ * Assert that target is in the viewport of win.
+ *
+ * @param {Array.<number>} target - [x, y] coordinates of target
+ * relative to viewport.
+ * @param {WindowProxy} win - target window.
+ * @throws {MoveTargetOutOfBoundsError} - If target is outside the
+ * viewport.
+ */
+function assertInViewPort(target, win) {
+ const [x, y] = target;
+ lazy.assert.number(x, `Expected x to be finite number`);
+ lazy.assert.number(y, `Expected y to be finite number`);
+ // Viewport includes scrollbars if rendered.
+ if (x < 0 || y < 0 || x > win.innerWidth || y > win.innerHeight) {
+ throw new lazy.error.MoveTargetOutOfBoundsError(
+ `(${x}, ${y}) is out of bounds of viewport ` +
+ `width (${win.innerWidth}) ` +
+ `and height (${win.innerHeight})`
+ );
+ }
+}