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 = { 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> = { // 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?: HTMLElement): HTMLElement | undefined => { return element?.closest(".result") ?? undefined; }; const isImageResult = (resultElement?: Element): boolean => { return resultElement?.classList.contains("result-images") ?? false; }; const highlightResult = (which: string | HTMLElement) => (noScroll?: boolean, keepFocus?: boolean): void => { let effectiveWhich = which; let current = document.querySelector(".result[data-vim-selected]"); if (!current) { // no selection : choose the first one current = document.querySelector(".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; } } const results = Array.from(document.querySelectorAll(".result")); let next: HTMLElement | undefined; 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 (const element of results) { const etop = element.offsetTop; const ebot = etop + element.clientHeight; if (ebot <= bot && etop > top) { next = element; 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.removeAttribute("data-vim-selected"); next.setAttribute("data-vim-selected", "true"); if (!keepFocus) { const link = next.querySelector("h3 a") || next.querySelector("a"); 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(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(".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("#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(".result[data-vim-selected] h3 a"); if (!link) { link = document.querySelector(".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 = {}; 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 = '×'; html += "

How to navigate SearXNG with hotkeys

"; html += ""; 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 += ""; } html += ""; if (!isFirst || isLast) { html += ""; } } html += "
"; html += `

${categoryKey}

`; html += '
    '; for (const binding of bindings) { html += `
  • ${binding.key} ${binding.des}
  • `; } html += "
"; html += "
"; divElement.innerHTML = html; }; const toggleHelp = (keyBindings: typeof baseKeyBinding): void => { let helpPanel = document.querySelector("#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 => { const currentUrlElement = document.querySelector(".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: HTMLElement, event: Event) { if (!isElementInDetail(event.target as HTMLElement)) { highlightResult(this)(true, true); const resultElement = getResultElement(event.target as HTMLElement); if (resultElement && isImageResult(resultElement)) { event.preventDefault(); searxng.selectImage?.(resultElement); } } }); searxng.listen( "focus", ".result a", (event: Event) => { if (!isElementInDetail(event.target as HTMLElement)) { const resultElement = getResultElement(event.target as HTMLElement); 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"); });