diff options
author | 0neGal <mail@0negal.com> | 2024-12-20 01:55:12 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-12-20 01:55:12 +0100 |
commit | 14c12f9bf74f7ae7e9b4b873e67b8e9769cdad1e (patch) | |
tree | 2a8c0535a22264f4f3d37d684231d2ac147ac199 | |
parent | bd4391f737ee4405fc390ab47c4124fe28df48d7 (diff) | |
parent | a8ba1b376ff16a69c52f223341a2ed39fd59a8ea (diff) | |
download | Viper-14c12f9bf74f7ae7e9b4b873e67b8e9769cdad1e.tar.gz Viper-14c12f9bf74f7ae7e9b4b873e67b8e9769cdad1e.zip |
Merge pull request #239 from 0neGal/gamepad-support
feat: Gamepad/keyboard navigation support
-rw-r--r-- | src/app/css/launcher.css | 11 | ||||
-rw-r--r-- | src/app/css/selection.css | 23 | ||||
-rw-r--r-- | src/app/css/theming.css | 2 | ||||
-rw-r--r-- | src/app/index.html | 14 | ||||
-rw-r--r-- | src/app/js/dom_events.js | 6 | ||||
-rw-r--r-- | src/app/js/gamepad.js | 333 | ||||
-rw-r--r-- | src/app/js/launcher.js | 24 | ||||
-rw-r--r-- | src/app/js/navigate.js | 902 | ||||
-rw-r--r-- | src/app/js/settings.js | 9 | ||||
-rw-r--r-- | src/app/js/tooltip.js | 2 | ||||
-rw-r--r-- | src/app/main.css | 1 | ||||
-rw-r--r-- | src/app/main.js | 3 |
12 files changed, 1308 insertions, 22 deletions
diff --git a/src/app/css/launcher.css b/src/app/css/launcher.css index c43bce1..47ecb25 100644 --- a/src/app/css/launcher.css +++ b/src/app/css/launcher.css @@ -34,9 +34,9 @@ transition: 0.3s ease-in-out; background-color: transparent; - margin: 20px; position: relative; + border-radius: 0px; box-sizing: border-box; flex-basis: calc(100% - 10px); } @@ -111,7 +111,8 @@ .contentMenu li:last-child {margin-right: 0px} .contentMenu li:first-child {margin-left: 0px} -.contentMenu li:hover {opacity: 0.7} +.contentMenu li:hover, +.contentMenu li.active-selection {opacity: 0.7} .contentMenu li[active] { opacity: 1.0; @@ -305,7 +306,8 @@ button:has(img):has(span) img { transition: opacity 0.2s ease-in-out; } -button:has(img):has(span):hover img { +button:has(img):has(span):hover img, +button:has(img):has(span).active-selection img { opacity: 1.0; } @@ -315,7 +317,8 @@ button:has(img):has(span) span { transition: right 0.2s ease-in-out; } -button:has(img):has(span):hover span { +button:has(img):has(span):hover span, +button:has(img):has(span).active-selection span { right: 0.1em; } diff --git a/src/app/css/selection.css b/src/app/css/selection.css new file mode 100644 index 0000000..5b7d647 --- /dev/null +++ b/src/app/css/selection.css @@ -0,0 +1,23 @@ +#selection { + z-index: 99999999999999; + + transition: 0.15s ease-in-out; + transition-property: + opacity, border-radius, background, + transform, width, height, top, left; + + position: fixed; + pointer-events: none; + transform: scale(1.0); + background: rgba(255, 255, 255, 0.3); + border-radius: calc(var(--padding) / 2); +} + +:has(.active-selection.scroll-selection) #selection { + background: rgba(255, 255, 255, 0.2); +} + +#selection.keyboard-selecting, +#selection.controller-selecting { + transform: scale(1.1); +} diff --git a/src/app/css/theming.css b/src/app/css/theming.css index fb8fbf7..c0b6497 100644 --- a/src/app/css/theming.css +++ b/src/app/css/theming.css @@ -42,7 +42,7 @@ a.disabled:not("[onclick='kill('game')']") { pointer-events: none; } -a:hover { +a:hover, a.active-selection { filter: brightness(80%) !important; } diff --git a/src/app/index.html b/src/app/index.html index ca80c86..9e97810 100644 --- a/src/app/index.html +++ b/src/app/index.html @@ -6,6 +6,8 @@ <meta charset="utf-8"> </head> <body> + <div id="selection"></div> + <div id="tooltip">Test</div> <div id="bgHolder"></div> @@ -33,7 +35,7 @@ <div id="overlay" onclick="popups.set_all(false)"></div> <div class="popup" id="options"> <div class="misc"> - <input class="search" placeholder="%%gui.search%%"> + <input class="search default-selection" placeholder="%%gui.search%%"> <button id="apply" onclick="settings.popup.apply();settings.popup.toggle(false)"> <img src="icons/apply.png"> %%gui.settings.save%% @@ -251,7 +253,7 @@ </div> <div class="misc"> - <input class="search" placeholder="%%gui.search%%"> + <input class="search default-selection" placeholder="%%gui.search%%"> <button id="filter" onclick="browser.filters.toggle()"> <img src="icons/filter.png"> </button> @@ -264,7 +266,7 @@ </div> </div> <div class="popup small blur" id="preview"> - <div class="misc fixed vertical"> + <div class="misc fixed vertical default-selection"> <button id="close" onclick="browser.preview.hide()"> <img src="icons/close.png"> </button> @@ -273,7 +275,7 @@ </button> </div> - <webview></webview> + <webview class="scroll-selection"></webview> </div> <nav class="gamesContainer"> @@ -297,7 +299,7 @@ <a id="setpath" href="#" onclick="gamepath.set()" class="disable-when-installing">%%gui.setpath%%</a> </div> </div> - <div id="vpReleaseNotes" class="hidden section"></div> + <div id="vpReleaseNotes" class="scroll-selection hidden section"></div> <div id="vpInfo" class="hidden section"> <h2>%%viper.info.links%%</h2> <ul> @@ -357,7 +359,7 @@ </div> </div> </div> - <div id="nsRelease" class="hidden section"></div> + <div id="nsRelease" class="scroll-selection hidden section"></div> </div> </div> diff --git a/src/app/js/dom_events.js b/src/app/js/dom_events.js index ab79c57..490d2ec 100644 --- a/src/app/js/dom_events.js +++ b/src/app/js/dom_events.js @@ -26,12 +26,6 @@ document.addEventListener("drop", (e) => { mods.install_from_path(e.dataTransfer.files[0].path); }) -document.body.addEventListener("keyup", (e) => { - if (e.key == "Escape") { - popups.hide_last(); - } -}) - document.body.addEventListener("click", (e) => { if (e.target.tagName.toLowerCase() === "a" && e.target.protocol != "file:") { diff --git a/src/app/js/gamepad.js b/src/app/js/gamepad.js new file mode 100644 index 0000000..69ca13f --- /dev/null +++ b/src/app/js/gamepad.js @@ -0,0 +1,333 @@ +const popups = require("./popups"); +const settings = require("./settings"); +const launcher = require("./launcher"); +const navigate = require("./navigate"); + +window.addEventListener("gamepadconnected", (e) => { + console.log("Gamepad connected:", e.gamepad.id); +}, false) + +window.addEventListener("gamepaddisconnected", (e) => { + console.log("Gamepad disconnected:", e.gamepad.id); +}, false) + +// this contains the names/directions of axes and IDs that have +// previously been pressed, if it is found that these were recently +// pressed in the next iteration of the `setInterval()` below than the +// iteration is skipped +// +// the value of each item is equivalent to the amount of iterations to +// wait, so `up: 3` will cause it to wait 3 iterations, before `up` can +// be pressed again +let delay_press = {}; + +let held_buttons = {}; + +setInterval(() => { + let gamepads = navigator.getGamepads(); + + // this has a list of all the directions that the `.axes[]` are + // pointing in, letting us navigate in that direction + let directions = {} + + // keeps track of which buttons `delay_press` that have already been + // lowered, that way we can lower the ones that haven't been lowered + // through a button press + let lowered_delay = []; + + // is the select/accept button being held + let selecting = false; + + for (let i in gamepads) { + if (! gamepads[i]) {continue} + // every other `.axes[]` element is a different coordinate, each + // analog stick has 2 elements in `.axes[]`, the first one is + // the x coordinate, second is the y coordinate + // + // so we use this to keep track of which coordinate we're + // currently on, and thereby the direction of the float inside + // `.axes[i]` + let coord = "x"; + let deadzone = 0.5; + + for (let ii = 0; ii < gamepads[i].axes.length; ii++) { + let value = gamepads[i].axes[ii]; + + // check if we're beyond the deadzone in both the negative + // and positive direction, and then using `coord` add a + // direction to `directions` + if (value < -deadzone) { + if (coord == "y") { + directions.up = true; + } else { + directions.left = true; + } + } else if (value > deadzone) { + if (coord == "y") { + directions.down = true; + } else { + directions.right = true; + } + } + + // flip `coord` + if (coord == "x") { + coord = "y"; + } else { + coord = "x"; + } + } + + // only support "standard" button layouts/mappings + // + // TODO: for anybody reading this in the future, the support + // for other mappings is something that's on the table, + // however, due to not having all the hardware in the world, + // this will have to be up to someone else + if (gamepads[i].mapping != "standard") { + continue; + } + + for (let ii = 0; ii < gamepads[i].buttons.length; ii++) { + if (! gamepads[i].buttons[ii].pressed) { + held_buttons[ii] = false; + continue; + } + + // a list of known combinations of buttons for the most + // common brands out there, more should possibly be added + let brands = { + "Xbox": { + accept: 0, + cancel: 1 + }, + "Nintendo": { + accept: 1, + cancel: 0 + }, + "PlayStation": { + accept: 0, + cancel: 1 + } + } + + // this is the most common setup, to my understanding, with + // the exception of third party Nintendo controller, may + // need to be adjusted in the future + let buttons = { + accept: 0, + cancel: 1 + } + + // set `cancel` and `accept` accordingly to the ID of the + // gamepad, if its a known brand + for (let brand in brands) { + // unknown brand + if (! gamepads[i].id.includes(brand)) { + continue; + } + + // set buttons according to brand + buttons = brands[brand]; + break; + } + + // if the button that's being pressed is the "accept" + // button, then we set `selecting` to `true`, this is done + // before we check for the button delay so that holding the + // button keeps the selection in place, until the button is + // no longer pressed + if (ii == buttons.accept) { + selecting = true; + } + + // if this button is still delayed, we lower the delay and + // then go to the next button + if (delay_press[ii]) { + delay_press[ii]--; + lowered_delay.push(ii); + continue; + } + + // add delay to this button, so it doesn't get clicked + // immediately again after this + delay_press[ii] = 3; + + if (held_buttons[ii]) { + continue; + } + + held_buttons[ii] = true; + + // interpret `ii` as a specific button/action, using the + // standard IDs: https://w3c.github.io/gamepad/#remapping + switch(ii) { + // settings popup (center cluster buttons) + case 8: settings.popup.toggle(); break; + case 9: settings.popup.toggle(); break; + + // change active section (top bumpers) + case 4: launcher.relative_section("left"); break; + case 5: launcher.relative_section("right"); break; + + // navigate selection (dpad) + case 12: navigate.move("up"); break; + case 13: navigate.move("down"); break; + case 14: navigate.move("left"); break; + case 15: navigate.move("right"); break; + + // click selected element + case buttons.accept: navigate.select(); break; + + // close last opened popup + case buttons.cancel: popups.hide_last(); break; + } + } + } + + for (let i in directions) { + if (directions[i] === true) { + // if this direction is still delayed, we lower the delay, + // and then go to the next direction + if (delay_press[i]) { + delay_press[i]--; + lowered_delay.push(i); + continue; + } + + // move in the direction + navigate.move(i); + + // add delay to this direction, to prevent it from being + // triggered immediately again + delay_press[i] = 5; + } + } + + // run through buttons that have or have had a delay + for (let i in delay_press) { + // if a button has a delay, and it hasn't already been lowered, + // then we lower it + if (delay_press[i] && ! lowered_delay.includes(i)) { + delay_press[i]--; + } + } + + let selection_el = document.getElementById("selection"); + + // add `.selecting` to `#selection` depending on whether + // `selecting`, is set or not + if (selecting) { + selection_el.classList.add("controller-selecting"); + } else { + selection_el.classList.remove("controller-selecting"); + } +}, 50) + + +let can_keyboard_navigate = (e) => { + // quite empty right now, might add more in the future, these are + // just element selectors where movement with the keyboard is off + let ignore_on_focus = [ + "input", + "select" + ] + + // check for whether the active element is one that matches + // something in `ignore_on_focus` + for (let i = 0; i < ignore_on_focus.length; i++) { + if (! document.activeElement.matches(ignore_on_focus)) { + // active element does not match to `ignore_on_focus[i]` + continue; + } + + // if the key that's being pressed is "Escape" then we unfocus + // to the currently focused active element, this lets you go + // into an input, and then exit it as well + if (e.key == "Escape") { + document.activeElement.blur(); + } + + return false; + } + + // check if there's already an active selection + if (document.querySelector(".active-selection")) { + // this is a list of keys where this keyboard event will be + // cancelled on, this prevents key events from being sent to + // element, but still lets you type + let cancel_keys = [ + "Space", "Enter", + "ArrowUp", "ArrowDown", + "ArrowLeft", "ArrowRight" + ] + + // cancel this keyboard event if `e.key` is inside `cancel_keys` + if (cancel_keys.includes(e.code)) { + e.preventDefault(); + } + } + + return true; +} + +window.addEventListener("keydown", (e) => { + // do nothing if we cant navigate + if (! can_keyboard_navigate(e)) { + return; + } + + let select = () => { + // do nothing if this is a repeat key press + if (e.repeat) {return} + + // select `.active-selection` + navigate.select(); + + // add `.keyboard-selecting` to `#selection` + document.getElementById("selection") + .classList.add("keyboard-selecting"); + } + + // perform the relevant action for the key that was pressed + switch(e.code) { + // select + case "Space": return select(); + case "Enter": return select(); + + // close popup + case "Escape": return popups.hide_last(); + + // move selection + case "KeyK": case "ArrowUp": return navigate.move("up") + case "KeyJ": case "ArrowDown": return navigate.move("down") + case "KeyH": case "ArrowLeft": return navigate.move("left") + case "KeyL": case "ArrowRight": return navigate.move("right") + } +}) + +window.addEventListener("keyup", (e) => { + if (! can_keyboard_navigate(e)) { + return; + } + + let selection_el = document.getElementById("selection"); + + // perform the relevant action for the key that was pressed + switch(e.code) { + // the second and third cases here are for SteamDeck bumper + // button support whilst inside the desktop layout + case "KeyQ": + case "ControlLeft": case "ControlRight": + launcher.relative_section("left"); break; + case "KeyE": + case "AltLeft": case "AltRight": + launcher.relative_section("right"); break; + + case "Space": return selection_el + .classList.remove("keyboard-selecting"); + + case "Enter": return selection_el + .classList.remove("keyboard-selecting"); + } +}) diff --git a/src/app/js/launcher.js b/src/app/js/launcher.js index dc99792..d249d34 100644 --- a/src/app/js/launcher.js +++ b/src/app/js/launcher.js @@ -1,3 +1,4 @@ +const popups = require("./popups"); const markdown = require("marked").parse; let launcher = {}; @@ -168,6 +169,11 @@ launcher.show_ns = (section) => { // // `direction` can be: left or right launcher.relative_section = (direction) => { + // prevent switching section if a popup is open + if (popups.open_list().length) { + return; + } + // the `.contentMenu` in the currently active tab let active_menu = document.querySelector( ".contentContainer:not(.hidden) .contentMenu" @@ -203,18 +209,30 @@ launcher.relative_section = (direction) => { } } + let new_section; + // if we're going left, and a previous section was found, click it if (direction == "left" && prev_section) { - prev_section.click(); + new_section = prev_section; } else if (direction == "right") { // click the next section, if one was found, otherwise just // assume that the first section is the next section, as the // active section is likely just the last section, so we wrap // around instead if (next_section) { - next_section.click(); + new_section = next_section; } else if (sections[0]) { - sections[0].click(); + new_section = sections[0]; + } + } + + if (new_section) { + new_section.click(); + + // if there's an active selection, we select the new section, as + // that selection may be in a section that's now hidden + if (document.querySelector(".active-selection")) { + navigate.selection(new_section); } } } diff --git a/src/app/js/navigate.js b/src/app/js/navigate.js new file mode 100644 index 0000000..18c32b3 --- /dev/null +++ b/src/app/js/navigate.js @@ -0,0 +1,902 @@ +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")) { + 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; diff --git a/src/app/js/settings.js b/src/app/js/settings.js index 3aa9c43..b5ef773 100644 --- a/src/app/js/settings.js +++ b/src/app/js/settings.js @@ -286,6 +286,15 @@ settings.popup.load = () => { } settings.popup.switch = (el, state) => { + if (! el) {return} + + // prevent switches from being switched when disabled + if (el.getAttribute("disabled") != null + || el.classList.contains("disabled")) { + + return; + } + if (state) { return el.classList.add("on"); } else if (state === false) { diff --git a/src/app/js/tooltip.js b/src/app/js/tooltip.js index f2d97a0..13dcd67 100644 --- a/src/app/js/tooltip.js +++ b/src/app/js/tooltip.js @@ -128,5 +128,3 @@ document.addEventListener("mousedown", tooltip_event); document.addEventListener("mousemove", tooltip_event); document.addEventListener("mouseenter", tooltip_event); document.addEventListener("mouseleave", tooltip_event); - -module.exports = tooltip; diff --git a/src/app/main.css b/src/app/main.css index 3f77ac4..9ade423 100644 --- a/src/app/main.css +++ b/src/app/main.css @@ -5,6 +5,7 @@ @import "css/tooltip.css"; @import "css/theming.css"; @import "css/launcher.css"; +@import "css/selection.css"; body { margin: 0; diff --git a/src/app/main.js b/src/app/main.js index 3adb96d..0a84e66 100644 --- a/src/app/main.js +++ b/src/app/main.js @@ -44,5 +44,8 @@ const launcher = require("./js/launcher"); const is_running = require("./js/is_running"); const set_buttons = require("./js/set_buttons"); +const navigate = require("./js/navigate"); +const gamepad = require("./js/gamepad"); + require("./js/dom_events"); require("./js/localize")(); |