diff --git a/js/dom.js b/js/dom.js index bd22bb5c..cce0fc0c 100644 --- a/js/dom.js +++ b/js/dom.js @@ -92,8 +92,11 @@ function onDOMready() { function scrollElementIntoView(element) { // align to the top/bottom of the visible area if wasn't visible const bounds = element.getBoundingClientRect(); - if (bounds.top < 0 || bounds.top > innerHeight - bounds.height) { - element.scrollIntoView(bounds.top < 0); + const boundsContainer = element.parentNode.getBoundingClientRect(); + const windowHeight = window.innerHeight; + if (bounds.top < Math.max(boundsContainer.top, windowHeight / 4) || + bounds.top > Math.min(boundsContainer.bottom, windowHeight) - bounds.height - windowHeight / 4) { + window.scrollBy(0, bounds.top - windowHeight / 2 + bounds.height); } } diff --git a/manage.html b/manage.html index aed0a3a5..4390bd4e 100644 --- a/manage.html +++ b/manage.html @@ -273,6 +273,7 @@ + diff --git a/manage/incremental-search.js b/manage/incremental-search.js new file mode 100644 index 00000000..45354152 --- /dev/null +++ b/manage/incremental-search.js @@ -0,0 +1,132 @@ +/* global installed */ +'use strict'; + +onDOMready().then(() => { + let prevText, focusedLink, focusedEntry; + let prevTime = performance.now(); + let focusedName = ''; + const input = $element({ + tag: 'textarea', + spellcheck: false, + oninput: incrementalSearch, + }); + replaceInlineStyle({ + position: 'absolute', + color: 'transparent', + border: '1px solid hsla(180, 100%, 100%, .5)', + top: '-1000px', + overflow: 'hidden', + resize: 'none', + 'background-color': 'hsla(180, 100%, 100%, .2)', + 'pointer-events': 'none', + }); + document.body.appendChild(input); + window.addEventListener('keydown', maybeRefocus, true); + + function incrementalSearch({which}, immediately) { + if (!immediately) { + debounce(incrementalSearch, 100, {}, true); + return; + } + const direction = which === 38 ? -1 : which === 40 ? 1 : 0; + const text = input.value.toLocaleLowerCase(); + if (!text.trim() || !direction && (text === prevText || focusedName.startsWith(text))) { + prevText = text; + return; + } + let textAtPos = 1e6; + let rotated; + const entries = [...installed.children]; + const focusedIndex = entries.indexOf(focusedEntry); + if (focusedIndex > 0) { + if (direction > 0) { + rotated = entries.slice(focusedIndex + 1).concat(entries.slice(0, focusedIndex + 1)); + } else if (direction < 0) { + rotated = entries.slice(0, focusedIndex).reverse().concat(entries.slice(focusedIndex).reverse()); + } + } + let found; + for (const entry of rotated || entries) { + const name = entry.styleNameLowerCase; + const pos = name.indexOf(text); + if (pos === 0) { + found = entry; + break; + } else if (pos > 0 && (pos < textAtPos || direction)) { + found = entry; + textAtPos = pos; + if (direction) { + break; + } + } + } + if (found && found !== focusedEntry) { + focusedEntry = found; + focusedLink = $('.style-name-link', found); + focusedName = found.styleNameLowerCase; + scrollElementIntoView(found); + animateElement(found, {className: 'highlight-quick'}); + resizeTo(focusedLink); + return true; + } + } + + function maybeRefocus(event) { + if (event.altKey || event.ctrlKey || event.metaKey || + event.target.matches('[type="text"], [type="search"]')) { + return; + } + const k = event.which; + // focus search field on "/" key + if (k === 191 && !event.shiftKey) { + event.preventDefault(); + $('#search').focus(); + return; + } + const time = performance.now(); + if ( + // 0-9 + k >= 48 && k <= 57 || + // a-z + k >= 65 && k <= 90 || + // numpad keys + k >= 96 && k <= 111 || + // marks + k >= 186 + ) { + input.focus(); + if (time - prevTime > 1000) { + input.value = ''; + } + prevTime = time; + } else + if (k === 13 && focusedLink) { + focusedLink.dispatchEvent(new MouseEvent('click', {bubbles: true})); + } else + if ((k === 38 || k === 40) && !event.shiftKey && + time - prevTime < 5000 && incrementalSearch(event, true)) { + prevTime = time; + } else + if (event.target === input) { + (focusedLink || document.body).focus(); + input.value = ''; + } + } + + function resizeTo(el) { + const bounds = el.getBoundingClientRect(); + const base = document.scrollingElement; + replaceInlineStyle({ + left: bounds.left - 2 + base.scrollLeft + 'px', + top: bounds.top - 1 + base.scrollTop + 'px', + width: bounds.width + 4 + 'px', + height: bounds.height + 2 + 'px', + }); + } + + function replaceInlineStyle(css) { + for (const prop in css) { + input.style.setProperty(prop, css[prop], 'important'); + } + } +}); diff --git a/manage/manage.css b/manage/manage.css index 85ed1dce..c9979fca 100644 --- a/manage/manage.css +++ b/manage/manage.css @@ -584,6 +584,9 @@ input[id^="manage.newUI"] { .highlight { animation: highlight 10s cubic-bezier(0,.82,.47,.98); } +.highlight-quick { + animation: highlight .5s; +} @keyframes highlight { from { diff --git a/manage/manage.js b/manage/manage.js index bb308f45..068106c9 100644 --- a/manage/manage.js +++ b/manage/manage.js @@ -59,16 +59,6 @@ function initGlobalEvents() { $('#manage-shortcuts-button').onclick = () => openURL({url: URLS.configureCommands}); $$('#header a[href^="http"]').forEach(a => (a.onclick = handleEvent.external)); - // focus search field on / key - document.onkeypress = event => { - if ((event.keyCode || event.which) === 47 - && !event.altKey && !event.shiftKey && !event.ctrlKey && !event.metaKey - && !event.target.matches('[type="text"], [type="search"]')) { - event.preventDefault(); - $('#search').focus(); - } - }; - // remember scroll position on normal history navigation window.onbeforeunload = rememberScrollPosition;