diff options
Diffstat (limited to 'client/simple/src/js/main/keyboard.ts')
| -rw-r--r-- | client/simple/src/js/main/keyboard.ts | 469 |
1 files changed, 469 insertions, 0 deletions
diff --git a/client/simple/src/js/main/keyboard.ts b/client/simple/src/js/main/keyboard.ts new file mode 100644 index 000000000..67dd1b3a0 --- /dev/null +++ b/client/simple/src/js/main/keyboard.ts @@ -0,0 +1,469 @@ +import { assertElement, searxng } from "./00_toolkit.ts"; + +export type KeyBindingLayout = "default" | "vim"; + +type KeyBinding = { + key: string; + fun: (event: KeyboardEvent) => void; + des: string; + cat: string; +}; + +/* common base for layouts */ +const baseKeyBinding: Record<string, KeyBinding> = { + Escape: { + key: "ESC", + fun: (event) => removeFocus(event), + des: "remove focus from the focused input", + cat: "Control" + }, + c: { + key: "c", + fun: () => copyURLToClipboard(), + des: "copy url of the selected result to the clipboard", + cat: "Results" + }, + h: { + key: "h", + fun: () => toggleHelp(keyBindings), + des: "toggle help window", + cat: "Other" + }, + i: { + key: "i", + fun: () => searchInputFocus(), + des: "focus on the search input", + cat: "Control" + }, + n: { + key: "n", + fun: () => GoToNextPage(), + des: "go to next page", + cat: "Results" + }, + o: { + key: "o", + fun: () => openResult(false), + des: "open search result", + cat: "Results" + }, + p: { + key: "p", + fun: () => GoToPreviousPage(), + des: "go to previous page", + cat: "Results" + }, + r: { + key: "r", + fun: () => reloadPage(), + des: "reload page from the server", + cat: "Control" + }, + t: { + key: "t", + fun: () => openResult(true), + des: "open the result in a new tab", + cat: "Results" + } +}; + +const keyBindingLayouts: Record<KeyBindingLayout, Record<string, KeyBinding>> = { + // SearXNG layout + default: { + ArrowLeft: { + key: "←", + fun: () => highlightResult("up"), + des: "select previous search result", + cat: "Results" + }, + ArrowRight: { + key: "→", + fun: () => highlightResult("down"), + des: "select next search result", + cat: "Results" + }, + ...baseKeyBinding + }, + + // Vim-like keyboard layout + vim: { + b: { + key: "b", + fun: () => scrollPage(-window.innerHeight), + des: "scroll one page up", + cat: "Navigation" + }, + d: { + key: "d", + fun: () => scrollPage(window.innerHeight / 2), + des: "scroll half a page down", + cat: "Navigation" + }, + f: { + key: "f", + fun: () => scrollPage(window.innerHeight), + des: "scroll one page down", + cat: "Navigation" + }, + g: { + key: "g", + fun: () => scrollPageTo(-document.body.scrollHeight, "top"), + des: "scroll to the top of the page", + cat: "Navigation" + }, + j: { + key: "j", + fun: () => highlightResult("down"), + des: "select next search result", + cat: "Results" + }, + k: { + key: "k", + fun: () => highlightResult("up"), + des: "select previous search result", + cat: "Results" + }, + u: { + key: "u", + fun: () => scrollPage(-window.innerHeight / 2), + des: "scroll half a page up", + cat: "Navigation" + }, + v: { + key: "v", + fun: () => scrollPageTo(document.body.scrollHeight, "bottom"), + des: "scroll to the bottom of the page", + cat: "Navigation" + }, + y: { + key: "y", + fun: () => copyURLToClipboard(), + des: "copy url of the selected result to the clipboard", + cat: "Results" + }, + ...baseKeyBinding + } +}; + +const keyBindings = + searxng.settings.hotkeys && searxng.settings.hotkeys in keyBindingLayouts + ? keyBindingLayouts[searxng.settings.hotkeys] + : keyBindingLayouts.default; + +const isElementInDetail = (element?: Element): boolean => { + const ancestor = element?.closest(".detail, .result"); + return ancestor?.classList.contains("detail") ?? false; +}; + +const getResultElement = (element?: Element): Element | undefined => { + return element?.closest(".result") ?? undefined; +}; + +const isImageResult = (resultElement?: Element): boolean => { + return resultElement?.classList.contains("result-images") ?? false; +}; + +const highlightResult = + (which: string | Element) => + (noScroll?: boolean, keepFocus?: boolean): void => { + let current = document.querySelector<HTMLElement>(".result[data-vim-selected]"); + let effectiveWhich = which; + if (!current) { + // no selection : choose the first one + current = document.querySelector<HTMLElement>(".result"); + if (!current) { + // no first one : there are no results + return; + } + // replace up/down actions by selecting first one + if (which === "down" || which === "up") { + effectiveWhich = current; + } + } + + let next: Element | null | undefined = null; + const results = Array.from(document.querySelectorAll<HTMLElement>(".result")); + + if (typeof effectiveWhich !== "string") { + next = effectiveWhich; + } else { + switch (effectiveWhich) { + case "visible": { + const top = document.documentElement.scrollTop || document.body.scrollTop; + const bot = top + document.documentElement.clientHeight; + + for (let i = 0; i < results.length; i++) { + const element = results[i] as HTMLElement; + next = element; + + const etop = element.offsetTop; + const ebot = etop + element.clientHeight; + + if (ebot <= bot && etop > top) { + break; + } + } + break; + } + case "down": + next = results[results.indexOf(current) + 1] || current; + break; + case "up": + next = results[results.indexOf(current) - 1] || current; + break; + case "bottom": + next = results[results.length - 1]; + break; + // biome-ignore lint/complexity/noUselessSwitchCase: fallthrough is intended + case "top": + default: + next = results[0]; + } + } + + if (next && current) { + current.removeAttribute("data-vim-selected"); + next.setAttribute("data-vim-selected", "true"); + if (!keepFocus) { + const link = next.querySelector<HTMLElement>("h3 a") || next.querySelector<HTMLElement>("a"); + if (link) { + link.focus(); + } + } + if (!noScroll) { + scrollPageToSelected(); + } + } + }; + +const reloadPage = (): void => { + document.location.reload(); +}; + +const removeFocus = (event: KeyboardEvent): void => { + const target = event.target as HTMLElement; + const tagName = target?.tagName?.toLowerCase(); + + if (document.activeElement && (tagName === "input" || tagName === "select" || tagName === "textarea")) { + (document.activeElement as HTMLElement).blur(); + } else { + searxng.closeDetail?.(); + } +}; + +const pageButtonClick = (css_selector: string): void => { + const button = document.querySelector<HTMLButtonElement>(css_selector); + if (button) { + button.click(); + } +}; + +const GoToNextPage = () => { + pageButtonClick('nav#pagination .next_page button[type="submit"]'); +}; + +const GoToPreviousPage = () => { + pageButtonClick('nav#pagination .previous_page button[type="submit"]'); +}; + +const scrollPageToSelected = (): void => { + const sel = document.querySelector<HTMLElement>(".result[data-vim-selected]"); + if (!sel) return; + + const wtop = document.documentElement.scrollTop || document.body.scrollTop, + height = document.documentElement.clientHeight, + etop = sel.offsetTop, + ebot = etop + sel.clientHeight, + offset = 120; + + // first element ? + if (!sel.previousElementSibling && ebot < height) { + // set to the top of page if the first element + // is fully included in the viewport + window.scroll(window.scrollX, 0); + return; + } + + if (wtop > etop - offset) { + window.scroll(window.scrollX, etop - offset); + } else { + const wbot = wtop + height; + if (wbot < ebot + offset) { + window.scroll(window.scrollX, ebot - height + offset); + } + } +}; + +const scrollPage = (amount: number): void => { + window.scrollBy(0, amount); + highlightResult("visible")(); +}; + +const scrollPageTo = (position: number, nav: string): void => { + window.scrollTo(0, position); + highlightResult(nav)(); +}; + +const searchInputFocus = (): void => { + window.scrollTo(0, 0); + + const q = document.querySelector<HTMLInputElement>("#q"); + if (q) { + q.focus(); + + if (q.setSelectionRange) { + const len = q.value.length; + + q.setSelectionRange(len, len); + } + } +}; + +const openResult = (newTab: boolean): void => { + let link = document.querySelector<HTMLAnchorElement>(".result[data-vim-selected] h3 a"); + if (!link) { + link = document.querySelector<HTMLAnchorElement>(".result[data-vim-selected] > a"); + } + if (!link) return; + + const url = link.getAttribute("href"); + if (url) { + if (newTab) { + window.open(url); + } else { + window.location.href = url; + } + } +}; + +const initHelpContent = (divElement: HTMLElement, keyBindings: typeof baseKeyBinding): void => { + const categories: Record<string, KeyBinding[]> = {}; + + for (const binding of Object.values(keyBindings)) { + const cat = binding.cat; + categories[cat] ??= []; + categories[cat].push(binding); + } + + const sortedCategoryKeys = Object.keys(categories).sort( + (a, b) => (categories[b]?.length ?? 0) - (categories[a]?.length ?? 0) + ); + + let html = '<a href="#" class="close" aria-label="close" title="close">×</a>'; + html += "<h3>How to navigate SearXNG with hotkeys</h3>"; + html += "<table>"; + + for (const [i, categoryKey] of sortedCategoryKeys.entries()) { + const bindings = categories[categoryKey]; + if (!bindings || bindings.length === 0) continue; + + const isFirst = i % 2 === 0; + const isLast = i === sortedCategoryKeys.length - 1; + + if (isFirst) { + html += "<tr>"; + } + + html += "<td>"; + html += `<h4>${categoryKey}</h4>`; + html += '<ul class="list-unstyled">'; + + for (const binding of bindings) { + html += `<li><kbd>${binding.key}</kbd> ${binding.des}</li>`; + } + + html += "</ul>"; + html += "</td>"; + + if (!isFirst || isLast) { + html += "</tr>"; + } + } + + html += "</table>"; + + divElement.innerHTML = html; +}; + +const toggleHelp = (keyBindings: typeof baseKeyBinding): void => { + let helpPanel = document.querySelector<HTMLElement>("#vim-hotkeys-help"); + if (!helpPanel) { + // first call + helpPanel = Object.assign(document.createElement("div"), { + id: "vim-hotkeys-help", + className: "dialog-modal" + }); + initHelpContent(helpPanel, keyBindings); + const body = document.getElementsByTagName("body")[0]; + if (body) { + body.appendChild(helpPanel); + } + } else { + // toggle hidden + helpPanel.classList.toggle("invisible"); + } +}; + +const copyURLToClipboard = async (): Promise<void> => { + const currentUrlElement = document.querySelector<HTMLAnchorElement>(".result[data-vim-selected] h3 a"); + assertElement(currentUrlElement); + + const url = currentUrlElement.getAttribute("href"); + if (url) { + await navigator.clipboard.writeText(url); + } +}; + +searxng.ready(() => { + searxng.listen("click", ".result", function (this: Element, event: Event) { + if (!isElementInDetail(event.target as Element)) { + highlightResult(this)(true, true); + + const resultElement = getResultElement(event.target as Element); + + if (resultElement && isImageResult(resultElement)) { + event.preventDefault(); + searxng.selectImage?.(resultElement); + } + } + }); + + searxng.listen( + "focus", + ".result a", + (event: Event) => { + if (!isElementInDetail(event.target as Element)) { + const resultElement = getResultElement(event.target as Element); + + if (resultElement && !resultElement.getAttribute("data-vim-selected")) { + highlightResult(resultElement)(true); + } + + if (resultElement && isImageResult(resultElement)) { + searxng.selectImage?.(resultElement); + } + } + }, + { capture: true } + ); + + searxng.listen("keydown", document, (event: KeyboardEvent) => { + // check for modifiers so we don't break browser's hotkeys + if (Object.hasOwn(keyBindings, event.key) && !event.ctrlKey && !event.altKey && !event.shiftKey && !event.metaKey) { + const tagName = (event.target as Element)?.tagName?.toLowerCase(); + + if (event.key === "Escape") { + keyBindings[event.key]?.fun(event); + } else { + if (event.target === document.body || tagName === "a" || tagName === "button") { + event.preventDefault(); + keyBindings[event.key]?.fun(event); + } + } + } + }); + + searxng.scrollPageToSelected = scrollPageToSelected; + searxng.selectNext = highlightResult("down"); + searxng.selectPrevious = highlightResult("up"); +}); |