mirror of
https://github.com/NoCrypt/migu.git
synced 2026-01-11 20:10:22 +00:00
266 lines
No EOL
9.4 KiB
JavaScript
266 lines
No EOL
9.4 KiB
JavaScript
import { SUPPORTS } from '@/modules/support.js'
|
|
|
|
let lastTapElement = null
|
|
let lastHoverElement = null
|
|
|
|
const noop = _ => {}
|
|
|
|
document.addEventListener('pointerup', () => {
|
|
setTimeout(() => {
|
|
lastTapElement?.(false)
|
|
lastTapElement = null
|
|
lastHoverElement?.(false)
|
|
lastHoverElement = null
|
|
})
|
|
})
|
|
|
|
/** @typedef {{element: Element, x: number, y: number, inViewport: boolean}} ElementPosition */
|
|
|
|
/**
|
|
* Adds click event listener to the specified node.
|
|
* @param {HTMLElement} node - The node to attach the click event listener to.
|
|
* @param {Function} [cb=noop] - The callback function to be executed on click.
|
|
*/
|
|
export function click (node, cb = noop) {
|
|
node.tabIndex = 0
|
|
node.role = 'button'
|
|
node.addEventListener('click', e => {
|
|
e.stopPropagation()
|
|
navigator.vibrate(15)
|
|
cb(e)
|
|
})
|
|
node.addEventListener('pointerup', e => {
|
|
e.stopPropagation()
|
|
})
|
|
node.addEventListener('pointerleave', e => {
|
|
e.stopPropagation()
|
|
})
|
|
if (!SUPPORTS.isAndroid) {
|
|
node.addEventListener('keydown', e => {
|
|
if (e.key === 'Enter') {
|
|
e.stopPropagation()
|
|
cb(e)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Adds hover event listener to the specified node.
|
|
* @param {HTMLElement} node - The node to attach the click event listener to.
|
|
* @param {Function} [hoverUpdate=noop] - The callback function to be executed on hover.
|
|
*/
|
|
export function hoverChange (node, hoverUpdate = noop) {
|
|
node.tabIndex = 0
|
|
node.role = 'button'
|
|
node.addEventListener('pointerleave', e => {
|
|
hoverUpdate()
|
|
})
|
|
}
|
|
|
|
// TODO: this needs to be re-written.... again... it should detect pointer type and have separate functionality for mouse and touch and none for dpad
|
|
/**
|
|
* Adds hover and click event listeners to the specified node.
|
|
* @param {HTMLElement} node - The node to attach the event listeners to.
|
|
*/
|
|
export function hoverClick (node, [cb = noop, hoverUpdate = noop]) {
|
|
let pointerType = 'touch'
|
|
node.tabIndex = 0
|
|
node.role = 'button'
|
|
node.addEventListener('pointerenter', e => {
|
|
lastHoverElement?.(false)
|
|
lastTapElement?.(false)
|
|
hoverUpdate(true)
|
|
lastHoverElement = hoverUpdate
|
|
pointerType = e.pointerType
|
|
})
|
|
node.addEventListener('click', e => {
|
|
e.stopPropagation()
|
|
if (pointerType === 'mouse') return cb(e)
|
|
lastTapElement?.(false)
|
|
if (lastTapElement === hoverUpdate) {
|
|
lastTapElement = null
|
|
navigator.vibrate(15)
|
|
hoverUpdate(false)
|
|
cb(e)
|
|
} else {
|
|
lastTapElement = hoverUpdate
|
|
}
|
|
})
|
|
node.addEventListener('keydown', e => {
|
|
if (e.key === 'Enter') {
|
|
e.stopPropagation()
|
|
lastTapElement?.(false)
|
|
if (lastTapElement === hoverUpdate) {
|
|
lastTapElement = null
|
|
cb(e)
|
|
} else {
|
|
hoverUpdate(true)
|
|
if (!SUPPORTS.isAndroid) lastTapElement = hoverUpdate
|
|
}
|
|
}
|
|
})
|
|
node.addEventListener('pointerup', e => {
|
|
e.stopPropagation()
|
|
if (e.pointerType === 'mouse') setTimeout(() => hoverUpdate(false))
|
|
})
|
|
node.addEventListener('pointerleave', e => {
|
|
lastHoverElement = hoverUpdate
|
|
if (e.pointerType === 'mouse') hoverUpdate(false)
|
|
})
|
|
node.addEventListener('pointermove', e => {
|
|
if (e.pointerType === 'touch') hoverUpdate(false)
|
|
})
|
|
}
|
|
|
|
const Directions = { up: 1, right: 2, down: 3, left: 4 }
|
|
// const InverseDirections = { up: 'down', down: 'up', left: 'right', right: 'left' }
|
|
const DirectionKeyMap = { ArrowDown: 'down', ArrowUp: 'up', ArrowLeft: 'left', ArrowRight: 'right' }
|
|
|
|
/**
|
|
* Calculates the direction between two points.
|
|
* @param {Object} anchor - The anchor point.
|
|
* @param {Object} relative - The relative point.
|
|
* @returns {number} - The direction between the two points.
|
|
*/
|
|
function getDirection (anchor, relative) {
|
|
return Math.round((Math.atan2(relative.y - anchor.y, relative.x - anchor.x) * 180 / Math.PI + 180) / 90) || 4
|
|
}
|
|
|
|
/**
|
|
* Calculates the distance between two points.
|
|
* @param {Object} anchor - The anchor point.
|
|
* @param {Object} relative - The relative point.
|
|
* @returns {number} - The distance between the two points.
|
|
*/
|
|
function getDistance (anchor, relative) {
|
|
return Math.hypot(relative.x - anchor.x, relative.y - anchor.y)
|
|
}
|
|
|
|
/**
|
|
* Gets keyboard-focusable elements within a specified element.
|
|
* @param {Element} [element=document.body] - The element to search within.
|
|
* @returns {Element[]} - An array of keyboard-focusable elements.
|
|
*/
|
|
function getKeyboardFocusableElements (element = document.body) {
|
|
return [...element.querySelectorAll('a[href], button:not([disabled]), fieldset:not([disabled]), input:not([disabled]), optgroup:not([disabled]), option:not([disabled]), select:not([disabled]), textarea:not([disabled]), details, [tabindex]:not([tabindex="-1"], [disabled]), [contenteditable], [controls]')].filter(
|
|
el => !el.getAttribute('aria-hidden')
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Gets the position of an element.
|
|
* @param {Element} element - The element to get the position of.
|
|
* @returns {ElementPosition} - The position of the element.
|
|
*/
|
|
function getElementPosition (element) {
|
|
const { x, y, width, height, top, left, bottom, right } = element.getBoundingClientRect()
|
|
const inViewport = isInViewport({ top, left, bottom, right, width, height })
|
|
return { element, x: x + width * 0.5, y: y + height * 0.5, inViewport }
|
|
}
|
|
|
|
/**
|
|
* Gets the positions of all focusable elements.
|
|
* @returns {ElementPosition[]} - An array of element positions.
|
|
*/
|
|
function getFocusableElementPositions () {
|
|
const elements = []
|
|
for (const element of getKeyboardFocusableElements(document.querySelector('.modal.show') ?? document.body)) {
|
|
const position = getElementPosition(element)
|
|
if (position) elements.push(position)
|
|
}
|
|
return elements
|
|
}
|
|
|
|
/**
|
|
* Checks if an element is within the viewport.
|
|
* @param {Object} rect - The coordinates of the element.
|
|
* @returns {boolean} - True if the element is within the viewport, false otherwise.
|
|
*/
|
|
function isInViewport ({ top, left, bottom, right, width, height }) {
|
|
return top + height >= 0 && left + width >= 0 && bottom - height <= window.innerHeight && right - width <= window.innerWidth
|
|
}
|
|
|
|
// function isVisible ({ top, left, bottom, right }, element) {
|
|
// for (const [x, y] of [[left, top], [right, top], [left, bottom], [right, bottom]]) {
|
|
// if (document.elementFromPoint(x, y)?.isSameNode(element)) return true
|
|
// }
|
|
// return false
|
|
// }
|
|
|
|
/**
|
|
* @param {ElementPosition[]} keyboardFocusable
|
|
* @param {ElementPosition} currentElement
|
|
* @param {string} direction
|
|
* @returns {ElementPosition[]}
|
|
*/
|
|
function getElementsInDesiredDirection (keyboardFocusable, currentElement, direction) {
|
|
// first try finding visible elements in desired direction
|
|
return keyboardFocusable.filter(position => {
|
|
// in order of computation cost
|
|
if (position.element === currentElement.element) return false
|
|
if (getDirection(currentElement, position) !== Directions[direction]) return false
|
|
|
|
// filters out elements which are in the viewport, but are overlayed by other elements like a modal
|
|
if (position.inViewport && !position.element.checkVisibility()) return false
|
|
if (!position.inViewport && direction === 'right') return false // HACK: prevent right navigation from going to offscreen elements, but allow vertical elements!
|
|
return true
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Navigates using D-pad keys.
|
|
* @param {string} [direction='up'] - The direction to navigate.
|
|
*/
|
|
function navigateDPad (direction = 'up') {
|
|
const keyboardFocusable = getFocusableElementPositions()
|
|
const currentElement = !document.activeElement || document.activeElement === document.body ? keyboardFocusable[0] : getElementPosition(document.activeElement)
|
|
|
|
const elementsInDesiredDirection = getElementsInDesiredDirection(keyboardFocusable, currentElement, direction)
|
|
|
|
// if there are elements in desired direction
|
|
if (elementsInDesiredDirection.length) {
|
|
const closestElement = elementsInDesiredDirection.reduce((reducer, position) => {
|
|
const distance = getDistance(currentElement, position)
|
|
if (distance < reducer.distance) return { distance, element: position.element }
|
|
return reducer
|
|
}, { distance: Infinity, element: null })
|
|
|
|
/** @type {{element: HTMLElement}} */
|
|
const { element } = closestElement
|
|
|
|
const isInput = element.matches('input[type=text], input[type=url], input[type=number], textarea')
|
|
// make readonly
|
|
let wasReadOnly = false
|
|
if (isInput) {
|
|
wasReadOnly = element.readOnly
|
|
element.readOnly = true
|
|
}
|
|
element.focus()
|
|
if (isInput && !wasReadOnly) setTimeout(() => { element.readOnly = false })
|
|
element.scrollIntoView({ block: 'center', inline: 'center', behavior: 'smooth' })
|
|
// return
|
|
}
|
|
|
|
// no elements in desired direction, go to opposite end [wrap around] // this wasnt a good idea in the long run
|
|
// const elementsInOppositeDirection = getElementsInDesiredDirection(keyboardFocusable, currentElement, InverseDirections[direction])
|
|
// if (elementsInOppositeDirection.length) {
|
|
// const furthestElement = elementsInOppositeDirection.reduce((reducer, position) => {
|
|
// const distance = getDistance(currentElement, position)
|
|
// if (distance > reducer.distance) return { distance, element: position.element }
|
|
// return reducer
|
|
// }, { distance: -Infinity, element: null })
|
|
|
|
// furthestElement.element.focus()
|
|
// }
|
|
}
|
|
|
|
// hacky, but make sure keybinds system loads first so it can prevent this from running
|
|
queueMicrotask(() => {
|
|
document.addEventListener('keydown', e => {
|
|
if (DirectionKeyMap[e.key]) {
|
|
e.preventDefault()
|
|
navigateDPad(DirectionKeyMap[e.key])
|
|
}
|
|
})
|
|
}) |