From 8c6069716a98ff699e4eee54c348ccb07c8f9843 Mon Sep 17 00:00:00 2001 From: Lindely Date: Sat, 31 Aug 2024 21:29:11 +0200 Subject: [PATCH 1/5] Add key binds to Jump for cycling through tabs and sites. --- jumpapp/assets/css/src/_sites.scss | 4 +- jumpapp/assets/css/src/_tags.scss | 2 +- jumpapp/assets/js/src/classes/KeyBinds.js | 125 ++++++++++++++++++++++ jumpapp/assets/js/src/classes/Main.js | 4 + 4 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 jumpapp/assets/js/src/classes/KeyBinds.js diff --git a/jumpapp/assets/css/src/_sites.scss b/jumpapp/assets/css/src/_sites.scss index 60b7edc..340e8eb 100644 --- a/jumpapp/assets/css/src/_sites.scss +++ b/jumpapp/assets/css/src/_sites.scss @@ -25,7 +25,7 @@ $unknown-color: #ccc; position: relative; overflow: hidden; - &:hover { + &:hover, &.active { background-color: #fff; box-shadow: 0 1px 5px rgba(0,0,0,.6); } @@ -125,7 +125,7 @@ $unknown-color: #ccc; padding: 12px; border-radius: 6px; - &:hover { + &:hover, &.active { background-color: #ffffff15; transition: background-color .1s; } diff --git a/jumpapp/assets/css/src/_tags.scss b/jumpapp/assets/css/src/_tags.scss index d17784b..731215b 100644 --- a/jumpapp/assets/css/src/_tags.scss +++ b/jumpapp/assets/css/src/_tags.scss @@ -70,7 +70,7 @@ margin-left: 1px; border-radius: 4px; - &:hover { + &:hover, &.active { background-color: #f3f3f3; transition: background-color .1s; } diff --git a/jumpapp/assets/js/src/classes/KeyBinds.js b/jumpapp/assets/js/src/classes/KeyBinds.js new file mode 100644 index 0000000..3a1f3ae --- /dev/null +++ b/jumpapp/assets/js/src/classes/KeyBinds.js @@ -0,0 +1,125 @@ +/** + * Add key binds to JUMP, allowing the user to navigate + * through tags and sites using their keyboards. When the + * user presses "T", the tag dropdown is opened and the + * arrow keys can be used to cycle through the tags. When + * the tag list is closed, the sites will be navigated + * with the arrow keys instead. Pressing ENTER will open + * the selected tag or site, pressing ESCAPE will deselect + * both active tag and site. The user can also open a site + * by pressing CTRL + number. When a site is found at the + * pressed number, it will be opened. Numbers start at 1 + * and end at 0 (ten). + */ +export default class KeyBinds { + constructor() { + this.keys = new Map(); + this.numericKeys = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"]; + } + + /** + * Initialise the key binds by adding event listeners. + */ + init() { + document.addEventListener("keydown", (e) => { + this.keys.set(e.key, true); + this.process(); + }); + + document.addEventListener("keyup", (e) => { + this.keys.set(e.key, false); + }) + } + + /** + * Determine whether to directly open a site or navigate the tags or sites. + */ + process() { + if (this.keys.get("Control")) { + this.activate_site(); + } else { + this.parse_navigation(); + } + } + + /** + * Activate the site for the number that was pressed on the keyboard. + */ + activate_site() { + // Determine the number that was pressed. + const site = this.numericKeys.find((n) => this.keys.get(n) === true); + + if (site) { + // Convert 0 to 10, so ten shortcuts can be offered with 1 being the first. + const num = site === "0" ? "10" : site; + + // Find the site for the number. When none is found, the result will be null. + const anchor = document.querySelector(`ul.sites li:nth-child(${num}) a`); + + // Trigger the anchor of the site, if any. + if (anchor) { + anchor.click(); + } + } + } + + /** + * Parse the active key to determine how to navigate. + */ + parse_navigation() { + // The T key is bound to opening and closing the tags list. + if (this.keys.get("t")) { + document.getElementById("tags").classList.toggle("enable"); + return; + } + + // The arrow keys will cycle through tags or sites. + if (this.keys.get("ArrowUp") || this.keys.get("ArrowDown") || this.keys.get("ArrowLeft") || this.keys.get("ArrowRight")) { + // Determine whether tags or sites should be cycled through. + if (document.getElementById("tags").classList.contains("enable")) { + this.navigate_elements('#tags', this.keys.get("ArrowDown") || this.keys.get("ArrowRight")); + } else { + this.navigate_elements('ul.sites', this.keys.get("ArrowDown") || this.keys.get("ArrowRight")); + } + return; + } + + // The ENTER key will activate a selected tag or site. + if (this.keys.get("Enter")) { + // Determine whether tags or sites should be triggered. + if (document.getElementById("tags").classList.contains("enable")) { + document.querySelector("#tags a.active").click(); + } else { + document.querySelector("ul.sites a.active").click(); + } + return; + } + + // The ESCAPE key will deselect any active tag or site. + if (this.keys.get('Escape')) { + document.querySelectorAll('a.active').forEach(a => a.classList.remove('active')); + } + } + + /** + * Navigate through tags or sites. + * @param containerSelector {string} The CSS selector for the container in which the anchors can be found. + * @param forward {boolean} Whether we are cycling forward through the list of anchors or not. + */ + navigate_elements(containerSelector, forward) { + let newEl; + + // Find the currently active anchor. When found, the next link will be determined, otherwise + // the first or last, based on the value of "forward", will be selected. + const activeEl = document.querySelector(`${containerSelector} a.active`); + + if (activeEl) { + activeEl.classList.toggle("active"); + newEl = (forward ? activeEl.parentElement.nextElementSibling : activeEl.parentElement.previousElementSibling)?.querySelector("a"); + } else { + newEl = document.querySelector(`${containerSelector} li:${forward ? 'first' : 'last'}-of-type a`); + } + + newEl?.classList.toggle("active"); + } +} \ No newline at end of file diff --git a/jumpapp/assets/js/src/classes/Main.js b/jumpapp/assets/js/src/classes/Main.js index ff48cbf..1d874a8 100644 --- a/jumpapp/assets/js/src/classes/Main.js +++ b/jumpapp/assets/js/src/classes/Main.js @@ -14,6 +14,7 @@ import Clock from './Clock'; import EventEmitter from 'eventemitter3'; import Fuse from 'fuse.js'; import Greeting from './Greeting'; +import KeyBinds from './KeyBinds'; import SearchSuggestions from './SearchSuggestions'; import Weather from './Weather'; @@ -47,6 +48,7 @@ export default class Main { this.eventemitter = new EventEmitter(); this.clock = new Clock(this.eventemitter, !!JUMP.ampmclock, !JUMP.owmapikey); this.weather = new Weather(this.eventemitter); + this.keyBinds = new KeyBinds(); if (this.showsearchbuttonelm) { this.searchclosebuttonelm = this.showsearchbuttonelm.querySelector('.close'); @@ -245,6 +247,8 @@ export default class Main { } }); } + + this.keyBinds.init(); } search_close() { From b28590f49eeba66b058a20de98c41e19c52a96a0 Mon Sep 17 00:00:00 2001 From: Lindely Date: Sat, 31 Aug 2024 21:53:24 +0200 Subject: [PATCH 2/5] Properly check for active element and fix cycling method. --- jumpapp/assets/js/src/classes/KeyBinds.js | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/jumpapp/assets/js/src/classes/KeyBinds.js b/jumpapp/assets/js/src/classes/KeyBinds.js index 3a1f3ae..9a443c2 100644 --- a/jumpapp/assets/js/src/classes/KeyBinds.js +++ b/jumpapp/assets/js/src/classes/KeyBinds.js @@ -88,16 +88,17 @@ export default class KeyBinds { if (this.keys.get("Enter")) { // Determine whether tags or sites should be triggered. if (document.getElementById("tags").classList.contains("enable")) { - document.querySelector("#tags a.active").click(); + document.querySelector("#tags a.active")?.click(); } else { - document.querySelector("ul.sites a.active").click(); + document.querySelector("ul.sites a.active")?.click(); } return; } - - // The ESCAPE key will deselect any active tag or site. + + // The ESCAPE key will deselect any active tag or site and close the tags list. if (this.keys.get('Escape')) { document.querySelectorAll('a.active').forEach(a => a.classList.remove('active')); + document.getElementById("tags").classList.remove("enable"); } } @@ -113,13 +114,17 @@ export default class KeyBinds { // the first or last, based on the value of "forward", will be selected. const activeEl = document.querySelector(`${containerSelector} a.active`); + // Function that returns the default element that should be activated if none is found by + // other means. + const getDefault = () => document.querySelector(`${containerSelector} li:${forward ? 'first' : 'last'}-of-type a`); + if (activeEl) { activeEl.classList.toggle("active"); - newEl = (forward ? activeEl.parentElement.nextElementSibling : activeEl.parentElement.previousElementSibling)?.querySelector("a"); + newEl = (forward ? activeEl.parentElement.nextElementSibling : activeEl.parentElement.previousElementSibling)?.querySelector("a") || getDefault(); } else { - newEl = document.querySelector(`${containerSelector} li:${forward ? 'first' : 'last'}-of-type a`); + newEl = getDefault(); } newEl?.classList.toggle("active"); } -} \ No newline at end of file +} From d87bc5b5cb98d6f5f2d291091f7c1001251f3675 Mon Sep 17 00:00:00 2001 From: Lindely Date: Sat, 31 Aug 2024 22:00:29 +0200 Subject: [PATCH 3/5] Be consistent with quotes. --- jumpapp/assets/js/src/classes/KeyBinds.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/jumpapp/assets/js/src/classes/KeyBinds.js b/jumpapp/assets/js/src/classes/KeyBinds.js index 9a443c2..b47d67d 100644 --- a/jumpapp/assets/js/src/classes/KeyBinds.js +++ b/jumpapp/assets/js/src/classes/KeyBinds.js @@ -1,5 +1,5 @@ /** - * Add key binds to JUMP, allowing the user to navigate + * Add key binds to Jump, allowing the user to navigate * through tags and sites using their keyboards. When the * user presses "T", the tag dropdown is opened and the * arrow keys can be used to cycle through the tags. When @@ -28,7 +28,7 @@ export default class KeyBinds { document.addEventListener("keyup", (e) => { this.keys.set(e.key, false); - }) + }); } /** @@ -77,9 +77,9 @@ export default class KeyBinds { if (this.keys.get("ArrowUp") || this.keys.get("ArrowDown") || this.keys.get("ArrowLeft") || this.keys.get("ArrowRight")) { // Determine whether tags or sites should be cycled through. if (document.getElementById("tags").classList.contains("enable")) { - this.navigate_elements('#tags', this.keys.get("ArrowDown") || this.keys.get("ArrowRight")); + this.navigate_elements("#tags", this.keys.get("ArrowDown") || this.keys.get("ArrowRight")); } else { - this.navigate_elements('ul.sites', this.keys.get("ArrowDown") || this.keys.get("ArrowRight")); + this.navigate_elements("ul.sites", this.keys.get("ArrowDown") || this.keys.get("ArrowRight")); } return; } @@ -96,8 +96,8 @@ export default class KeyBinds { } // The ESCAPE key will deselect any active tag or site and close the tags list. - if (this.keys.get('Escape')) { - document.querySelectorAll('a.active').forEach(a => a.classList.remove('active')); + if (this.keys.get("Escape")) { + document.querySelectorAll("a.active").forEach(a => a.classList.remove("active")); document.getElementById("tags").classList.remove("enable"); } } @@ -116,7 +116,7 @@ export default class KeyBinds { // Function that returns the default element that should be activated if none is found by // other means. - const getDefault = () => document.querySelector(`${containerSelector} li:${forward ? 'first' : 'last'}-of-type a`); + const getDefault = () => document.querySelector(`${containerSelector} li:${forward ? "first" : "last"}-of-type a`); if (activeEl) { activeEl.classList.toggle("active"); From 6a5b20360a65ee8331f2dcba94de7856aef30c8d Mon Sep 17 00:00:00 2001 From: Lindely Date: Sun, 1 Sep 2024 01:02:46 +0200 Subject: [PATCH 4/5] Highlight the active tag when opening the tag menu. --- jumpapp/assets/js/src/classes/KeyBinds.js | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/jumpapp/assets/js/src/classes/KeyBinds.js b/jumpapp/assets/js/src/classes/KeyBinds.js index b47d67d..f6e6904 100644 --- a/jumpapp/assets/js/src/classes/KeyBinds.js +++ b/jumpapp/assets/js/src/classes/KeyBinds.js @@ -69,7 +69,24 @@ export default class KeyBinds { parse_navigation() { // The T key is bound to opening and closing the tags list. if (this.keys.get("t")) { - document.getElementById("tags").classList.toggle("enable"); + const tagsEl = document.getElementById("tags"); + tagsEl.classList.toggle("enable"); + + // Mark the active tag so the user is aware where navigation begins. + if (tagsEl.classList.contains("enable") && tagsEl.querySelector('a.active') === null) { + // Try and read the tag from the URL. If that fails, select the first one. + const tag = document.location.pathname.split('/').filter(x => !!x).pop(); + + if (tag) { + tagsEl.querySelectorAll("a").forEach(a => { + if (a.textContent === tag) { + a.classList.add("active"); + } + }); + } else { + tagsEl.querySelector("a").classList.add("active"); + } + } return; } From ef77ff58914cf7350244147138bc0ac19c194754 Mon Sep 17 00:00:00 2001 From: Lindely Date: Sun, 1 Sep 2024 11:52:12 +0200 Subject: [PATCH 5/5] Take the search window into account when processing key events. --- jumpapp/assets/js/src/classes/KeyBinds.js | 140 ++++++++++++++++------ 1 file changed, 103 insertions(+), 37 deletions(-) diff --git a/jumpapp/assets/js/src/classes/KeyBinds.js b/jumpapp/assets/js/src/classes/KeyBinds.js index f6e6904..9063412 100644 --- a/jumpapp/assets/js/src/classes/KeyBinds.js +++ b/jumpapp/assets/js/src/classes/KeyBinds.js @@ -1,13 +1,15 @@ /** * Add key binds to Jump, allowing the user to navigate * through tags and sites using their keyboards. When the + * user presses "S", the search box is opened. When the * user presses "T", the tag dropdown is opened and the * arrow keys can be used to cycle through the tags. When * the tag list is closed, the sites will be navigated * with the arrow keys instead. Pressing ENTER will open * the selected tag or site, pressing ESCAPE will deselect - * both active tag and site. The user can also open a site - * by pressing CTRL + number. When a site is found at the + * both active tag and site and close both the search box + * and the tag list. The user can also open a site by + * pressing CTRL + number. When a site is found at the * pressed number, it will be opened. Numbers start at 1 * and end at 0 (ten). */ @@ -15,6 +17,16 @@ export default class KeyBinds { constructor() { this.keys = new Map(); this.numericKeys = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"]; + this.searchOpen = false; + this.activeEvent = null; + + this.tags = document.getElementById("tags"); + this.search = document.querySelector(".search"); + + // Watch for the opening of the search box. When it's open, we shouldn't interfere with the input. + this.observer = new MutationObserver((mutations) => { + this.searchOpen = mutations.some((m) => m.attributeName === "class" && m.target?.classList.contains("open")); + }); } /** @@ -22,13 +34,20 @@ export default class KeyBinds { */ init() { document.addEventListener("keydown", (e) => { + this.activeEvent = e; this.keys.set(e.key, true); this.process(); }); document.addEventListener("keyup", (e) => { + this.activeEvent = null; this.keys.set(e.key, false); }); + + if (this.search) { + this.observer.disconnect(); + this.observer.observe(this.search, {attributes: true}); + } } /** @@ -67,55 +86,102 @@ export default class KeyBinds { * Parse the active key to determine how to navigate. */ parse_navigation() { + // The ESCAPE key will deselect any active tag or site and close the tags list and search window. + if (this.keys.get("Escape")) { + return this.escape_press(); + } + + // Ignore any of the following when the search window is open. + if (this.searchOpen) { + return; + } + + // The S key is bound to opening the search window. + if (this.keys.get("s")) { + return this.toggle_search(); + } + // The T key is bound to opening and closing the tags list. if (this.keys.get("t")) { - const tagsEl = document.getElementById("tags"); - tagsEl.classList.toggle("enable"); - - // Mark the active tag so the user is aware where navigation begins. - if (tagsEl.classList.contains("enable") && tagsEl.querySelector('a.active') === null) { - // Try and read the tag from the URL. If that fails, select the first one. - const tag = document.location.pathname.split('/').filter(x => !!x).pop(); - - if (tag) { - tagsEl.querySelectorAll("a").forEach(a => { - if (a.textContent === tag) { - a.classList.add("active"); - } - }); - } else { - tagsEl.querySelector("a").classList.add("active"); - } - } - return; + return this.toggle_tags(); } // The arrow keys will cycle through tags or sites. if (this.keys.get("ArrowUp") || this.keys.get("ArrowDown") || this.keys.get("ArrowLeft") || this.keys.get("ArrowRight")) { - // Determine whether tags or sites should be cycled through. - if (document.getElementById("tags").classList.contains("enable")) { - this.navigate_elements("#tags", this.keys.get("ArrowDown") || this.keys.get("ArrowRight")); - } else { - this.navigate_elements("ul.sites", this.keys.get("ArrowDown") || this.keys.get("ArrowRight")); - } - return; + return this.arrow_press(); } // The ENTER key will activate a selected tag or site. if (this.keys.get("Enter")) { - // Determine whether tags or sites should be triggered. - if (document.getElementById("tags").classList.contains("enable")) { - document.querySelector("#tags a.active")?.click(); + return this.enter_press(); + } + } + + /** + * Close both search and tag windows. + */ + escape_press() { + document.querySelectorAll("a.active").forEach(a => a.classList.remove("active")); + this.tags.classList.remove("enable"); + this.search?.classList.remove("open", "suggestions"); + this.search?.querySelector(".suggestion-list").childNodes.forEach((n) => n.parentNode.removeChild(n)); + } + + /** + * Process the user pressing the ENTER button. Depending on the context, a tag or site link is clicked. + */ + enter_press() { + // Determine whether tags or sites should be triggered. + if (this.tags.classList.contains("enable")) { + this.tags.querySelector("a.active")?.click(); + } else { + document.querySelector("ul.sites a.active")?.click(); + } + } + + /** + * Process the user pressing an arrow button, which cycles through either tags or sites. + */ + arrow_press() { + // Determine whether tags or sites should be cycled through. + if (this.tags.classList.contains("enable")) { + this.navigate_elements("#tags", this.keys.get("ArrowDown") || this.keys.get("ArrowRight")); + } else { + this.navigate_elements("ul.sites", this.keys.get("ArrowDown") || this.keys.get("ArrowRight")); + } + } + + /** + * Toggle the visibility of the tags list. + */ + toggle_tags() { + this.tags.classList.toggle("enable"); + + // Mark the active tag so the user is aware where navigation begins. + if (this.tags.classList.contains("enable") && this.tags.querySelector("a.active") === null) { + // Try and read the tag from the URL. If that fails, select the first one. + const tag = document.location.pathname.split("/").filter(x => !!x).pop(); + + if (tag) { + this.tags.querySelectorAll("a").forEach(a => { + if (a.textContent === tag) { + a.classList.add("active"); + } + }); } else { - document.querySelector("ul.sites a.active")?.click(); + this.tags.querySelector("a").classList.add("active"); } - return; } + } - // The ESCAPE key will deselect any active tag or site and close the tags list. - if (this.keys.get("Escape")) { - document.querySelectorAll("a.active").forEach(a => a.classList.remove("active")); - document.getElementById("tags").classList.remove("enable"); + /** + * Toggle the visibility of the search box, if it exists. + */ + toggle_search() { + if (this.search) { + this.activeEvent?.preventDefault(); + this.search.classList.add("open"); + this.search.querySelector("input[type=\"search\"]").focus(); } }