UNPKG

coherent-gameface-interaction-manager

Version:
1,390 lines 84.5 kB
var __defProp = Object.defineProperty; var __name = (target, value) => __defProp(target, "name", { value, configurable: true }); const _IM2 = class _IM2 { constructor() { this.actions = []; this.keyboardFunctions = []; this.gamepadFunctions = []; } /** * Initialize global object */ init() { if (!window._IM) window._IM = new _IM2(); } /** * Get keyboard functions matching the given keys */ getKeys(keys) { return _IM.keyboardFunctions.filter((keyFunction) => keyFunction.keys.every((key) => keys.includes(key))); } /** * Get the index of a keyboard function matching the given keys */ getKeysIndex(keys) { return _IM.keyboardFunctions.findIndex((keyFunction) => keyFunction.keys.every((key) => keys.includes(key))); } /** * Get a gamepad function matching the given button actions and type */ getGamepadAction({ actions: actions2, type }) { return _IM.gamepadFunctions.find((gpFunc) => { return gpFunc.actions.every((action) => actions2.includes(action)) && gpFunc.type === type && gpFunc.actions.length === actions2.length; }); } /** * Get all gamepad functions matching the given button actions */ getGamepadActions(actions2, exactMatch = true) { return _IM.gamepadFunctions.filter( (gpFunc) => gpFunc.actions.every((action) => actions2.includes(action)) && (exactMatch ? gpFunc.actions.length === actions2.length : true) ); } /** * Get the index of a gamepad function matching the given button actions */ getGamepadActionIndex(actions2) { return _IM.gamepadFunctions.findIndex((gpFunc) => gpFunc.actions.every((action) => actions2.includes(action))); } /** * Get an action by name * @param {string} action - Action to search for */ getAction(action) { return _IM.actions.find((actionObj) => actionObj.name === action); } /** * Get the index of an action by name */ getActionIndex(action) { return _IM.actions.findIndex((actionObj) => actionObj.name === action); } /** * Checks if a callback already exists in a registered function entry */ hasDuplicateCallback(functionEntry, callback) { return functionEntry.callbacks.some((cb) => cb === callback); } /** * Adds a callback to an existing function entry if it's not a duplicate * @param {KeyboardFunction | GamepadFunction} functionEntry - The function entry to add the callback to * @param {Function | string} callback - The callback to add * @param {Object} errorContext - Context information for error messages * @param {string} errorContext.identifier - String identifying the keys/actions (e.g., "Keys: [A, B]") * @param {string} errorContext.type - The type of action (press, hold, lift) */ addCallbackToEntry(functionEntry, callback, errorContext) { if (this.hasDuplicateCallback(functionEntry, callback)) { const callbackType = typeof callback === "string" ? "action" : "function"; const callbackName = typeof callback === "string" ? callback : "(anonymous function)"; return console.error( `Duplicate callback detected! ${errorContext.identifier} Type: '${errorContext.type}' Callback: ${callbackName} This ${callbackType} is already registered for this combination and type. To update it, first remove with the .off() method.` ); } return functionEntry.callbacks.push(callback); } /** * Removes a keyboard function entry from the registry */ removeKeyboardFunction(functionEntry) { const index = _IM.keyboardFunctions.indexOf(functionEntry); if (index !== -1) _IM.keyboardFunctions.splice(index, 1); } /** * Removes a gamepad function entry from the registry */ removeGamepadFunction(functionEntry) { const index = _IM.gamepadFunctions.indexOf(functionEntry); if (index !== -1) _IM.gamepadFunctions.splice(index, 1); } }; __name(_IM2, "IM"); let IM = _IM2; const IM$1 = new IM(); const mappings$1 = { 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 }; const mappingsKeys = Object.keys(mappings$1); const _Actions = class _Actions { /** * Register an action */ register(action, callback) { if (IM$1.getAction(action)) return console.error(`The following action "${action}" is already registered!`); _IM.actions.push({ name: action, callback }); } /** * Remove a registered action */ remove(action) { const actionIndex = IM$1.getActionIndex(action); if (actionIndex === -1) return console.error(`${action} is not a registered action!`); _IM.actions.splice(actionIndex, 1); } /** * Trigger an action */ execute(action, value) { const actionObject = IM$1.getAction(action); if (!actionObject) return console.error(`${action} is not a registered action!`); actionObject.callback(value); } }; __name(_Actions, "Actions"); let Actions = _Actions; const actions = new Actions(); const _Keyboard = class _Keyboard { constructor() { 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 = mappings$1; } /** * Registers keyboard event listeners * @param options - Configuration object * @param options.keys - Array of keys (e.g., ['A', 'SHIFT']) or key codes * @param options.callback - Function or action name to execute * @param options.type - Event type(s): 'press', 'hold', or 'lift' (can be single or array) */ on(options) { const keys = this.normalizeKeys(options.keys); const incorrectKeys = keys.filter((key) => !mappings$1[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; } const types = !options.type ? ["press"] : Array.isArray(options.type) ? options.type : [options.type]; types.forEach((type) => { const registeredKeys = IM$1.getKeys(keys); const existingEntry = registeredKeys.find((key) => key.type === type); if (existingEntry) { return IM$1.addCallbackToEntry(existingEntry, options.callback, { identifier: `Keys: [${keys.join(", ")}]`, type }); } if (type === "lift" && keys.length > 1) return console.error("You can only have a single key trigger an action on lift"); _IM.keyboardFunctions.push({ keys, type, callbacks: [options.callback] }); }); } /** * Removes either a key combination or a callback from the provided key combination * @param keys - Key combination you want to remove (e.g., ['A', 'SHIFT']) * @param callback - Optional specific callback or action to remove */ off(keys, callback) { keys = this.normalizeKeys(keys); const keyCombinations = IM$1.getKeys(keys); if (keyCombinations.length === 0) return console.error("You are trying to remove a non-existent key combination!"); if (callback) { const combinationsWithCallback = keyCombinations.filter((combination) => combination.callbacks.includes(callback)); if (combinationsWithCallback.length === 0) { return console.error("You are trying to remove a non-existent callback from this key combination!"); } combinationsWithCallback.forEach((combination) => { const cbIndex = combination.callbacks.indexOf(callback); combination.callbacks.splice(cbIndex, 1); if (combination.callbacks.length === 0) { IM$1.removeKeyboardFunction(combination); } }); } else { keyCombinations.forEach((combination) => { IM$1.removeKeyboardFunction(combination); }); } if (_IM.keyboardFunctions.length === 0) { document.removeEventListener("keydown", this.onKeyDown); document.removeEventListener("keyup", this.onKeyUp); this.eventListenerAttached = false; } } /** * Handles when key is pressed */ onKeyDown(event) { const keyPressed = this.keyCodeToString(event.keyCode); if (!keyPressed) return; this.keysPressed.add(keyPressed); const registeredKeys = IM$1.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.executeCallbacks(event, key); }); } /** * Handles when key is released */ onKeyUp(event) { const keyPressed = this.keyCodeToString(event.keyCode); if (!keyPressed) return; this.keysPressed.delete(keyPressed); const registeredKeys = IM$1.getKeys([keyPressed]); if (registeredKeys.length === 0) return; registeredKeys.forEach((key) => { if (key.type === "lift" && key.keys.indexOf(keyPressed) !== -1) this.executeCallbacks(event, key); }); } /** * Convert keyCode to string representing key */ keyCodeToString(code) { return mappingsKeys.find((key) => mappings$1[key] === code); } /** * Removes duplicates and converts KeyCodes to valid KeyName strings */ normalizeKeys(keys) { const normalizedKeys = keys.map((key) => { if (typeof key === "number") { const name = this.keyCodeToString(key); if (!name) throw new Error(`Invalid KeyCode: ${key}`); return name; } return key.toUpperCase(); }); return [...new Set(normalizedKeys)]; } /** * 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.callbacks - Functions or actions to be executed on the key combination * @param {('press'|'hold'|'lift')} registeredKeys.type - Type of key action you want to use. */ executeCallbacks(event, registeredKeys) { registeredKeys.callbacks.forEach((callback) => { if (typeof callback === "string") return actions.execute(callback, event); callback(event); }); } }; __name(_Keyboard, "Keyboard"); let Keyboard = _Keyboard; const keyboard = new Keyboard(); const mappings = { 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-shoulder": "LEFT_SHOULDER", "right-shoulder": "RIGHT_SHOULDER", "left-shoulder-bottom": "LEFT_SHOULDER_BOTTOM", "right-shoulder-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" ] }; const AXIS_THRESHOLD = 0.9; const ACTION_TYPES = ["press", "hold"]; const _Gamepad = class _Gamepad { constructor() { this._gamepadEnabled = false; this._pollingInterval = 200; this._pressedAction = null; this._pressedButtons = []; this.sanitizeAction = this.sanitizeAction.bind(this); } /** * Allow gamepads to be connected */ set enabled(isEnabled) { this._gamepadEnabled = isEnabled; this._gamepadEnabled ? this.startPolling() : this.stopPolling(); } set pollingInterval(interval) { this._pollingInterval = interval; if (this._gamepadEnabled) { if (this.pollingIntervalRef) this.stopPolling(); this.startPolling(); } } on(options) { const actions2 = options.actions.map(this.sanitizeAction); const isAxisAlias = mappings.axisAliases.some((alias) => actions2.includes(alias)); const type = options.type && ACTION_TYPES.includes(options.type) ? options.type : "hold"; if (type === "press" && isAxisAlias) { return console.error(`You can't use an axis action with a 'press' type!`); } if (actions2.length > 1 && isAxisAlias) { return console.error(`You can't use an axis action in a combination with a button action`); } const existingEntry = IM$1.getGamepadAction({ actions: actions2, type }); if (existingEntry) { return IM$1.addCallbackToEntry(existingEntry, options.callback, { identifier: `Actions: [${actions2.join(", ")}]`, type }); } _IM.gamepadFunctions.push({ actions: actions2, type, callbacks: [options.callback] }); } /** * Removes either an action or a callback from the provided action * @param {Array} actions - Array containing the action you want to remove * @param {string | Function} callback - Callback or action you want to remove */ off(actions2, callback) { const matchingActions = IM$1.getGamepadActions(actions2.map(this.sanitizeAction)); if (matchingActions.length === 0) { return console.error("You are trying to remove a non-existent action!"); } if (callback) { const actionsWithCallback = matchingActions.filter((action) => action.callbacks.includes(callback)); if (actionsWithCallback.length === 0) return console.error("You are trying to remove a non-existent callback from this action!"); actionsWithCallback.forEach((action) => { const cbIndex = action.callbacks.indexOf(callback); action.callbacks.splice(cbIndex, 1); if (action.callbacks.length === 0) { IM$1.removeGamepadFunction(action); } }); } else { matchingActions.forEach((action) => IM$1.removeGamepadFunction(action)); } } /** * Loop that handles button presses and axis movement */ startPolling() { this.pollingIntervalRef = setInterval(() => { const gamepads = navigator.getGamepads(); if (gamepads.length === 0) return; gamepads.forEach((gamepad2) => { if (!gamepad2) return; this.handleButtons(gamepad2.buttons); this.handleJoysticks(gamepad2.axes); }); }, this._pollingInterval); } stopPolling() { if (this.pollingIntervalRef) clearInterval(this.pollingIntervalRef); } 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 = IM$1.getGamepadActions(pressedButtons.buttonIndexes, false); if (this._pressedAction) { if (!gamepadActions.includes(this._pressedAction)) { this.executeCallbacks(this._pressedAction, this._pressedButtons); } this._pressedAction = null; this._pressedButtons = []; } gamepadActions.forEach((gamepadAction) => { if (gamepadAction.type === "press") { this._pressedAction = gamepadAction; this._pressedButtons = pressedButtons.buttons; return; } this.executeCallbacks(gamepadAction, pressedButtons.buttons); }); } /* eslint-disable max-lines-per-function */ handleJoysticks(axes) { const joystickActions = this.getJoystickActions(); joystickActions.forEach((jAction) => { switch (jAction.actions[0]) { case "left.joystick": return this.executeCallbacks(jAction, [axes[0], axes[1]]); case "right.joystick": return this.executeCallbacks(jAction, [axes[2], axes[3]]); case "left.joystick.down": if (axes[1] > AXIS_THRESHOLD) return this.executeCallbacks(jAction, [axes[0], axes[1]]); break; case "left.joystick.up": if (axes[1] < -AXIS_THRESHOLD) return this.executeCallbacks(jAction, [axes[0], axes[1]]); break; case "left.joystick.left": if (axes[0] < -AXIS_THRESHOLD) return this.executeCallbacks(jAction, [axes[0], axes[1]]); break; case "left.joystick.right": if (axes[0] > AXIS_THRESHOLD) return this.executeCallbacks(jAction, [axes[0], axes[1]]); break; case "right.joystick.down": if (axes[3] > AXIS_THRESHOLD) return this.executeCallbacks(jAction, [axes[2], axes[3]]); break; case "right.joystick.up": if (axes[3] < -AXIS_THRESHOLD) return this.executeCallbacks(jAction, [axes[2], axes[3]]); break; case "right.joystick.left": if (axes[2] < -AXIS_THRESHOLD) return this.executeCallbacks(jAction, [axes[2], axes[3]]); break; case "right.joystick.right": if (axes[2] > AXIS_THRESHOLD) return this.executeCallbacks(jAction, [axes[2], axes[3]]); break; } }); } /** * Convert button aliases to indexes or keep joystick aliases * @param {string | number} action - Actions to convert */ sanitizeAction(action) { if (typeof action === "number") return action; const actionName = action.toLowerCase(); if (mappings.axisAliases.includes(actionName)) return actionName; const key = mappings.aliases[actionName]; if (key) return mappings[key]; throw new Error(`You have entered a non-supported button alias: ${action}`); } /** * Gets all registered Joystick actions */ getJoystickActions() { return _IM.gamepadFunctions.filter((gpFunc) => mappings.axisAliases.includes(gpFunc.actions[0])); } /** * Executes the callbacks from the registered action */ executeCallbacks(action, value) { action.callbacks.forEach((callback) => { if (typeof callback === "string") return actions.execute(callback, value); callback(value); }); } }; __name(_Gamepad, "Gamepad"); let Gamepad = _Gamepad; const gamepad = new Gamepad(); function toDeg(rad) { return rad * 180 / Math.PI; } __name(toDeg, "toDeg"); function clamp(value, min, max) { return Math.min(Math.max(value, min), max); } __name(clamp, "clamp"); function createHash() { return (Math.random() + 1).toString(36).substring(7); } __name(createHash, "createHash"); function distanceBetweenTwoPoints(x1, y1, x2, y2) { const a = x1 - x2; const b = y1 - y2; return Math.hypot(a, b); } __name(distanceBetweenTwoPoints, "distanceBetweenTwoPoints"); function getMidPoint(x1, y1, x2, y2) { return { x: (x1 + x2) / 2, y: (y1 + y2) / 2 }; } __name(getMidPoint, "getMidPoint"); const directions = ["down", "up", "left", "right"]; const defaultKeysState = { up: ["arrow_up"], down: ["arrow_down"], right: ["arrow_right"], left: ["arrow_left"] }; const _SpatialNavigation = class _SpatialNavigation { constructor() { this.enabled = false; this.areas = { default: this.createDefaultAreaState() }; this.registeredKeys = /* @__PURE__ */ new Set(); this.clearCurrentActiveKeys = false; this.overlapPercentage = 0.5; this.lastFocusedElement = null; this.paused = false; this.syncLastFocused = (event) => { const target = event.target; if (!this.enabled || !target) return; for (const areaName in this.areas) { const area = this.areas[areaName]; if (area.elements.includes(target)) { area.lastFocusedElement = target; this.lastFocusedElement = target; break; } } }; } /** * Initializes the spatial navigation * @param {string[]|Object[]|HTMLElement[]} navigableElements - Array of selector strings, objects with area/elements, or HTMLElement references * @param {string} navigableElements[].area - Name of the navigation area * @param {(string|HTMLElement)[]} navigableElements[].elements - Array of selector strings or HTMLElement references * @param {number} overlap - Overlap percentage (0-1) for determining if elements are on the same axis * @example * // Using selector strings * spatialNavigation.init(['.menu-item', '#header']); * * // Using HTMLElement references * spatialNavigation.init([element1, element2]); * * // Using object syntax with mixed selectors and HTMLElements * spatialNavigation.init([ * { area: 'menu', elements: ['.item', element1, '#button'] } * ]); */ init(navigableElements = [], overlap) { if (this.enabled) return; this.enabled = true; this.add(navigableElements); this.activeKeys = JSON.parse(JSON.stringify(defaultKeysState)); this.registerKeyActions(); window.addEventListener("focusin", this.syncLastFocused); if (overlap && 0 <= overlap && overlap <= 1) { this.overlapPercentage = overlap; } } /** * Deinitialize the spatial navigation */ deinit() { if (!this.enabled) return; this.enabled = false; this.areas = { default: this.createDefaultAreaState() }; this.removeKeyActions(); this.overlapPercentage = 0.5; this.lastFocusedElement = null; window.removeEventListener("focusin", this.syncLastFocused); } /** * Add new elements to area or new area * @param {string[]|Object[]|HTMLElement[]} navigableElements - Array of selector strings, objects with area/elements, or HTMLElement references * @param {string} navigableElements[].area - Name of the navigation area * @param {(string|HTMLElement)[]} navigableElements[].elements - Array of selector strings or HTMLElement references * @example * // Add selector strings to default area * spatialNavigation.add(['.new-item']); * * // Add HTMLElement references to default area * spatialNavigation.add([element1, element2]); * * // Add to named area with mixed types * spatialNavigation.add([ * { area: 'sidebar', elements: ['.link', element1] } * ]); */ add(navigableElements) { if (!this.enabled) return; if (navigableElements.every((el) => el instanceof HTMLElement)) { navigableElements.forEach((element) => this.makeFocusable(element)); this.setNavigationAreaProperties("default", navigableElements); return; } navigableElements.forEach((navArea) => { typeof navArea === "string" ? this.handleString(navArea) : this.handleObject(navArea); }); } /** * Remove an area from the focusable groups * @param area area to be removed */ remove(area = "default") { if (!this.enabled) return; if (!this.areas[area]) return console.error(`The area '${area}' you are trying to remove doesn't exist`); this.areas[area].elements.forEach((element) => element.removeAttribute("tabindex")); delete this.areas[area]; } /** * Gets the last focused element from the specified area * @param area */ getLastFocused(area = "default") { const el = this.areas[area]?.lastFocusedElement; if (el && !document.contains(el)) { this.areas[area].lastFocusedElement = void 0; return void 0; } return el; } /** * Returns the structure of the default area */ createDefaultAreaState() { return { elements: [], lastFocusedElement: void 0 }; } /** * Get elements from selector and save them to the default group * @param navArea */ 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 - Navigation area configuration * @param {string} navArea.area - Name of the navigation area * @param {(string|HTMLElement)[]} navArea.elements - Array of selector strings or HTMLElement references */ handleObject(navArea) { const domElements = navArea.elements.reduce((acc, el) => { if (el instanceof HTMLElement) { acc.push(el); this.makeFocusable(el); return acc; } 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.areas[navArea.area]) { this.areas[navArea.area] = this.createDefaultAreaState(); } this.setNavigationAreaProperties(navArea.area, domElements); } /** * @param area - The area to set the properties for * @param domElements - The elements to be added to the area */ setNavigationAreaProperties(area, domElements) { this.areas[area].elements.push(...domElements); } /** * Sets the tabindex of the element that needs to be focused * @param element */ makeFocusable(element) { element.setAttribute("tabindex", "1"); } /** * Returns the valid focusable elements in the navigable area * @param {HTMLElement} targetElement * @param {HTMLElement[]} elements */ getFocusableGroup(targetElement, elements) { return elements.reduce((accumulator, element) => { if (element !== targetElement && !element.hasAttribute("disabled")) { const { x, y, height, width } = element.getBoundingClientRect(); accumulator.push({ element, x, y, 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.areas).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 */ getClosestToEdge(direction, elements) { return elements.reduce((acc, el) => { switch (direction) { case "down": return el.y < acc.y ? el : acc; case "up": return el.y > acc.y ? el : acc; case "right": return el.x < acc.x ? el : acc; case "left": return el.x > acc.x ? el : acc; default: return acc; } }); } /** * Moves the focus in the desired direction * @param {string} direction */ moveFocus(direction) { if (!this.enabled) return; const activeElement = this.isActiveElementInGroup() && document.activeElement || this.lastFocusedElement; const currentArea = this.getCurrentArea(activeElement); if (!currentArea) return console.error("The active element is not in a focusable area!"); const { elements } = currentArea; const focusableGroup = this.getFocusableGroup(activeElement, elements); const { x, y, width, height } = activeElement.getBoundingClientRect(); const dimensions = { x, y, width, height }; if (focusableGroup.length === 0) return; const currentAxisGroup = this.filterGroupByCurrentAxis(direction, focusableGroup, dimensions); if (!currentAxisGroup.length) return; let nextFocusableElement = this.findNextElement( direction, currentAxisGroup, dimensions.x, dimensions.y ); if (!nextFocusableElement) { nextFocusableElement = this.getClosestToEdge(direction, currentAxisGroup); } if (nextFocusableElement) { nextFocusableElement.element.focus(); this.lastFocusedElement = nextFocusableElement.element; currentArea.lastFocusedElement = this.lastFocusedElement; } } /** Filters the focusable group by the relevant axis by chacking for same axis overlap */ 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 */ 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 */ 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 */ 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 */ 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 = /* @__PURE__ */ __name(() => { if (this.paused) return; this.moveFocus(direction); }, "callback"); actions.register(`move-focus-${direction}`, callback); const keys = this.activeKeys[direction]; for (const key of keys) { keyboard.on({ keys: [key], callback: `move-focus-${direction}`, type: ["press", "hold"] }); this.registeredKeys.add(key); } gamepad.on({ actions: [`playstation.d-pad-${direction}`], callback: `move-focus-${direction}` }); }); } /** * Resets to the original keys state */ resetKeys() { if (!this.enabled) return; 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 options.clearCurrentActiveKeys - If true, overrides all keys. Defaults to false. */ changeKeys(customDirections, options = { clearCurrentActiveKeys: false }) { if (!this.enabled) return; 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) { directions.forEach((direction) => { const actionName = `move-focus-${direction}`; for (const key of this.activeKeys[direction]) { keyboard.off([key], actionName); } actions.remove(actionName); gamepad.off([`playstation.d-pad-${direction}`], actionName); if (this.clearCurrentActiveKeys) this.activeKeys[direction] = []; }); this.registeredKeys.clear(); this.clearCurrentActiveKeys = false; } } /** * Focuses on the first element in a focusable area */ focusFirst(area = "default") { if (!this.enabled) return; const navigableElements = this.areas[area].elements; if (!navigableElements || navigableElements.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 = navigableElements.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(); this.areas[area].lastFocusedElement = this.lastFocusedElement; } /** * Focuses on the last element in a focusable area */ focusLast(area = "default") { if (!this.enabled) return; const navigableElements = this.areas[area].elements; if (!navigableElements || navigableElements.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 = navigableElements.length - 1; i >= 0; i--) { if (!navigableElements[i].hasAttribute("disabled")) { element = navigableElements[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(); this.areas[area].lastFocusedElement = this.lastFocusedElement; } /** * Changes focus to another area by focusing on the last focused element in that area. If no element has previously been focused, it will focus the first available element. */ switchArea(area) { if (!this.enabled) return; const lastFocusedInArea = this.getLastFocused(area); if (!lastFocusedInArea || lastFocusedInArea.hasAttribute("disabled")) { return this.focusFirst(area); } lastFocusedInArea.focus(); this.lastFocusedElement = lastFocusedInArea; } /** * Checks if a given element is in a focusable area */ isElementInGroup(element) { return Object.values(this.areas).some((group) => group.elements.includes(element)); } /** * Checks if the currently active element is in a focusable area */ isActiveElementInGroup() { if (!document.activeElement) return false; return this.isElementInGroup(document.activeElement); } /** * Removes the focus from a focused element in a group */ clearFocus() { if (!this.enabled) return; if (this.isActiveElementInGroup()) document.activeElement.blur(); } /** * Pauses the spatial navigation functionality */ pause() { if (!this.enabled) return; this.paused = true; } /** * Resumes the spatial navigation functionality */ resume() { if (!this.enabled) return; this.paused = false; } }; __name(_SpatialNavigation, "SpatialNavigation"); let SpatialNavigation = _SpatialNavigation; const spatialNavigation = new SpatialNavigation(); const _DragBase = class _DragBase { constructor(options) { this.options = options; this.draggableElements = []; this.draggedElement = null; this.enabled = false; this.offset = { x: 0, y: 0 }; } /** * 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 }; } setPointerOffset(clientX, clientY, target) { const { x, y } = target.getBoundingClientRect(); this.offset = { x: clientX - x, y: clientY - y }; } }; __name(_DragBase, "DragBase"); let DragBase = _DragBase; 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"; } __name(getDirection, "getDirection"); function getElement(element) { return element instanceof HTMLElement ? element : document.querySelector(element); } __name(getElement, "getElement"); const MULTIPLE_TOUCHES_MIN_NUMBER = 2; const _TouchGestures = class _TouchGestures { constructor() { this.activeTouches = /* @__PURE__ */ new Map(); } /** * Hold gesture - triggers after holding for specified time * @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 */ 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 = /* @__PURE__ */ __name(({ touches }) => { this.activeTouches.set(touches[0].identifier, touches[0]); holdTimer = setTimeout(() => { if (!options.callback) return; options.callback(); }, options.time || 1e3); }, "onHold"); const onHoldEnd = /* @__PURE__ */ __name(({ touches }) => { this.activeTouches.delete(touches[0].identifier); clearTimeout(holdTimer); }, "onHoldEnd"); element.addEventListener("touchstart", onHold); element.addEventListener("touchend", onHoldEnd); return { /** * Removes the event listeners */ remove() { element.removeEventListener("touchstart", onHold); element.removeEventListener("touchend", onHoldEnd); } }; } /** * Tap gesture - single or multi-tap detection * @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 */ 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 = /* @__PURE__ */ __name(({ touches }) => { this.activeTouches.set(touches[0].identifier, touches[0]); clearTimeout(betweenTapsTimer); isTap = true; tapTimer = setTimeout(() => { isTap = false; }, options.tapTime || 200); }, "onTap"); const onTapEnd = /* @__PURE__ */ __name(({ 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; }, "onTapEnd"); element.addEventListener("touchstart", onTap); element.addEventListener("touchend", onTapEnd); return { /** * Removes the event listeners */ remove() { element.removeEventListener("touchstart", onTap); element.removeEventListener("touchend", onTapEnd); } }; } /** * Drag gesture - tracks touch movement * @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 */ 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 = /* @__PURE__ */ __name(({ 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 }); }, "onDragStart"); const onDrag = /* @__PURE__ */ __name(({ touches }) => { if (!this.activeTouches.has(touches[0].identifier)) return; if (!options.onDrag) return; options.onDrag({ x: touches[0].clientX, y: touches[0].clientY }); }, "onDrag"); const onDragEnd = /* @__PURE__ */ __name(({ 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 }); }, "onDragEnd"); element.addEventListener("touchstart", onDragStart); return { /** * Removes the event listeners */ remove() { element.removeEventListener("touchstart", onDragStart); } }; } /** * Swipe gesture - detects directional swipes * @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 */ swipe(options) { if (!options) return console.error("Options not provided for swipe!"); let swipeTimer; let direction; let 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 = /* @__PURE__ */ __name(({ 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); }, "onSwipeStart"); const onSwipe = /* @__PURE__ */ __name(({ touches }) => { if (!this.activeTouches.has(touches[0].identifier)) return; const startTouch = this.activeTouches.get(touches[0].identifier); if (!startTouch) return; const { clientX: startX, clientY: startY } = startTouch; 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); }, "onSwipe"); const onSwipeEnd = /* @__PURE__ */ __name(({ 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; }, "onSwipeEnd"); const isSwipeComplete = /* @__PURE__ */ __name(() => { return isSwipe && direction && distance && distance > SWIPE_MIN_DISTANCE; }, "isSwipeComplete"); element.addEventListener("touchstart", onSwipeStart); return { /** * Removes the event listeners */ remove() { element.removeEventListener("touchstart", onSwipeStart); } }; } /** * Pinch gesture - two-finger pinch zoom * @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 */ pinch(options) { if (!options) return console.error("Options