diff options
Diffstat (limited to 'src/app/js/navigate.js')
-rw-r--r-- | src/app/js/navigate.js | 903 |
1 files changed, 903 insertions, 0 deletions
diff --git a/src/app/js/navigate.js b/src/app/js/navigate.js new file mode 100644 index 0000000..5665687 --- /dev/null +++ b/src/app/js/navigate.js @@ -0,0 +1,903 @@ +const events = require("./events"); +const popups = require("./popups"); +const settings = require("./settings"); + +let navigate = {}; + +// sets `#selection` to the correct position, size and border radius, +// according to what is currently the `.active-selection`, if none is +// found, it'll instead be hidden +navigate.selection = (new_selection) => { + if (new_selection) { + let selected = document.querySelectorAll(".active-selection"); + + // make sure just `new_selection` has `.active-selection` + for (let i = 0; i < selected.length; i++) { + if (selected[i] != new_selection) { + selected[i].classList.remove("active-selection"); + } + } + + new_selection.classList.add("active-selection"); + } + + // shorthands + let selection_el = document.getElementById("selection"); + let active_el = document.querySelector(".active-selection"); + + // make sure there's an `active_el`, and hide the `selection_el` if + // that isn't the case + if (! active_el) { + selection_el.style.opacity = "0.0"; + return; + } + + // this adds space between the `selection_el` and `` + let padding = 8; + + // attempt to get the border radius of `active_el` + let radius = getComputedStyle(active_el).borderRadius; + + // if there's no radius set, we default to the default of + // `selection_el` through using `null` + if (! radius || radius == "0px") { + radius = null; + } + + // set visibility and radius + selection_el.style.opacity = "1.0"; + selection_el.style.borderRadius = radius; + + // get bounds for position and size calculations of `selection_el` + let active_bounds = active_el.getBoundingClientRect(); + + // set top and left side coordinate subtracting the padding + selection_el.style.top = active_bounds.top - padding + "px"; + selection_el.style.left = active_bounds.left - padding + "px"; + + // set width of `selection_el` with the padding + selection_el.style.width = + active_bounds.width + (padding * 2) + "px"; + + // set height of `selection_el` with the padding + selection_el.style.height = + active_bounds.height + (padding * 2) + "px"; +} + +// data from the last iterations of the interval below +let last_sel = { + el: false, + bounds: false +} + +// auto update `#selection` if `.active-selection` changes bounds, but +// not element by itself +setInterval(() => { + // get active selection + let selected = document.querySelector(".active-selection"); + + // if there's no active selection, reset `last_sel` + if (! selected) { + last_sel.el = false; + last_sel.bounds = false; + + return; + } + + // get stringified bounds + let bounds = JSON.stringify(selected.getBoundingClientRect()); + + // if `last_sel.el` is not `selected` the selected element was + // changed, so we just set `last_el` and nothing more + if (last_sel.el != selected) { + last_sel.el = selected; + last_sel.bounds = bounds; + + return; + } + + // if stringified bounds changed we update `#selection` + if (bounds != last_sel.bounds) { + navigate.selection(); + last_sel.el = selected; + last_sel.bounds = bounds; + } +}, 50) + +// these events cause the `#selection` element to reposition itself +window.addEventListener("resize", () => {navigate.selection()}, true); +window.addEventListener("scroll", () => {navigate.selection()}, true); + +// listen for click events, and hide the `#selection` element, when +// emitting a mouse event we will want to hide, as it then isn't needed +window.addEventListener("click", (e) => { + // make sure its a trusted click event, and therefore actually a + // mouse, and not anything else + if (! e.isTrusted) { + return; + } + + // get the `.active-selection` + let active_el = document.querySelector(".active-selection"); + + // if there's an `active_el` then we unselect it, and update the + // `#selection` element, hiding it + if (active_el) { + active_el.classList.remove("active-selection"); + navigate.selection(); + } +}) + +// returns a list of valid elements that should be possible to navigate +// to/select with the `#selection` element +// +// setting `div` makes it limit itself to elements inside that, without +// it, it'll use `document.body` or the active popup, if one is found +navigate.get_els = (div) => { + let els = []; + + // is `div` not set, and is there a popup shown + if (! div && document.body.querySelector(".popup.shown")) { + // the spread operator is to convert from a `NodeList` to an + // `Array`, and then we need to reverse this to get the ones + // that are layered on top first. + let popups_list = [...popups.list()].reverse(); + + // run through the list of popups + for (let i = 0; i < popups_list.length; i++) { + // if this popup is shown, we make it the current `div` + if (popups_list[i].classList.contains("shown")) { + div = popups_list[i]; + break; + } + } + + // get buttons inside `#winbtns` + els = [...document.body.querySelectorAll("#winbtns [onclick]")]; + } if (! div) { // default + div = document.body; + } + + // this gets the list of all the elements we should be able to + // select inside `div`, on top of anything that's already in `els` + els = [...els, ...div.querySelectorAll([ + "a", + "input", + "button", + "select", + "textarea", + "[onclick]", + ".scroll-selection" + ])] + + // this'll contain a filtered list of `els` + let new_els = []; + + // filter out elements we don't care about + filter: for (let i = 0; i < els.length; i++) { + // elements that match on `els.closest()` with any of these will + // be stripped away, as we dont want them + let ignore_closest = [ + "#overlay", + ".no-navigate", + "button.visual", + ".scroll-selection", + ".popup:not(.shown)" + ] + + // ignore, even if `.closest()` matches, if its just matching on + // itself instead of a different element + let ignore_closest_self = [ + ".scroll-selection" + ] + + // check if `els[i].closest()` matches on any of the elements + // inside of `ignore_closest` + for (let ii = 0; ii < ignore_closest.length; ii++) { + let closest = els[i].closest(ignore_closest[ii]); + + // check if `.closest()` matches, but not on itself + if (closest) { + // ignore if `closest` is just `els[i]` and the selector + // is inside `ignore_closest_self` + if (closest == els[i] && + ignore_closest_self.includes(ignore_closest[ii])) { + + continue; + } + + // it matches + continue filter; + } + } + + // make sure `els[i]` is visible on screen + let visible = els[i].checkVisibility({ + checkOpacity: true, + visibilityProperty: true, + checkVisibilityCSS: true, + contentVisibilityAuto: true + }) + + // filter out if not visible + if (! visible) {continue} + + // add to filtered list + new_els.push(els[i]) + } + + // return the filtered list of elements + return new_els; +} + +// attempts to select the currently default selection, if inside a popup +// we'll look for a `.default-selection`, if it doesn't exist we'll +// simply use the first selectable element in it +// +// if not inside a popup we'll just use the currently selected tab in +// the `.gamesContainer` sidebar +navigate.default_selection = () => { + // the spread operator is to convert from a `NodeList` to an + // `Array`, and then we need to reverse this to get the ones + // that are layered on top first. + let popups_list = [...popups.list()].reverse(); + + let active_popup; + + // run through the list of popups + for (let i = 0; i < popups_list.length; i++) { + // if this popup is shown, set set `active_popup` to it + if (popups_list[i].classList.contains("shown")) { + active_popup = popups_list[i]; + break; + } + } + + // is there no active popup? + if (! active_popup) { + // select the currently selected page in `.gamesContainer` + document.querySelector( + ".gamesContainer :not(.inactive)" + ).classList.add("active-selection"); + + // update the `#selection` element + navigate.selection(); + + return; + } + + // get the default element inside the active popup + let popup_default = active_popup.querySelector("default-selection"); + + // did we not find a default selection element? + if (! popup_default) { + // select the first selectable element in the popup + navigate.get_els(active_popup)[0].classList.add( + "active-selection" + ) + + // update the `#selection` element + navigate.selection(); + + return; + } + + // select the default selection + popup_default.classList.add("active-selection"); + + // update the `#selection` element + navigate.selection(); +} + +// this navigates `#selection` in the direction of `direction` +// this can be: up, down, left and right +navigate.move = async (direction) => { + // get the `.active-selection` if there is one + let active = document.querySelector(".active-selection"); + + // if there is no active selection, then attempt to select the + // default selection + if (! active) { + navigate.default_selection() + + active = document.querySelector(".active-selection"); + + // if there is somehow still no active selection we stop here + if (! active) { + return; + } + } + + // is the active selection one that should be scrollable? + if (active.classList.contains("scroll-selection")) { + // scroll the respective `direction` if `active` has any more + // scroll left in that direction + + // short hand to easily scroll in `direction` by `amount` with + // smooth scrolling enabled + let scroll = (direction, amount) => { + // update the `#selection` element + navigate.selection(); + + // scroll inside `<webview>` if the active selection is one + if (active.tagName == "WEBVIEW") { + active.executeJavaScript(` + document.scrollingElement.scrollBy({ + behavior: "smooth", + ${direction}: ${amount} + }) + `) + + return; + } + + active.scrollBy({ + behavior: "smooth", + [direction]: amount + }) + } + + // get values needed for determining if we should scroll the + // active selection, and by how much + let scroll_el = { + top: active.scrollTop, + left: active.scrollLeft, + width: active.scrollWidth, + height: active.scrollHeight, + bounds: { + width: active.clientWidth, + height: active.clientWidth + } + } + + // get `scroll_el` from inside a `<webview>` if the active + // selection is one + if (active.tagName == "WEBVIEW") { + scroll_el = await active.executeJavaScript(`(() => { + return { + top: document.scrollingElement.scrollTop, + left: document.scrollingElement.scrollLeft, + width: document.scrollingElement.scrollWidth, + height: document.scrollingElement.scrollHeight, + bounds: { + width: document.scrollingElement.clientWidth, + height: document.scrollingElement.clientHeight + } + } + })()`) + } + + // decrease to increase scroll length, and in reverse + let scroll_scale = 2; + + if (direction == "up" && scroll_el.top > 0) { + return scroll("top", -scroll_el.bounds.height / scroll_scale); + } + + if (direction == "down" && + scroll_el.top <= scroll_el.height && + scroll_el.height != scroll_el.bounds.height) { + + return scroll("top", scroll_el.bounds.height / scroll_scale); + } + + if (direction == "left" && scroll_el.left > 0) { + return scroll("left", -width / scroll_scale); + } + + if (direction == "right" && + scroll_el.left <= scroll_el.width && + scroll_el.width != scroll_el.bounds.width) { + + return scroll("left", scroll_el.bounds.width / scroll_scale); + } + } + + // attempt to get the element in the `direction` requested + let move_to_el = navigate.get_relative_el(active, direction); + + // if no element is found, do nothing + if (! move_to_el) {return} + + // switch `.active-selection` from `active` to `move_to_el` + active.classList.remove("active-selection"); + move_to_el.classList.add("active-selection"); + + // update the `#selection` element + navigate.selection(); + + // make sure the selecting classes are removed, and thereby the + // scale/pressed effect with it + document.getElementById("selection").classList.remove( + "keyboard-selecting", + "controller-selecting" + ) + + // stop here if `move_to_el` is a child to `document.body` + if (move_to_el.parentElement == document.body) { + return; + } + + // this element will be scrolled in view later + let scroll_el = move_to_el; + + // these elements cant be scrolled + let no_scroll_parents = [ + ".el .text", + ".gamesContainer", + ] + + // run through unscrollable parent elements + for (let i = 0; i < no_scroll_parents.length; i++) { + // check if `move_to_el.closest()` matches on anything in + // `no_scroll_parents` + let no_scroll_parent = move_to_el.closest( + no_scroll_parents[i] + ) + + if (! no_scroll_parent) { + // it does not match + continue; + } + + // it matches, so we make the new `scroll_el` the parent + scroll_el = no_scroll_parent; + } + + // refuse to scroll to begin with, if any of these are a parent + let ignore_parents = [ + ".contentMenu", + ".gamesContainer", + ] + + // check if `ignore_parents` match on `move_to_el`, and if so, stop + for (let i = 0; i < ignore_parents.length; i++) { + if (move_to_el.closest(ignore_parents[i])) { + return; + } + } + + // scroll `scroll_el` smoothly into view, centered + scroll_el.scrollIntoView({ + block: "center", + inline: "center", + behavior: "smooth", + }) +} + +// selects the currently selected element, by clicking or focusing it +navigate.select = () => { + // get the current selection + let active = document.querySelector(".active-selection"); + + // make sure there is a selection + if (! active) {return} + + // slight delay to prevent some timing issues + setTimeout(() => { + // if `active` is a switch, use `settings.popup.switch()` on it, + // to be able to toggle it + if (active.closest(".switch")) { + active.closest(".switch").click(); + settings.popup.switch(active.closest(".switch")); + return; + } + + // click and focus `active` + active.click(); + active.focus(); + }, 150) +} + +// selects the closest and hopefully most correct element to select next +// to `relative_el` in the direction of `direction` +// +// the direction can be: up, down, left and right +navigate.get_relative_el = (relative_el, direction) => { + // get selectable elements + let els = navigate.get_els(); + + // get bounds of `relative_el` + let bounds = relative_el.getBoundingClientRect(); + + // get the centered coordinates of `relative_el` + let relative = { + x: bounds.left + (bounds.width / 2), + y: bounds.top + (bounds.height / 2) + } + + // update the coordinates on the element itself + relative_el.coords = relative; + + // attempt to return the element in the correct direction + // if `x` or `y` is a number that's greater or less than 0 then + // we'll go in the direction of the coordinate that is as such + // + // meaning `get_el(1, 0)` will go to the right + let get_el = (x = 0, y = 0) => { + // `coord` is the coordinate that we're trying to get an element + // on, and `rev_coord` is just the opposite coord + let coord, rev_coord; + + // set `coord` and `rev_coord` according to `x` and `y` + if (x > 0 || x < 0) { + coord = "x"; + rev_coord = "y"; + } else if (y > 0 || y < 0) { + coord = "y"; + rev_coord = "x"; + } else { // something unexpected was given + return false; + } + + // this is the distance between each point which we check for + // selectable elements, increasing this improves performance, + // but lowers accuracy, and likewise in reverse + let jump_distance = 5; + + // this is the coordinates to check in the direct coord check + let check = { + x: relative.x, + y: relative.y + } + + // this will contain the element that directly next to + // `relative_el` from checking every point from `relative_el` + // into `direction` + let direct_el; + + // this is the amount of pixels inbetween `relative_el` and + // `direct_el`, this means it doesn't have the distance from the + // center of `direct_el` or anything included, just the raw + // distance between them, this number could vary in accuracy + // depending on how big or small `jump_distance` is + let direct_distance = 0; + + // attempt to find an element from a straight line from + // `relative_el`, by checking whether there's an element at each + // point in the `direction` specified + while (! direct_el) { + // add `jump_distance` to the coordinates we're checking + check.x += x * jump_distance; + check.y += y * jump_distance; + + // get the elements at the coordinates we're checking + let els_at = document.elementsFromPoint(check.x, check.y); + + // run through all the elements we found + for (let i = 0; i < els_at.length; i++) { + // make sure `els_at[i]` isn't `relative_el` and that + // its a selectable element + if (els_at[i] == relative_el + || ! els.includes(els_at[i])) { + + // not selectable or is `relative_el` + continue; + } + + // set `direct_el` + direct_el = els_at[i]; + + // get the bounds of `direct_el` + let direct_bounds = direct_el.getBoundingClientRect(); + + // get the centered coordinates for `direct_el` + let direct_coords = { + x: direct_bounds.left + (direct_bounds.width / 2), + y: direct_bounds.top + (direct_bounds.height / 2) + } + + // update the coordinates on the element itself + direct_el.coords = direct_coords; + + // get the difference between `relative_el` and + // `direct_el`'s coordinates, effectively their distance + let diff_x = direct_coords.x - relative.x; + let diff_y = direct_coords.y - relative.y; + + // make sure this element is marked as the element that + // was found directly + direct_el.is_direct_el = true; + + // update the distance on the element itself + direct_el.distance = Math.sqrt( + diff_x*diff_x + diff_y*diff_y + ) + + // set the distance on the coord we're checking on the + // element itself + direct_el.coord_distance = direct_distance; + + break; // we found the `direct_el` we can stop now + } + + // if `els_at` has `relative_el` then we reset + // `direct_distance` + if (els_at.includes(relative_el)) { + direct_distance = 0; + } else { + // add `jump_distance` to `direct_distance`, because + // we're no longer on the `relative_el` nor the + // `direct_el` + direct_distance += jump_distance; + } + + // are we beyond the edges of the window + if (check.x < 0 || check.y < 0 || + check.x > innerWidth || check.y > innerHeight) { + + // did we find no elements? + if (! els_at.length) { + break; // stop searching + } + } + } + + // this contains elements in the respective directions + let positions = { + up: [], down: [], + left: [], right: [] + } + + // gets the nearest elements from the selectable elements + for (let i = 0; i < els.length; i++) { + // get bounds + let el_bounds = els[i].getBoundingClientRect(); + + // get centered coordinates + let el_coords = { + x: el_bounds.left + (el_bounds.width / 2), + y: el_bounds.top + (el_bounds.height / 2) + } + + // get the difference between `el_coords` and `direct_el`'s + // coordinates, effectively their distance + let diff_x = el_coords.x - relative.x; + let diff_y = el_coords.y - relative.y; + + // is this element not an element that was previously a + // `direct_el`? + if (! els[i].is_direct_el) { + // update centerd coordinates on the element itself + els[i].coords = el_coords; + + // set the distance on the element itself + els[i].distance = Math.sqrt( + diff_x*diff_x + diff_y*diff_y + ) + + // get and set the distance on the coord we're checking + // on the element itself + els[i].coord_distance = Math.abs( + relative[coord] - el_coords[coord] + ) + } else { + els[i].is_direct_el = false; + continue; + } + + // put `els[i]` in the correct place in `positions` + if (el_coords.x < relative.x) { + positions.left.push(els[i]); + } if (el_coords.x > relative.x) { + positions.right.push(els[i]); + } if (el_coords.y < relative.y) { + positions.up.push(els[i]); + } if (el_coords.y > relative.y) { + positions.down.push(els[i]); + } + } + + // this will contain the element closest to `relative_el` in the + // correct direction, but not necessarily at the same + // coordinates + let closest_el; + + // set `closest_el` to the elements closest element in the + // respective position using `direction` + for (let i = 0; i < positions[direction].length; i++) { + // get the element + let el = positions[direction][i]; + + // is this the first check, or is it closer than the + // previous `closest_el`, then update `closest_el` + if (! closest_el || closest_el.distance > el.distance) { + closest_el = el; + } + } + + // was there found a `closest_el` and `direct_el` + if (closest_el && direct_el) { + // simply return `direct_el` if its the same as `closest_el` + if (closest_el == direct_el) { + return direct_el; + } + + // if the parent element of `closest_el` and `direct_el` is + // the same, then we prefer the `direct_el` + // + // unless the parent is `document.body` + if (closest_el.parentElement == direct_el.parentElement && + direct_el.parentElement !== document.body) { + return direct_el; + } + + // get the difference between `relative_el` and `direct_el` + // on the coordinate of `direction` + let same_coord_diff = Math.abs( + direct_el.coords[rev_coord] - + relative_el.coords[rev_coord] + ) + + // if the difference is less than 3 then we just return the + // `direct_el` as its only a couple pixels off being on the + // same coordinate as `relative_el` + if (same_coord_diff < 3) { + return direct_el; + } + + // get the difference is distance on `direct_el` and + // `closest_el` + let difference = Math.abs( + direct_el.distance - closest_el.distance + ) + + // is the distance les than 50? + if (difference < 50) { + // get the difference between `direct_el` and + // `relative_el` + let direct_diff = Math.abs( + direct_el.coords[rev_coord] - + relative_el.coords[rev_coord] + ) + + // get the difference between `closest_el` and + // `relative_el` + let closest_diff = Math.abs( + closest_el.coords[rev_coord] - + relative_el.coords[rev_coord] + ) + + // if the `direct_el` is closer to `relative_el`, return + // that, otherwise return `closet_el` + if (direct_diff < closest_diff) { + return direct_el; + } else if (closest_diff < direct_diff) { + return closest_el; + } + } + + // is `direct_el` closer than `closest_el` in either + // `.coord_distance` or `.distance` + // + // if not just return `closest_el` + if (direct_el.coord_distance <= closest_el.coord_distance || + direct_el.distance <= closest_el.distance) { + + // if `direct_el` is closer in `.coord_distance` then + // return `direct_el` + if (direct_el.coord_distance <= + closest_el.coord_distance) { + + return direct_el; + } + + // if the difference in `.distance` is less than 50, + // then return `direct_el` + if (difference < 50) { + return direct_el; + } + + // if the `.distance` is overall closer on `direct_el` + // than `closest_el` then we return `direct_el`, + // otherwise we return `closest_el` + if (direct_el.distance < closest_el.distance) { + return direct_el; + } else { + return closest_el; + } + } else { // `direct_el` is unarguably too far away + return closest_el; + } + } else if (! direct_el && ! closest_el) { + // do nothing if no element at all was found + return false; + } + + // return whichever element we did find + return direct_el || closest_el; + } + + // translate `direction` into `get_el()` args + switch(direction) { + case "up": return get_el(0, -1); + case "down": return get_el(0, 1); + case "left": return get_el(-1, 0); + case "right": return get_el(1, 0); + } +} + +// contains a list of the last selections we had before a popup was +// opened, letting us go back to those selections when they're closed +let last_popup_selections = []; + +// attempt to reselect the default selection when a popup is either +// closed or opened +events.on("popup-changed", (e) => { + // get the active selection + let active_el = document.querySelector(".active-selection"); + + // make sure there is a selection + if (! active_el) { + return; + } + + // add `active_el` to `last_popup_selections` if we opened a popup + if (e.new_state) { + last_popup_selections.push({ + el: active_el, + popup: e.popup + }) + } else { // we're closing a popup + // this may contain the element we had opened before the popup + // we're closing was opened + let last_selection; + + // remove selections that are for this popup + last_popup_selections = last_popup_selections.filter((item) => { + // is this selection for this popup? + let is_popup = item.popup == e.popup; + + // set `last_selection` to `.el` if its this popup we're + // closing, thereby getting the last selection made before + // we opened this popup + if (is_popup) { + last_selection = item.el; + } + + return ! is_popup; + }) + + // select `last_selection` if one was found + if (last_selection) { + setTimeout(() => { + navigate.selection(last_selection); + }, 150) // needed due to popup animation + + return; + } + } + + // remove the currently active selection + active_el.classList.remove("active-selection"); + + // update the `#selection` element + navigate.selection(); + + // wait a moment to allow the popup to open or close completely + setTimeout(() => { + // select the default selection + navigate.default_selection(); + }, 300) +}) + +// automatically deselect a selection if its no longer visible +setInterval(() => { + // get the active selection + let active_el = document.querySelector(".active-selection"); + + if (! active_el) {return} + + let visible = active_el.checkVisibility({ + checkOpacity: true, + visibilityProperty: true, + checkVisibilityCSS: true, + contentVisibilityAuto: true + }) + + if (! visible) { + navigate.default_selection(); + } +}, 500) + +module.exports = navigate; |