fune/remote/shared/webdriver/Actions.sys.mjs
Natalia Csoregi 2d404ebada Backed out 8 changesets (bug 1830884, bug 1822466) for causing regressions in the upstream wpt tests. a=backout
Backed out changeset 7f4052a38bc6 (bug 1830884)
Backed out changeset 67d5d6a5f321 (bug 1830884)
Backed out changeset 77f0334c7976 (bug 1830884)
Backed out changeset 31607d74ee69 (bug 1830884)
Backed out changeset 256239106623 (bug 1822466)
Backed out changeset d94b6d6cd713 (bug 1822466)
Backed out changeset 2c6d325cb248 (bug 1822466)
Backed out changeset b89608b3c46a (bug 1822466)
2023-05-16 15:33:54 +03:00

2136 lines
62 KiB
JavaScript

/* 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})`
);
}
}