UNPKG

coherent-gameface-interaction-manager

Version:
562 lines (476 loc) 20.6 kB
/*--------------------------------------------------------------------------------------------- * Copyright (c) Coherent Labs AD. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ /** * @typedef {NavigationObject} * @property {HTMLElement} element * @property {number} x * @property {number} y * @property {number} height * @property {number} width */ import { toDeg } from '../utils/utility-functions'; import actions from './actions'; import keyboard from './keyboard'; import gamepad from './gamepad'; const directions = ['down', 'up', 'left', 'right']; const defaultKeysState = { up: ['arrow_up'], down: ['arrow_down'], right: ['arrow_right'], left: ['arrow_left'] }; /** * Spatial Navigation for keyboard and controller */ class SpatialNavigation { // eslint-disable-next-line require-jsdoc constructor() { this.enabled = false; this.navigatableElements = { default: { elements: [], distance: 0, overflow: { x: 0, y: 0 } } }; this.registeredKeys = 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.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() { 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.off([key])); this.registeredKeys.clear(); directions.forEach((direction) => { actions.remove(`move-focus-${direction}`); gamepad.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(); } } export default new SpatialNavigation();