coherent-gameface-interaction-manager
Version:
Library for the most common UI interactions
1,503 lines (1,492 loc) • 86.6 kB
JavaScript
// interaction-manager/src/utils/global-object.js
var IM = class _IM2 {
// eslint-disable-next-line require-jsdoc
constructor() {
this.actions = [];
this.keyboardFunctions = [];
this.gamepadFunctions = [];
}
/**
* Initialize global object
*/
init() {
if (!window._IM)
window._IM = new _IM2();
}
/**
*
* @param {string[]} keys Array of key combinations
* @returns {string[]} Key combination from the _IM global object
*/
getKeys(keys) {
return _IM.keyboardFunctions.filter((keyFunction) => keyFunction.keys.every((key) => keys.includes(key)));
}
/**
*
* @param {string[]} keys Array of key combinations
* @returns {number} Index of key combination in _IM
*/
getKeysIndex(keys) {
return _IM.keyboardFunctions.findIndex((keyFunction) => keyFunction.keys.every((key) => keys.includes(key)));
}
/**
*
* @param {Array} actions Array of actions
* @param {string} type Type of action
* @returns {Object} Action from the _IM global object
*/
getGamepadAction({ actions, type }) {
return _IM.gamepadFunctions.find((gpFunc) => {
return gpFunc.actions.every((action) => actions.includes(action)) && gpFunc.type === type && gpFunc.actions.length === actions.length;
});
}
/**
*
* @param {Array} actions Array of actions
* @param {string} type Type of action
* @returns {Object} Action from the _IM global object
*/
getGamepadActions(actions) {
return _IM.gamepadFunctions.filter(
(gpFunc) => gpFunc.actions.every((action) => actions.includes(action)) && gpFunc.actions.length === actions.length
);
}
/**
*
* @param {Array} actions Array of actions
* @returns {number} Index of an action from the _IM global object
*/
getGamepadActionIndex(actions) {
return _IM.gamepadFunctions.findIndex((gpFunc) => gpFunc.actions.every((action) => actions.includes(action)));
}
/**
*
* @param {string} action Action to search for
* @returns {Object}
*/
getAction(action) {
return _IM.actions.find((actionObj) => actionObj.name === action);
}
/**
*
* @param {string} action Action to search for
* @returns {number}
*/
getActionIndex(action) {
return _IM.actions.findIndex((actionObj) => actionObj.name === action);
}
};
var global_object_default = new IM();
// interaction-manager/src/utils/keyboard-mappings.js
var keyboard_mappings_default = {
ALT: 18,
ARROW_DOWN: 40,
ARROW_LEFT: 37,
ARROW_RIGHT: 39,
ARROW_UP: 38,
BACKSPACE: 8,
CAPS_LOCK: 20,
CTRL: 17,
DELETE: 46,
END: 35,
ENTER: 13,
ESC: 27,
F1: 112,
F2: 113,
F3: 114,
F4: 115,
F5: 116,
F6: 117,
F7: 118,
F8: 119,
F9: 120,
F10: 121,
F11: 122,
F12: 123,
HOME: 36,
INSERT: 45,
NUM_LOCK: 144,
NUMPAD_ENTER: 13,
NUMPAD_DASH: 109,
NUMPAD_STAR: 106,
NUMPAD_DOT: 110,
NUMPAD_FORWARD_SLASH: 111,
NUMPAD_PLUS: 107,
NUMPAD_0: 96,
NUMPAD_1: 97,
NUMPAD_2: 98,
NUMPAD_3: 99,
NUMPAD_4: 100,
NUMPAD_5: 101,
NUMPAD_6: 102,
NUMPAD_7: 103,
NUMPAD_8: 104,
NUMPAD_9: 105,
PAGE_DOWN: 34,
PAGE_UP: 33,
PAUSE: 19,
PRINT_SCRN: 44,
SCROLL_LOCK: 145,
SHIFT: 16,
SPACEBAR: 32,
TAB: 9,
A: 65,
B: 66,
C: 67,
D: 68,
E: 69,
F: 70,
G: 71,
H: 72,
I: 73,
J: 74,
K: 75,
L: 76,
M: 77,
N: 78,
O: 79,
P: 80,
Q: 81,
R: 82,
S: 83,
T: 84,
U: 85,
V: 86,
W: 87,
X: 88,
Y: 89,
Z: 90,
1: 49,
2: 50,
3: 51,
4: 52,
5: 53,
6: 54,
7: 55,
8: 56,
9: 57,
0: 48,
QUOTE: 222,
DASH: 189,
COMMA: 188,
DOT: 190,
FORWARD_SLASH: 191,
SEMI_COLON: 186,
SQUARE_BRACKET_LEFT: 219,
SQUARE_BRACKET_RIGHT: 221,
BACKWARD_SLASH: 220,
BACKTICK: 192,
EQUAL: 187,
SYSTEM: 91
};
// interaction-manager/src/lib_components/actions.js
var Actions = class {
/**
* Register an action
* @param {string} action - Function alias that allows you to save functions and reuse them
* @param {function} callback
* @returns {void}
*/
register(action, callback) {
if (global_object_default.getAction(action))
return console.error(`The following action "${action}" is already registered!`);
_IM.actions.push({ name: action, callback });
}
/**
* Remove a registered action
* @param {string} action - Name of action you want to remove
* @returns {void}
*/
remove(action) {
const actionIndex = global_object_default.getActionIndex(action);
if (actionIndex === -1)
return console.error(`${action} is not a registered action!`);
_IM.actions.splice(actionIndex, 1);
}
/**
* Trigger an action
* @param {string} action - Name of action you want to execute
* @param {any} value - Value that is passed to the callback
* @returns {void}
*/
execute(action, value) {
const actionObject = global_object_default.getAction(action);
if (!actionObject)
return console.error(`${action} is not a registered action!`);
actionObject.callback(value);
}
};
var actions_default = new Actions();
// interaction-manager/src/lib_components/keyboard.js
var Keyboard = class {
/* eslint-disable-next-line require-jsdoc */
constructor() {
this.mappings = keyboard_mappings_default;
this.eventListenerAttached = false;
this.keysPressed = /* @__PURE__ */ new Set();
this.onKeyDown = this.onKeyDown.bind(this);
this.onKeyUp = this.onKeyUp.bind(this);
if (!window.KEYS)
window.KEYS = keyboard_mappings_default;
}
/**
* @param {Object} options
* @param {string[]} options.keys - Array of keys you want to use, allows only combination of modifier and regular keys
* @param {function | string} options.callback - Function or action to be executed on the key combination
* @param {string[]} options.type - Type of key action you want to use.
* @returns {void}
*/
on(options) {
options.keys = [
...new Set(
options.keys.map((key) => {
key = typeof key === "number" ? this.keyCodeToString(key) : key;
return key.toUpperCase();
})
)
];
const incorrectKeys = options.keys.filter((key) => !this.mappings[key]);
if (incorrectKeys.length > 0)
return console.error(`The following keys [${incorrectKeys.join(", ")}] you have entered are incorrect! `);
if (!this.eventListenerAttached) {
document.addEventListener("keydown", this.onKeyDown);
document.addEventListener("keyup", this.onKeyUp);
this.eventListenerAttached = true;
}
if (!Array.isArray(options.type))
options.type = [options.type];
options.type.forEach((type) => {
const registeredKeys = global_object_default.getKeys(options.keys);
if (registeredKeys.length > 0 && registeredKeys.some((key) => key.type === type)) {
return console.error("You are trying to overwrite an existing key combination! To do that, first remove it with .off([keys]) then add it again");
}
if (type === "lift" && options.keys.length > 1)
return console.error("You can only have a single key trigger an action on lift");
_IM.keyboardFunctions.push({ ...options, type });
});
}
/**
*
* @param {string[]} keys - Key combination you want to remove from the listener
* @returns {void}
*/
off(keys) {
keys = [
...new Set(
keys.map((key) => {
key = typeof key === "number" ? this.keyCodeToString(key) : key;
return key.toUpperCase();
})
)
];
let keyCombinationCount = global_object_default.getKeys(keys).length;
if (keyCombinationCount === 0)
return console.error("You are trying to remove a non-existent key combination!");
while (keyCombinationCount > 0) {
const keyCombinationIndex = global_object_default.getKeysIndex(keys);
_IM.keyboardFunctions.splice(keyCombinationIndex, 1);
keyCombinationCount--;
}
if (_IM.keyboardFunctions.length === 0) {
document.removeEventListener("keydown", this.onKeyDown);
document.removeEventListener("keyup", this.onKeyUp);
this.eventListenerAttached = false;
}
}
/**
* Handles when key is pressed
* @param {KeyboardEvent} event
* @returns {void}
* @private
*/
onKeyDown(event) {
const keyPressed = this.keyCodeToString(event.keyCode);
this.keysPressed.add(keyPressed);
const registeredKeys = global_object_default.getKeys([...this.keysPressed]);
if (registeredKeys.length === 0)
return;
registeredKeys.forEach((key) => {
if (key.type === "press" && event.repeat)
return;
if (key.type !== "press" && key.type !== "hold")
return;
if (key.type === "hold" && !event.repeat)
return;
this.executeCallback(event, key);
});
}
/**
* Handles when key is released
* @param {KeyboardEvent} event
* @returns {void}
* @private
*/
onKeyUp(event) {
const keyPressed = this.keyCodeToString(event.keyCode);
this.keysPressed.delete(keyPressed);
const registeredKeys = global_object_default.getKeys(keyPressed);
if (registeredKeys.length === 0)
return;
registeredKeys.forEach((key) => {
if (key.type === "lift" && key.keys.indexOf(keyPressed) !== -1)
this.executeCallback(event, key);
});
}
/**
* Convert keyCode to string representing key
* @param {number} code
* @returns {string}
* @private
*/
keyCodeToString(code) {
return Object.keys(this.mappings).find((key) => this.mappings[key] === code);
}
/**
* Executes the registered callbacks. Has to be invoked from the onKeyDown and onKeyUp functions
* @param {KeyboardEvent} event
* @param {Object} registeredKeys
* @param {string[]} registeredKeys.keys - Array of keys you want to use, allows only combination of modifier and regular keys
* @param {function | string} registeredKeys.callback - Function or action to be executed on the key combination
* @param {('press'|'hold'|'lift')} registeredKeys.type - Type of key action you want to use.
* @return {void}
* @private
*/
executeCallback(event, registeredKeys) {
if (typeof registeredKeys.callback === "string")
return actions_default.execute(registeredKeys.callback, event);
registeredKeys.callback(event);
}
};
var keyboard_default = new Keyboard();
// interaction-manager/src/utils/gamepad-mappings.js
var gamepad_mappings_default = {
FACE_BUTTON_DOWN: 0,
FACE_BUTTON_RIGHT: 1,
FACE_BUTTON_LEFT: 2,
FACE_BUTTON_TOP: 3,
LEFT_SHOULDER: 4,
RIGHT_SHOULDER: 5,
LEFT_SHOULDER_BOTTOM: 6,
RIGHT_SHOULDER_BOTTOM: 7,
SELECT: 8,
START: 9,
LEFT_ANALOGUE_STICK: 10,
RIGHT_ANALOGUE_STICK: 11,
PAD_UP: 12,
PAD_DOWN: 13,
PAD_LEFT: 14,
PAD_RIGHT: 15,
CENTER_BUTTON: 16,
aliases: {
"face-button-down": "FACE_BUTTON_DOWN",
"face-button-left": "FACE_BUTTON_LEFT",
"face-button-right": "FACE_BUTTON_RIGHT",
"face-button-top": "FACE_BUTTON_TOP",
"left-sholder": "LEFT_SHOULDER",
"right-sholder": "RIGHT_SHOULDER",
"left-sholder-bottom": "LEFT_SHOULDER_BOTTOM",
"right-sholder-bottom": "RIGHT_SHOULDER_BOTTOM",
select: "SELECT",
start: "START",
"left-analogue-stick": "LEFT_ANALOGUE_STICK",
"right-analogue-stick": "RIGHT_ANALOGUE_STICK",
"pad-up": "PAD_UP",
"pad-down": "PAD_DOWN",
"pad-left": "PAD_LEFT",
"pad-right": "PAD_RIGHT",
"center-button": "CENTER_BUTTON",
"playstation.x": "FACE_BUTTON_DOWN",
"playstation.square": "FACE_BUTTON_LEFT",
"playstation.circle": "FACE_BUTTON_RIGHT",
"playstation.triangle": "FACE_BUTTON_TOP",
"playstation.l1": "LEFT_SHOULDER",
"playstation.r1": "RIGHT_SHOULDER",
"playstation.l2": "LEFT_SHOULDER_BOTTOM",
"playstation.r2": "RIGHT_SHOULDER_BOTTOM",
"playstation.share": "SELECT",
"playstation.options": "START",
"playstation.l3": "LEFT_ANALOGUE_STICK",
"playstation.r3": "RIGHT_ANALOGUE_STICK",
"playstation.d-pad-up": "PAD_UP",
"playstation.d-pad-down": "PAD_DOWN",
"playstation.d-pad-left": "PAD_LEFT",
"playstation.d-pad-right": "PAD_RIGHT",
"playstation.center": "CENTER_BUTTON",
"xbox.a": "FACE_BUTTON_DOWN",
"xbox.x": "FACE_BUTTON_LEFT",
"xbox.b": "FACE_BUTTON_RIGHT",
"xbox.y": "FACE_BUTTON_TOP",
"xbox.lb": "LEFT_SHOULDER",
"xbox.rb": "RIGHT_SHOULDER",
"xbox.lt": "LEFT_SHOULDER_BOTTOM",
"xbox.rt": "RIGHT_SHOULDER_BOTTOM",
"xbox.view": "SELECT",
"xbox.menu": "START",
"xbox.left-thumbstick": "LEFT_ANALOGUE_STICK",
"xbox.right-thumbstick": "RIGHT_ANALOGUE_STICK",
"xbox.d-pad-up": "PAD_UP",
"xbox.d-pad-down": "PAD_DOWN",
"xbox.d-pad-left": "PAD_LEFT",
"xbox.d-pad-right": "PAD_RIGHT",
"xbox.center": "CENTER_BUTTON"
},
axisAliases: [
"right.joystick",
"left.joystick",
"left.joystick.down",
"left.joystick.up",
"left.joystick.left",
"left.joystick.right",
"right.joystick.down",
"right.joystick.up",
"right.joystick.left",
"right.joystick.right"
]
};
// interaction-manager/src/lib_components/gamepad.js
var AXIS_THRESHOLD = 0.9;
var ACTION_TYPES = ["press", "hold"];
var Gamepad = class {
// eslint-disable-next-line require-jsdoc
constructor() {
this.mappings = gamepad_mappings_default;
this.pollingStarted = false;
this.gamepadEnabled = false;
this.onGamepadConnected = this.onGamepadConnected.bind(this);
this.sanitizeAction = this.sanitizeAction.bind(this);
this.pollingInterval = 200;
this._pressedAction = null;
}
/**
* Allow gamepads to be connected
* @param {boolean} isEnabled
*/
set enabled(isEnabled) {
this.gamepadEnabled = isEnabled;
this.gamepadEnabled ? this.init() : this.deinit();
}
/**
* Attaches the event listeners for the gamepads
* @private
*/
init() {
window.addEventListener("gamepadconnected", this.onGamepadConnected);
}
/**
* Removes any attached event listeners for gamepads
* @private
*/
deinit() {
window.removeEventListener("gamepadconnected", this.onGamepadConnected);
}
/**
* Starts polling on the first connected
* @returns {void}
* @private
*/
onGamepadConnected() {
if (this.pollingStarted)
return;
this.pollingStarted = true;
this.startPolling();
}
/**
*
* @param {Object} options
* @param {string[] | number[]} options.actions - Action to trigger the callback. Can be name of button or joystick
* @param {'press' | 'hold'} options.type - The type of action to trigger the callback. The available options are hold and press.
* @param {function} options.callback - Callback to trigger on the set action
* @param {number} options.gamepadNumber - The number of the gamepad that you want to trigger the callback on. Use -1 for all gamepads
* @returns {void}
*/
on(options) {
options.actions = options.actions.map(this.sanitizeAction);
const isAxisAlias = this.mappings.axisAliases.some((alias) => options.actions.includes(alias));
if (!options.type || !ACTION_TYPES.includes(options.type))
options.type = "hold";
if (options.type === "press" && isAxisAlias) {
return console.error(`You can't use an axis action with a 'press' type!`);
}
if (options.actions.length > 1 && isAxisAlias) {
return console.error(`You can't use an axis action in a combination with a button action`);
}
if (global_object_default.getGamepadAction(options)) {
return console.error(
"You have already registered a callback for this action. If you want to overwrite it, remove it first with .off([actions])"
);
}
_IM.gamepadFunctions.push(options);
}
/**
* Removes registered actions
* @param {Array} actions - Array containing the action you want to remove
* @returns {void}
*/
off(actions) {
const actionsIndex = global_object_default.getGamepadActionIndex(actions.map(this.sanitizeAction));
if (actionsIndex === -1)
return console.error("You are trying to remove a non-existent action!");
_IM.gamepadFunctions.splice(actionsIndex, 1);
}
/**
* Loop that handles button presses and axis movement
* @returns {void}
* @private
*/
startPolling() {
const gamepads = navigator.getGamepads();
if (gamepads.length === 0) {
this.pollingStarted = false;
return;
}
gamepads.forEach((gamepad, index) => {
if (!gamepad)
return;
this.handleButtons(gamepad.buttons, index);
this.handleJoysticks(gamepad.axes);
});
setTimeout(this.startPolling.bind(this), this.pollingInterval);
}
/**
*
* @param {Object[]} buttons
* @private
*/
handleButtons(buttons) {
const pressedButtons = buttons.reduce(
(acc, el, index) => {
if (el.pressed) {
acc.buttonIndexes.push(index);
acc.buttons.push(el);
}
return acc;
},
{ buttonIndexes: [], buttons: [] }
);
const gamepadActions = global_object_default.getGamepadActions(pressedButtons.buttonIndexes);
if (!gamepadActions.length === 0)
return;
if (this._pressedAction) {
if (!gamepadActions.includes(this._pressedAction)) {
this.executeCallback(this._pressedAction, this._pressedAction.actions);
}
this._pressedAction = null;
}
gamepadActions.forEach((gamepadAction) => {
if (gamepadAction.type === "press") {
this._pressedAction = gamepadAction;
return;
}
this.executeCallback(gamepadAction, pressedButtons.buttons);
});
}
/* eslint-disable max-lines-per-function */
/**
*
* @param {number[]} axes
* @private
*/
handleJoysticks(axes) {
const joystickActions = this.getJoystickActions();
joystickActions.forEach((jAction) => {
switch (jAction.actions[0]) {
case "left.joystick":
return this.executeCallback(jAction, [axes[0], axes[1]]);
case "right.joystick":
return this.executeCallback(jAction, [axes[2], axes[3]]);
case "left.joystick.down":
if (axes[1] > AXIS_THRESHOLD)
return this.executeCallback(jAction, [axes[0], axes[1]]);
break;
case "left.joystick.up":
if (axes[1] < -AXIS_THRESHOLD)
return this.executeCallback(jAction, [axes[0], axes[1]]);
break;
case "left.joystick.left":
if (axes[0] < -AXIS_THRESHOLD)
return this.executeCallback(jAction, [axes[0], axes[1]]);
break;
case "left.joystick.right":
if (axes[0] > AXIS_THRESHOLD)
return this.executeCallback(jAction, [axes[0], axes[1]]);
break;
case "right.joystick.down":
if (axes[3] > AXIS_THRESHOLD)
return this.executeCallback(jAction, [axes[2], axes[3]]);
break;
case "right.joystick.up":
if (axes[3] < -AXIS_THRESHOLD)
return this.executeCallback(jAction, [axes[2], axes[3]]);
break;
case "right.joystick.left":
if (axes[2] < -AXIS_THRESHOLD)
return this.executeCallback(jAction, [axes[2], axes[3]]);
break;
case "right.joystick.right":
if (axes[2] > AXIS_THRESHOLD)
return this.executeCallback(jAction, [axes[2], axes[3]]);
break;
}
});
}
/* eslint-enable max-lines-per-function */
/**
* Convert button aliases to indexes or keep joystick aliases
* @param {string | number} action - Actions to convert
* @returns {string | number} - Converted action strings
* @private
*/
sanitizeAction(action) {
if (typeof action === "number")
return action;
if (this.mappings.axisAliases.includes(action.toLowerCase()))
return action.toLowerCase();
if (typeof action === "string") {
const key = this.mappings.aliases[action.toLowerCase()];
if (!key)
return console.error(`You have entered a non-supported button alias ${action}`);
return this.mappings[key];
}
return action;
}
/**
* Gets all registered Joystick actions
* @returns {Object[]} - Joystick actions
* @private
*/
getJoystickActions() {
return _IM.gamepadFunctions.filter((gpFunc) => this.mappings.axisAliases.includes(gpFunc.actions[0]));
}
/**
* Executes the callback from the registered action
* @param {Object} action
* @param {any} value
* @returns {void}
* @private
*/
executeCallback(action, value) {
if (typeof action.callback === "string")
return actions_default.execute(action.callback, value);
action.callback(value);
}
};
var gamepad_default = new Gamepad();
// interaction-manager/src/utils/utility-functions.js
function toDeg(rad) {
return rad * 180 / Math.PI;
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function createHash() {
return (Math.random() + 1).toString(36).substring(7);
}
function distanceBetweenTwoPoints(x1, y1, x2, y2) {
const a = x1 - x2;
const b = y1 - y2;
return Math.hypot(a, b);
}
function getMidPoint(x1, y1, x2, y2) {
return {
x: (x1 + x2) / 2,
y: (y1 + y2) / 2
};
}
// interaction-manager/src/lib_components/spatial-navigation.js
var directions = ["down", "up", "left", "right"];
var defaultKeysState = { up: ["arrow_up"], down: ["arrow_down"], right: ["arrow_right"], left: ["arrow_left"] };
var SpatialNavigation = class {
// eslint-disable-next-line require-jsdoc
constructor() {
this.enabled = false;
this.navigatableElements = { default: { elements: [], distance: 0, overflow: { x: 0, y: 0 } } };
this.registeredKeys = /* @__PURE__ */ new Set();
this.clearCurrentActiveKeys = false;
this.overlapPercentage = 0.5;
this.lastFocusedElement = null;
}
/**
* Initializes the spatial navigation
* @param {string[]|Object[]} navigatableElements
* @param {string} navigatableElements[].area
* @param {string[]} navigatableElements[].elements
* @param {number} overlap
* @returns {void}
*/
init(navigatableElements = [], overlap) {
if (this.enabled)
return;
this.enabled = true;
this.add(navigatableElements);
this.activeKeys = JSON.parse(JSON.stringify(defaultKeysState));
this.registerKeyActions();
if (overlap && 0 <= overlap && overlap <= 1) {
this.overlapPercentage = overlap;
}
}
/**
* Deinitialize the spatial navigation
* @returns {void}
*/
deinit() {
if (!this.enabled)
return;
this.enabled = false;
this.navigatableElements = { default: { elements: [], distance: 0, overflow: { x: 0, y: 0 } } };
this.removeKeyActions();
this.overlapPercentage = 0.5;
this.lastFocusedElement = null;
}
/**
* Add new elements to area or new area
* @param {string[]|Object[]} navigatableElements
* @param {string} navigatableElements[].area
* @param {string[]} navigatableElements[].elements
*/
add(navigatableElements) {
if (!this.enabled)
return;
navigatableElements.forEach((navArea) => {
typeof navArea === "string" ? this.handleString(navArea) : this.handleObject(navArea);
});
}
/**
* Remove an area from the focusable groups
* @param {string} area area to be removed
* @returns {void}
*/
remove(area = "default") {
if (!this.enabled)
return;
if (!this.navigatableElements[area])
return console.error(`The area '${area}' you are trying to remove doesn't exist`);
this.navigatableElements[area].elements.forEach((element) => element.removeAttribute("tabindex"));
this.navigatableElements[area] = {};
}
/**
* Get elements from selector and save them to the default group
* @param {string} navArea
* @returns {void}
*/
handleString(navArea) {
const domElements = document.querySelectorAll(navArea);
if (domElements.length === 0)
return console.error(`${navArea} is either not a correct selector or the element is not present in the DOM.`);
domElements.forEach(this.makeFocusable);
this.setNavigationAreaProperties("default", domElements);
}
/**
* Gets elements from object and saves them to a focusable group
* @param {Object} navArea
* @param {string} navArea.area
* @param {string[]} navArea.elements
* @returns {void}
*/
handleObject(navArea) {
const domElements = navArea.elements.reduce((acc, el) => {
const elements = document.querySelectorAll(el);
elements.forEach(this.makeFocusable);
acc.push(...elements);
return acc;
}, []);
if (domElements.length === 0)
return console.error(`${navArea.elements.join(", ")} are either not a correct selectors or the elements are not present in the DOM.`);
if (!this.navigatableElements[navArea.area]) {
this.navigatableElements[navArea.area] = { elements: [], distance: 0 };
}
this.setNavigationAreaProperties(navArea.area, domElements);
}
/**
* @param {string} area - The area to set the properties for
* @param {HTMLElement[]} domElements - The elements to be added to the area
*/
setNavigationAreaProperties(area, domElements) {
this.navigatableElements[area].elements.push(...domElements);
this.navigatableElements[area].distance = this.getElementsDistance(this.navigatableElements[area].elements);
this.navigatableElements[area].overflow = this.setOverflowValues(domElements[0].parentElement);
}
/**
* Calculates the distance between the provided elements and return the max distance
* @param {HTMLElement[]} elements
* @returns {number} - The max distance between the elements
*/
getElementsDistance(elements) {
return elements.reduce((acc, el) => {
const { x, y } = el.getBoundingClientRect();
const distance = Math.hypot(x, y);
return acc < distance ? distance : acc;
}, 0);
}
/**
* Recursively checks for overflow in the parent elements and sets the area overflow values
* @param {HTMLElement} element - The element to check for overflow
* @returns {{x: number, y: number}|HTMLElement} - Next element to check for overflow or object with the overflow values
*/
setOverflowValues(element) {
if (!element)
return { x: 0, y: 0 };
const { scrollWidth, scrollHeight } = element;
const overflowX = Math.max(0, scrollWidth - window.innerWidth);
const overflowY = Math.max(0, scrollHeight - window.innerHeight);
if (overflowX > 0 || overflowY > 0) {
return { x: overflowX, y: overflowY };
}
return this.setOverflowValues(element.parentElement);
}
/**
* Sets the tabindex of the element that needs to be focused
* @param {HTMLElement} element
*/
makeFocusable(element) {
element.setAttribute("tabindex", 1);
}
/**
* Returns the valid focusable elements in the navigatable area
* @param {HTMLElement} targetElement
* @param {HTMLElement[]} elements
* @param {number} distance
* @returns {NavigationObject[]}
*/
getFocusableGroup(targetElement, elements, distance) {
return elements.reduce((accumulator, element) => {
if (element !== targetElement && !element.hasAttribute("disabled")) {
const { x, y, height, width } = element.getBoundingClientRect();
accumulator.push({
element,
x: x + distance,
y: y + distance,
height,
width
});
}
return accumulator;
}, []);
}
/**
* Checks if the passed element is within a group and returns the rest of the elements in the group
* @param {HTMLElement} targetElement
* @returns {NavigationObject[]}
*/
getCurrentArea(targetElement) {
return Object.values(this.navigatableElements).find((area) => {
if (area.elements.includes(targetElement))
return true;
});
}
/**
* Gets the element closest to the opposite edge of the navigation direction
* @param {string} direction
* @param {NavigationObject[]} elements
* @param {Object} focusedElement
* @param {number} focusedElement.x
* @param {number} focusedElement.y
* @param {number} distance
* @param {{x: number, y: number}} overflow
* @returns {NavigationObject}
*/
getClosestToEdge(direction, elements, focusedElement, distance, overflow) {
let newDistance, oldDistance;
const bottomEdge = window.innerHeight + distance + overflow.y;
const rightEdge = window.innerWidth + distance + overflow.x;
return elements.reduce((acc, el) => {
switch (direction) {
case "down":
newDistance = Math.hypot(el.x - focusedElement.x, el.y);
oldDistance = Math.hypot(acc.x - focusedElement.x, acc.y);
break;
case "up":
newDistance = Math.hypot(el.x - focusedElement.x, bottomEdge - el.y);
oldDistance = Math.hypot(acc.x - focusedElement.x, bottomEdge - acc.y);
break;
case "right":
newDistance = Math.hypot(el.x, el.y - focusedElement.y);
oldDistance = Math.hypot(acc.x, acc.y - focusedElement.y);
break;
case "left":
newDistance = Math.hypot(rightEdge - el.x, el.y - focusedElement.y);
oldDistance = Math.hypot(rightEdge - acc.x, acc.y - focusedElement.y);
break;
}
acc = newDistance < oldDistance ? el : acc;
return acc;
});
}
/**
* Moves the focus in the desired direction
* @param {string} direction
* @returns {void}
*/
moveFocus(direction) {
if (!this.enabled)
return;
const activeElement = this.checkActiveElementInGroup();
const currentArea = this.getCurrentArea(activeElement);
if (!currentArea)
return console.error("The active element is not in a focusable area!");
const { elements, distance, overflow } = currentArea;
const focusableGroup = this.getFocusableGroup(activeElement, elements, distance);
const { x, y, width, height } = activeElement.getBoundingClientRect();
const adjustedDimensions = {
x: x + distance,
y: y + distance,
width,
height
};
if (focusableGroup.length === 0)
return;
const currentAxisGroup = this.filterGroupByCurrentAxis(direction, focusableGroup, adjustedDimensions);
if (!currentAxisGroup.length)
return;
let nextFocusableElement = this.findNextElement(
direction,
currentAxisGroup,
adjustedDimensions.x,
adjustedDimensions.y
);
if (!nextFocusableElement) {
nextFocusableElement = this.getClosestToEdge(
direction,
currentAxisGroup,
adjustedDimensions,
distance,
overflow
);
}
if (nextFocusableElement) {
nextFocusableElement.element.focus();
this.lastFocusedElement = nextFocusableElement.element;
}
}
/** Filters the focusable group by the relevant axis by chacking for same axis overlap
* @param {string} direction
* @param {Array} focusableGroup
* @param {Object} currentElement
* @returns {Array}
*/
filterGroupByCurrentAxis(direction, focusableGroup, currentElement) {
return focusableGroup.filter((element) => {
if (direction === "left" || direction === "right")
return this.isOverlappingX(currentElement, element);
return this.isOverlappingY(currentElement, element);
});
}
/** Compares the Y coordinates of two elements and checks for overlap by the specified overlap value
* @param {Object} currentElement
* @param {Object} nextElement
* @returns {boolean}
*/
isOverlappingX(currentElement, nextElement) {
const lowerBoundary = Math.min(currentElement.y + currentElement.height, nextElement.y + nextElement.height);
const topBoundary = Math.max(currentElement.y, nextElement.y);
const verticalOverlap = Math.max(0, lowerBoundary - topBoundary);
const minHeight = Math.min(currentElement.height, nextElement.height);
const overlapPercentage = verticalOverlap / minHeight;
return overlapPercentage >= this.overlapPercentage;
}
/** Compares the X coordinates of two elements and checks for overlap by the specified overlap value
* @param {Object} currentElement
* @param {Object} nextElement
* @returns {boolean}
*/
isOverlappingY(currentElement, nextElement) {
const rightBoundary = Math.min(currentElement.x + currentElement.width, nextElement.x + nextElement.width);
const leftBoundary = Math.max(currentElement.x, nextElement.x);
const horizontalOverlap = Math.max(0, rightBoundary - leftBoundary);
const minWidth = Math.min(currentElement.width, nextElement.width);
const overlapPercentage = horizontalOverlap / minWidth;
return overlapPercentage >= this.overlapPercentage;
}
/** Returns the next element to focus within the group
* @param {string} direction
* @param {Array} focusableGroup
* @param {number} x
* @param {number} y
* @returns {Object}
*/
findNextElement(direction, focusableGroup, x, y) {
return focusableGroup.reduce((acc, el) => {
const deltaX = el.x - x;
const deltaY = el.y - y;
const angle = toDeg(Math.atan2(deltaY, deltaX));
if (this.getDirectionAngle(direction, angle)) {
if (!acc)
acc = el;
const newDistance = Math.hypot(deltaX, deltaY);
const oldDistance = Math.hypot(acc.x - x, acc.y - y);
acc = newDistance < oldDistance ? el : acc;
}
return acc;
}, null);
}
/**
* Get the angle range for the direction
* @param {string} direction
* @param {number} angle
* @returns {boolean}
*/
getDirectionAngle(direction, angle) {
switch (direction) {
case "down":
return angle > 0 && angle < 180;
case "up":
return angle > -180 && angle < 0;
case "left":
return angle < -90 || angle > 90;
case "right":
return angle > -90 && angle < 90;
}
}
/**
* Registers actions and adds them to the keyboard and gamepad objects
*/
registerKeyActions() {
directions.forEach((direction) => {
const callback = () => {
this.moveFocus(direction);
};
actions_default.register(`move-focus-${direction}`, callback);
const keys = this.activeKeys[direction];
for (const key of keys) {
keyboard_default.on({
keys: [key],
callback: `move-focus-${direction}`,
type: ["press", "hold"]
});
this.registeredKeys.add(key);
}
gamepad_default.on({
actions: [`playstation.d-pad-${direction}`],
callback: `move-focus-${direction}`
});
});
}
/**
* Resets to the original keys state
*/
resetKeys() {
this.removeKeyActions();
this.activeKeys = JSON.parse(JSON.stringify(defaultKeysState));
this.registerKeyActions();
}
/**
* Adds or override default direction keys with the specified ones
* @param {Object} customDirections - { up: 'W', left: 'A', right: 'D', down: 'S' }
* @param {Object} options - Optional settings.
* @param {Boolean} options.clearCurrentActiveKeys - If true, overrides all keys. Defaults to false.
* @returns {void}
*/
changeKeys(customDirections, options = { clearCurrentActiveKeys: false }) {
const customKeysDirections = Object.keys(customDirections);
if (customKeysDirections.length === 0)
return;
const incorrectDirections = customKeysDirections.filter((direction) => !directions.includes(direction));
if (incorrectDirections.length > 0)
return console.error(`The following directions: [${incorrectDirections.join(", ")}] you have entered are incorrect! `);
this.clearCurrentActiveKeys = options.clearCurrentActiveKeys;
this.removeKeyActions();
for (const direction in this.activeKeys) {
const newKey = customDirections[direction];
if (typeof newKey === "string" && !this.activeKeys[direction].includes(newKey)) {
this.activeKeys[direction].push(newKey.toUpperCase());
}
}
this.registerKeyActions();
}
/**
* Removes the added actions
*/
removeKeyActions() {
if (this.registeredKeys.size !== 0) {
this.registeredKeys.forEach((key) => keyboard_default.off([key]));
this.registeredKeys.clear();
directions.forEach((direction) => {
actions_default.remove(`move-focus-${direction}`);
gamepad_default.off([`playstation.d-pad-${direction}`]);
if (this.clearCurrentActiveKeys)
this.activeKeys[direction] = [];
});
}
}
/**
* Focuses on the first element in a focusable area
* @param {string} area
* @returns {void}
*/
focusFirst(area = "default") {
const navigatableElements = this.navigatableElements[area].elements;
if (!navigatableElements || navigatableElements.length === 0) {
return console.error(`The area '${area}' you are trying to focus doesn't exist or the spatial navigation hasn't been initialized`);
}
this.lastFocusedElement = navigatableElements.find((el) => !el.hasAttribute("disabled"));
if (!this.lastFocusedElement) {
return console.error(`The area '${area}' you are trying to focus doesn't have any focusable elements`);
}
this.lastFocusedElement.focus();
}
/**
* Focuses on the last element in a focusable area
* @param {string} area
* @returns {void}
*/
focusLast(area = "default") {
if (!this.enabled)
return;
const navigatableElements = this.navigatableElements[area].elements;
if (!navigatableElements || navigatableElements.length === 0) {
return console.error(`The area '${area}' you are trying to focus doesn't exist or the spatial navigation hasn't been initialized`);
}
let element;
for (let i = navigatableElements.length - 1; i >= 0; i--) {
if (!navigatableElements[i].hasAttribute("disabled")) {
element = navigatableElements[i];
break;
}
}
this.lastFocusedElement = element;
if (!this.lastFocusedElement) {
return console.error(`The area '${area}' you are trying to focus doesn't have any focusable elements`);
}
this.lastFocusedElement.focus();
}
/**
* Changes focus to another area
* @param {string} area
*/
switchArea(area) {
this.focusFirst(area);
}
/**
* Checks if a given element is a focusable area
* @returns {boolean}
*/
isActiveElementInGroup() {
return Object.values(this.navigatableElements).some((group) => group.elements.includes(document.activeElement));
}
/**
* Checks if the active element is within a group and returns the last focused element if it isn't.
* @returns {HTMLElement}
*/
checkActiveElementInGroup() {
return this.isActiveElementInGroup(document.activeElement) ? document.activeElement : this.lastFocusedElement;
}
/**
* Removes the focus from a focused element in a group
*/
clearFocus() {
if (this.isActiveElementInGroup())
document.activeElement.blur();
}
};
var spatial_navigation_default = new SpatialNavigation();
// interaction-manager/src/utils/drag-base.js
var DragBase = class {
/**
*
* @param {Object} options
*/
constructor(options) {
this.draggableElements = [];
this.draggedElement = null;
this.enabled = false;
this.onMouseDown = this.onMouseDown.bind(this);
this.onMouseMove = this.onMouseMove.bind(this);
this.onMouseUp = this.onMouseUp.bind(this);
this.options = options;
}
/**
* Get the index of the dragged item in the draggableElements
*/
get draggedItemIndex() {
return [...this.draggableElements].indexOf(this.draggedElement);
}
/**
* Gets the body scroll offset to calculate in the dragging
*/
get bodyScrollOffset() {
return {
x: document.body.scrollLeft,
y: document.body.scrollTop
};
}
/**
*
* @param {number} clientX
* @param {number} clientY
* @param {HTMLElement} target
*/
setPointerOffset(clientX, clientY, target) {
const { x, y } = target.getBoundingClientRect();
this.offset = {
x: clientX - x,
y: clientY - y
};
}
};
var drag_base_default = DragBase;
// interaction-manager/src/utils/gesture-utils.js
function getDirection(diffX, diffY) {
const MIN_SWIPE_OFFSET = 200;
if (diffY < 0 && diffX > -MIN_SWIPE_OFFSET && diffX < MIN_SWIPE_OFFSET)
return "top";
if (diffY > 0 && diffX > -MIN_SWIPE_OFFSET && diffX < MIN_SWIPE_OFFSET)
return "bottom";
if (diffX < 0 && diffY > -MIN_SWIPE_OFFSET && diffY < MIN_SWIPE_OFFSET)
return "left";
if (diffX > 0 && diffY > -MIN_SWIPE_OFFSET && diffY < MIN_SWIPE_OFFSET)
return "right";
if (diffX <= -MIN_SWIPE_OFFSET && diffY <= -MIN_SWIPE_OFFSET)
return "top-left";
if (diffX >= MIN_SWIPE_OFFSET && diffY <= -MIN_SWIPE_OFFSET)
return "top-right";
if (diffX <= -MIN_SWIPE_OFFSET && diffY >= MIN_SWIPE_OFFSET)
return "bottom-left";
if (diffX >= MIN_SWIPE_OFFSET && diffY >= MIN_SWIPE_OFFSET)
return "bottom-right";
}
function getElement(element) {
return element instanceof HTMLElement ? element : document.querySelector(element);
}
// interaction-manager/src/lib_components/touch-gestures.js
var MULTIPLE_TOUCHES_MIN_NUMBER = 2;
var TouchGestures = class {
/**
* @typedef {Object} gestureReturnObject
* @property {function} gesture.remove - Removes the gesture and detaches the event listeners
*/
/* eslint-disable-next-line require-jsdoc */
constructor() {
this.activeTouches = /* @__PURE__ */ new Map();
}
/**
*
* @param {Object} options
* @param {HTMLElement | string} options.element - Element you want to attach the touch event to
* @param {function} options.callback - Function to be executed on touch
* @param {number} [options.time=1000] - Time in milliseconds for the press
* @returns {gestureReturnObject}
*/
hold(options) {
if (!options)
return console.error("Options not provided for hold!");
let holdTimer;
const element = getElement(options.element);
if (!element)
return console.error("Element not found!");
const onHold = ({ touches }) => {
this.activeTouches.set(touches[0].identifier, touches[0]);
holdTimer = setTimeout(() => {
if (!options.callback)
return;
options.callback();
}, options.time || 1e3);
};
const onHoldEnd = ({ touches }) => {
this.activeTouches.delete(touches[0].identifier);
clearTimeout(holdTimer);
};
element.addEventListener("touchstart", onHold);
element.addEventListener("touchend", onHoldEnd);
return {
/**
* Removes the event listeners
*/
remove() {
element.removeEventListener("touchstart", onHold);
element.removeEventListener("touchend", onHoldEnd);
}
};
}
/**
*
* @param {Object} options
* @param {HTMLElement | string} options.element - Element you want to attach the touch event to
* @param {function} options.callback - Function to be executed on touch
* @param {number} [options.tapsNumber=1] - Number of taps necessary for the callback to be executed
* @param {number} [options.tapTime=200] - Time in milliseconds between putting down the finger and lifting it up
* @param {number} [options.betweenTapsTime=500] - Time in milliseconds between two sequential taps
* @returns {gestureReturnObject}
*/
tap(options) {
if (!options)
return console.error("Options not provided for tap!");
let tapTimer, betweenTapsTimer;
let isTap = true;
let tapCount = options.tapsNumber || 1;
const element = getElement(options.element);
if (!element)
return console.error("Element not found!");
const onTap = ({ touches }) => {
this.activeTouches.set(touches[0].identifier, touches[0]);
clearTimeout(betweenTapsTimer);
isTap = true;
tapTimer = setTimeout(() => {
isTap = false;
}, options.tapTime || 200);
};
const onTapEnd = ({ touches }) => {
this.activeTouches.delete(touches[0].identifier);
clearTimeout(tapTimer);
if (!isTap)
return;
tapCount--;
betweenTapsTimer = setTimeout(() => {
tapCount = options.tapsNumber || 1;
clearTimeout(betweenTapsTimer);
}, options.betweenTapsTime || 500);
if (tapCount !== 0 || !options.callback)
return;
options.callback();
isTap = true;
clearTimeout(tapTimer);
tapCount = options.tapsNumber || 1;
};
element.addEventListener("touchstart", onTap);
element.addEventListener("touchend", onTapEnd);
return {
/**
* Removes the event listeners
*/
remove() {
element.removeEventListener("touchstart", onTap);
element.removeEventListener("touchend", onTapEnd);
}
};
}
/**
*
* @param {Object} options
* @param {HTMLElement | string} options.element - Element you want to attach the touch event to
* @param {function} options.onDragStart - Function to be executed on drag start
* @param {function} options.onDrag - Function to be executed on drag
* @param {function} options.onDragEnd - Function to be executed on drag end
* @returns {gestureReturnObject}
*/
drag(options) {
if (!options)
return console.error("Options not provided for drag!");
const element = getElement(options.element);
if (!element)
return console.error("Element not found!");
const onDragStart = ({ touches, target, currentTarget }) => {
this.activeTouches.set(touches[0].identifier, touches[0]);
document.addEventListener("touchmove", onDrag);
document.addEventListener("touchend", onDragEnd);
if (!options.onDragStart)
return;
options.onDragStart({ x: touches[0].clientX, y: touches[0].clientY, target, currentTarget });
};
const onDrag = ({ touches }) => {
if (!this.activeTouches.has(touches[0].identifier))
return;
if (!options.onDrag)
return;
options.onDrag({ x: touches[0].clientX, y: touches[0].clientY });
};
const onDragEnd = ({ touches }) => {
this.activeTouches.delete(touches[0].identifier);
document.removeEventListener("touchmove", onDrag);
document.removeEventListener("touchend", onDragEnd);
if (!options.onDragEnd)
return;
options.onDragEnd({ x: touches[0].clientX, y: touches[0].clientY });
};
element.addEventListener("touchstart", onDragStart);
return {
/**
* Removes the event listeners
*/
remove() {
element.removeEventListener("touchstart", onDragStart);
}
};
}
/**
*
* @param {Object} options
* @param {HTMLElement | string} options.element - Element you want to attach the touch event to
* @param {function} options.callback - Function to be executed on touch- Directions of the swipe
* @param {number} options.touchNumber - Number of fingers necessary for the swipe
* @returns {gestureReturnObject}
*/
swipe(options) {
if (!options)
return console.error("Options not provided for swipe!");
let swipeTimer, direction, distance;
let isSwipe = true;
const SWIPE_MIN_DISTANCE = 100;
options.touchNumber ||= 1;
const element = getElement(options.element);
if (!element)
return console.error("Element not found!");
const onSwipeStart = ({ touches }) => {
this.activeTouches.set(touches[0].identifier, touches[0]);
if (this.activeTouches.size > options.touchNumber) {
document.removeEventListener("touchmove", onSwipe);
document.removeEventListener("touchend", onSwipeEnd);
}
if (this.activeTouches.size !== options.touchNumber)
return;
swipeTimer = setTimeout(() => {
isSwipe = false;
clearTimeout(swipeTimer);
swipeTimer = null;
}, 1e3);
document.addEventListener("touchmove", onSwipe);
document.addEventListener("touchend", onSwipeEnd);
};
const onSwipe = ({ touches }) => {
if (!this.activeTouches.has(touches[0].identifier))
return;
const { clientX: startX, clientY: startY } = this.activeTouches.get(touches[0].identifier);
const diffX = touches[0].clientX - startX;
const diffY = touches[0].clientY - startY;
direction = getDirection(diffX, diffY);
distance = distanceBetweenTwoPoints(startX, startY, touches[0].clientX, touches[0].clientY);
};
const onSwipeEnd = ({ touches }) => {
this.activeTouches.delete(touches[0].identifier);
if (this.activeTouches.size !== 0)
return;
document.removeEventListener("touchmove", onSwipe);
document.removeEventListener("touchend", onSwipeEnd);
if (isSwipeComplete()) {
options.callback(direction);
}
clearTimeout(swipeTimer);
isSwipe = true;
swipeTimer = null;
};
const isSwipeComplete = () => {
return isSwipe && options.callback && direction && distance > SWIPE_MIN_DISTANCE;
};
element.addEventListener("touchstart", onSwipeStart);
return {
/**
* Removes the event listeners
*/
remove() {
element.removeEventListener("touchstart", onSwipeStart);
}
};
}
/**
*
* @param {Object} options
* @param {HTMLElement | string} options.element - Element you want to attach the touch event to
* @param {function} options.callback - Function to be executed on touch
* @returns {gestureReturnObject}
*/
pinch(options) {
if (!options)
return console.error("Options not provided for pinch!");
let distance;
const PINCH_DELTA_NUMBER = 40;
const element = getElement(options.element);
if (!element)
return console.error("Element not found!");
const onPinchStart = ({ touches }) => {
this.activeTouches.set(touches[0].identifier, touches[0]);
if (this.activeTouches.size < MULTIPLE_TOUCHES_MIN_NUMBER)
return;
document.addEventListener("touchmove", onPinch);
document.addEventListener("touchend", onPinchEnd);