Instantly share code, notes, and snippets.
Last active
January 10, 2026 13:20
-
Star
0
(0)
You must be signed in to star a gist -
Fork
0
(0)
You must be signed in to fork a gist
-
-
Save gaabora/df6d1de8c5c4b267bb67cfb9dc252b40 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| .// ==UserScript== | |
| // @name Keyboard Navigation | |
| // @namespace none | |
| // @version 1.0 | |
| // @match *://*/* | |
| // @match *://animepahe.*/* | |
| // @grant none | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| /* ================= RULES ================= */ | |
| const SITE_NAV_CONFIG = [ | |
| { | |
| url: 'https://animepahe.*/.*', | |
| includeItems: ['.btn-group .btn', '.click-to-load'], | |
| excludeItems: ['.anime-content'], | |
| includeGroups: ['.player', 'form', '.theatre-settings'], | |
| excludeGroups: ['body', 'section', 'article', 'nav'], | |
| bindings: [{'PageDown': '.sequel a'}, {'PageUp': '.prequel a'}] | |
| } | |
| ]; | |
| /* ================ CONFIG ================ */ | |
| const KEYBOARD_LAYOUT = [ | |
| ['q','w','e','r','t','y','u','i','o','p'], | |
| ['a','s','d','f','g','h','j','k','l'], | |
| ['z','x','c','v','b','n','m'], | |
| ['SPACE','BACK','ENTER'] | |
| ]; | |
| /* ================= SELECTORS ================= */ | |
| const DEFAULT_ITEM_SELECTORS = [ | |
| 'button', | |
| 'a[href]', | |
| '[role="button"]', | |
| '[role="menuitem"]', | |
| '[role="tab"]', | |
| 'input:not([type=hidden])', | |
| 'textarea', | |
| '[contenteditable="true"]', | |
| 'video', | |
| 'iframe[src]', | |
| ]; | |
| const DEFAULT_GROUP_SELECTORS = [ | |
| 'body', | |
| 'nav', | |
| 'main', | |
| 'section', | |
| 'article', | |
| 'aside', | |
| '[role="navigation"]', | |
| '[role="main"]', | |
| '[role="dialog"]', | |
| '[role="menu"]' | |
| ]; | |
| /* ================= STATE ================= */ | |
| let treeRoot = null; | |
| let XX_currentGroup = null; | |
| let XX_currentIndex = 0; | |
| let mode = 'nav'; | |
| let navTable = []; | |
| let navTablePos = { g: 0, i: 0 }; | |
| let osk = null; | |
| let oskPos = { r: 0, c: 0 }; | |
| let activeInput = null; | |
| /* ================ CSS INJECT ================ */ | |
| const style = document.createElement('style'); | |
| style.textContent = ` | |
| :focus { | |
| outline: 3px solid #violet !important; | |
| outline-offset: 2px !important; | |
| } | |
| .__KEYNAV_INCLUDE { | |
| border: 1px dotted green !important; | |
| } | |
| .__KEYNAV_EXCLUDE { | |
| border: 1px dotted red !important; | |
| } | |
| .__KEYNAV_ELEMENT { | |
| outline: none; | |
| border: 1px dotted yellow !important; | |
| } | |
| .__KEYNAV_ACTIVE { | |
| border: 2px solid orange !important; | |
| outline: 2px solid orange !important; | |
| outline-offset: 0px; | |
| } | |
| .__KEYNAV_OSK { | |
| position: fixed; | |
| bottom: 20px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background: #111; | |
| padding: 10px; | |
| border-radius: 10px; | |
| z-index: 999999; | |
| color: white; | |
| font-family: monospace; | |
| } | |
| .__KEYNAV_OSK_KEY { | |
| display: inline-block; | |
| margin: 3px; | |
| padding: 10px; | |
| border-radius: 6px; | |
| background: #333; | |
| min-width: 40px; | |
| text-align: center; | |
| } | |
| .__KEYNAV_OSK_ACTIVE { | |
| outline: 3px solid orange; | |
| } | |
| `; | |
| document.head.appendChild(style); | |
| /* ================= UTIL ================= */ | |
| function getNavConfig() { | |
| return SITE_NAV_CONFIG.find(r => | |
| new RegExp(r.url).test(location.href) | |
| ); | |
| } | |
| function matchesRule(el, ruleList) { | |
| return ruleList?.some(sel => | |
| el.matches(sel) || el.closest(sel) | |
| ); | |
| } | |
| // function ariaScore(el) { | |
| // if (el.matches('button,[role="button"]')) return 100; | |
| // if (el.matches('a[href]')) return 90; | |
| // if (el.matches('input,textarea')) return 80; | |
| // if (el.matches('[role="menuitem"],[role="tab"]')) return 70; | |
| // if (el.matches('video')) return 60; | |
| // if (el.isContentEditable) return 50; | |
| // return 10; | |
| // } | |
| function rebuildNav() { | |
| console.log('rebuildNav'); | |
| const navConfig = getNavConfig(); | |
| const navGroups = [...DEFAULT_GROUP_SELECTORS.filter(el => !navConfig.excludeGroups.includes(el)), ...navConfig.includeGroups]; | |
| const navItems = [...DEFAULT_ITEM_SELECTORS, ...navConfig.includeItems]; | |
| navTable = []; | |
| document.querySelectorAll(navGroups.join(',')).forEach(groupEl => { | |
| if (!groupEl.offsetParent) return; | |
| // const parentGroup = groupMap.get(el.parentElement) || treeRoot; | |
| const navTableGroup = { el: groupEl, items: [] }; | |
| groupEl.querySelectorAll(navItems.join(',')).forEach(itemEl => { | |
| if (!itemEl.offsetParent) return; | |
| if (matchesRule(itemEl, navConfig.excludeItems || [])) { | |
| itemEl.classList.add('__KEYNAV_EXCLUDE'); | |
| return; | |
| } | |
| itemEl.classList.add('__KEYNAV_ELEMENT'); | |
| navTableGroup.items.push(itemEl); | |
| }); | |
| if (navTableGroup.items.length) navTable.push(navTableGroup); | |
| }); | |
| } | |
| /* ================= NAVIGATION ================= */ | |
| function skipGroup(stepSize) { | |
| navTablePos.g = navTablePos.g + stepSize; | |
| if (navTablePos.g >= navTable.length) navTablePos.g = 0; | |
| if (navTablePos.g < 0) navTablePos.g = navTable.length - 1; | |
| focusItem(); | |
| } | |
| function skipItem(stepSize) { | |
| const currentGroup = navTable[navTablePos.g]; | |
| navTablePos.i = navTablePos.i + stepSize; | |
| if (navTablePos.i >= currentGroup.items.length) navTablePos.i = 0; | |
| if (navTablePos.i < 0) navTablePos.i = currentGroup.items.length - 1; | |
| focusItem(); | |
| } | |
| function activateItem() { | |
| const el = document.querySelector('.__KEYNAV_ACTIVE'); | |
| if (!el) return; | |
| if (isInput(el)) { | |
| enterInputMode(el); | |
| } else if (el.matches('video')) { | |
| el.paused ? el.play() : el.requestFullscreen?.(); | |
| } else { | |
| el.click(); | |
| el.focus(); | |
| } | |
| } | |
| function clearActive() { | |
| document.querySelectorAll('.__KEYNAV_ACTIVE').forEach(el => { | |
| el.classList.remove('__KEYNAV_ACTIVE'); | |
| }); | |
| } | |
| function focusItem() { | |
| clearActive(); | |
| const currentGroup = navTable[navTablePos.g]; | |
| if (navTablePos.i >= currentGroup.items.length) navTablePos.i = currentGroup.items.length - 1; | |
| const itemEl = currentGroup.items[navTablePos.i]; | |
| itemEl.classList.add('__KEYNAV_ACTIVE'); | |
| itemEl.scrollIntoView({ block: 'center' }); | |
| } | |
| /* ---------------- TYPE HELPERS ---------------- */ | |
| const isInput = el => | |
| el.tagName === 'INPUT' || | |
| el.tagName === 'TEXTAREA' || | |
| el.isContentEditable; | |
| /* ---------------- OSK ---------------- */ | |
| function createOSK() { | |
| osk = document.createElement('div'); | |
| osk.className = '__KEYNAV_OSK'; | |
| KEYBOARD_LAYOUT.forEach((row, r) => { | |
| const rowDiv = document.createElement('div'); | |
| row.forEach((key, c) => { | |
| const k = document.createElement('span'); | |
| k.textContent = key; | |
| k.dataset.r = r; | |
| k.dataset.c = c; | |
| k.className = '__KEYNAV_OSK_KEY'; | |
| rowDiv.appendChild(k); | |
| }); | |
| osk.appendChild(rowDiv); | |
| }); | |
| document.body.appendChild(osk); | |
| highlightOSK(); | |
| } | |
| function destroyOSK() { | |
| osk?.remove(); | |
| osk = null; | |
| oskPos = { r: 0, c: 0 }; | |
| } | |
| function highlightOSK() { | |
| osk.querySelectorAll('.__KEYNAV_OSK_KEY') | |
| .forEach(k => k.classList.remove('__KEYNAV_OSK_ACTIVE')); | |
| osk.querySelector( | |
| `[data-r="${oskPos.r}"][data-c="${oskPos.c}"]` | |
| )?.classList.add('__KEYNAV_OSK_ACTIVE'); | |
| } | |
| function oskKey() { | |
| return KEYBOARD_LAYOUT[oskPos.r][oskPos.c]; | |
| } | |
| function oskInsert(key) { | |
| if (!activeInput) return; | |
| if (key === 'SPACE') activeInput.value += ' '; | |
| else if (key === 'BACK') activeInput.value = activeInput.value.slice(0, -1); | |
| else if (key === 'ENTER') exitInputMode(); | |
| else activeInput.value += key; | |
| } | |
| /* ---------------- MODES ---------------- */ | |
| function enterInputMode(el) { | |
| mode = 'input'; | |
| activeInput = el; | |
| el.focus(); | |
| if (el.tagName == 'INPUT') createOSK(); | |
| } | |
| function exitInputMode() { | |
| mode = 'nav'; | |
| activeInput?.blur(); | |
| activeInput = null; | |
| destroyOSK(); | |
| } | |
| /* ================= KEY HANDLER ================= */ | |
| document.addEventListener('keydown', e => { | |
| if (mode === 'nav') { | |
| console.log(e?.key, XX_currentGroup, XX_currentIndex); | |
| switch (e.key) { | |
| case 'ArrowDown': | |
| skipGroup(1); | |
| break; | |
| case 'ArrowUp': | |
| skipGroup(-1); | |
| break; | |
| case 'ArrowRight': | |
| skipItem(1); | |
| break; | |
| case 'ArrowLeft': | |
| skipItem(-1); | |
| break; | |
| case 'Enter': | |
| activateItem(); | |
| break; | |
| case '>': | |
| break; | |
| case 'Escape': | |
| clearActive(); | |
| break; | |
| } | |
| } else { | |
| e.preventDefault(); | |
| switch (e.key) { | |
| case 'ArrowRight': oskPos.c++; break; | |
| case 'ArrowLeft': oskPos.c--; break; | |
| case 'ArrowDown': oskPos.r++; break; | |
| case 'ArrowUp': oskPos.r--; break; | |
| case 'Enter': oskInsert(oskKey()); break; | |
| case 'Escape': exitInputMode(); return; | |
| } | |
| oskPos.r = Math.max(0, Math.min(KEYBOARD_LAYOUT.length - 1, oskPos.r)); | |
| oskPos.c = Math.max(0, Math.min(KEYBOARD_LAYOUT[oskPos.r].length - 1, oskPos.c)); | |
| highlightOSK(); | |
| } | |
| }); | |
| /* ================= INIT ================= */ | |
| rebuildNav(); | |
| focusItem(); | |
| new MutationObserver(rebuildNav) | |
| .observe(document.body, { childList: true, subtree: true }); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment