Skip to content

Instantly share code, notes, and snippets.

@monoblaine
Last active October 29, 2022 12:49
Show Gist options
  • Save monoblaine/4001d99067cfa4ded236066edb94e321 to your computer and use it in GitHub Desktop.
Save monoblaine/4001d99067cfa4ded236066edb94e321 to your computer and use it in GitHub Desktop.
// ==UserScript==
// @name Traverse visible and clickable elements (shift + up or down arrows)
// @description Spatial navigation similar to good old Presto-based Opera
// @version 1.4.7
// @match *://*/*
// @grant none
// ==/UserScript==
(function () {
const placeToReplace = window.EventTarget && EventTarget.prototype.addEventListener ? EventTarget : Element;
placeToReplace.prototype.originalAddEventListener = placeToReplace.prototype.addEventListener;
placeToReplace.prototype.addEventListener = function (type, listener, optionsOrUseCapture) {
if ((typeof type === 'string' && type.endsWith('keydown')) && listener !== navigate) {
const listenerIsACallbackFunction = typeof listener === 'function';
const listenerIsAnObject = typeof listener === 'object' && typeof listener.handleEvent === 'function';
if (listenerIsACallbackFunction || listenerIsAnObject) {
const originalFunction = listenerIsACallbackFunction ? listener : listener.handleEvent.bind(listener);
const interceptorFn = e => {
if (e.shiftKey) {
switch (e.target.tagName) {
case 'SUMMARY':
return;
case 'A':
case 'BUTTON':
if (
e.target.getAttribute('data-toggle') === 'dropdown' ||
e.target.getAttribute('aria-haspopup') !== 'false'
) {
return;
}
break;
}
}
if (
originalFunction.name !== 'fireCustomKeydown' ||
!(
!e.target.matches('input, textarea') ||
e.defaultPrevented
)
) {
originalFunction(e);
}
};
if (listenerIsACallbackFunction) {
listener = interceptorFn;
}
else {
listener.handleEvent = interceptorFn;
}
}
}
if (arguments.length < 3) {
this.originalAddEventListener(type, listener, false);
}
else {
this.originalAddEventListener(type, listener, optionsOrUseCapture);
}
}
let lastIndex = -1;
let dt = null;
let clickableElsCache;
let outlineNonesFixed = false;
class Focuser {
constructor () {
this.index = Number.MIN_VALUE;
}
tryFocus (clickableEls) {
let currentEl;
if (!isElementInViewport(currentEl = clickableEls[this.index])) {
return false;
}
lastIndex = this.index;
currentEl.focus();
return currentEl === document.activeElement;
}
focusNextClickable () {
!outlineNonesFixed && fixOutlineNones();
const clickableEls = getClickableEls();
const len = clickableEls.length;
let focused = false;
this.initializeIndex(len);
while (this.condition(len) && !(focused = this.tryFocus(clickableEls)));
if (!focused) {
document.activeElement.blur();
}
}
initializeIndex (length) { throw new Error('fuck'); }
condition (limit) { throw new Error('fuck'); }
}
class NextFocuser extends Focuser {
initializeIndex (length) {
this.index = lastIndex;
}
condition (length) {
return ++this.index < length;
}
}
class PrevFocuser extends Focuser {
initializeIndex (length) {
this.index = lastIndex < 0 ? length : lastIndex;
}
condition (length) {
return --this.index >= 0;
}
}
const nextFocuser = new NextFocuser();
const prevFocuser = new PrevFocuser();
document.addEventListener('keydown', navigate);
function navigate (e) {
const keyName = e.key,
isInput = e.target.tagName === 'TEXTAREA' || (e.target.tagName === 'INPUT' && e.target.type !== 'submit');
if (keyName === 'Shift' || !e.shiftKey || isInput) {
return;
}
let focuser = null;
switch (keyName) {
case 'ArrowUp':
focuser = prevFocuser;
break;
case 'ArrowDown':
focuser = nextFocuser;
break;
default:
return;
}
e.preventDefault();
focuser.focusNextClickable();
}
function getClickableEls () {
const now = Date.now();
if (dt === null) {
dt = now;
}
else if ((now - dt) < 1000) {
return clickableElsCache;
}
clickableElsCache = document.querySelectorAll('a[href]:not([disabled]), button:not([disabled]), input[type=submit]:not([disabled]), summary:not([disabled]), [role="button"]:not([disabled])');
if (document.activeElement != null) {
lastIndex = Array.prototype.indexOf.call(clickableElsCache, document.activeElement);
}
else {
lastIndex = -1;
}
return clickableElsCache;
}
function isElementInViewport (el) {
const rect = el.getBoundingClientRect();
const vWidth = window.innerWidth || document.documentElement.clientWidth;
const vHeight = window.innerHeight || document.documentElement.clientHeight;
const efp = function (x, y) { return document.elementFromPoint(x, y) };
if (
(
rect.top >= 0 &&
rect.bottom <= vHeight &&
rect.left >= 0 &&
rect.right <= vWidth
) === false
) {
return false;
}
const xCenter = rect.left + rect.width / 2;
const yCenter = rect.top + rect.height / 2;
return (
el.contains(efp(rect.left, rect.top)) ||
el.contains(efp(xCenter, rect.top)) ||
el.contains(efp(rect.right, rect.top)) ||
el.contains(efp(rect.right, yCenter)) ||
el.contains(efp(rect.right, rect.bottom)) ||
el.contains(efp(xCenter, rect.bottom)) ||
el.contains(efp(rect.left, rect.bottom)) ||
el.contains(efp(rect.left, yCenter))
);
}
// outline: none kullananları düzelt
function fixOutlineNones () {
const someAnchor = document.querySelector('a');
if (someAnchor !== null) {
const someAnchorFocusStyle = getComputedStyle(someAnchor, ':focus').content;
if (someAnchorFocusStyle === '' || /none|initial|normal/.test(someAnchorFocusStyle)) {
let style = document.createElement('style');
style.innerHTML = '*:focus { outline: 2px dashed orange !important; transition-property: none !important; }';
document.head.appendChild(style);
}
}
outlineNonesFixed = true;
}
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment