/* SPDX-License-Identifier: AGPL-3.0-or-later */ /* global searxng */ searxng.ready(() => { function isElementInDetail(el) { while (el !== undefined) { if (el.classList.contains("detail")) { return true; } if (el.classList.contains("result")) { // we found a result, no need to go to the root of the document: // el is not inside a
element return false; } el = el.parentNode; } return false; } function getResultElement(el) { while (el !== undefined) { if (el.classList.contains("result")) { return el; } el = el.parentNode; } return undefined; } function isImageResult(resultElement) { return resultElement?.classList.contains("result-images"); } searxng.on(".result", "click", function (e) { if (!isElementInDetail(e.target)) { highlightResult(this)(true, true); const resultElement = getResultElement(e.target); if (isImageResult(resultElement)) { e.preventDefault(); searxng.selectImage(resultElement); } } }); searxng.on( ".result a", "focus", (e) => { if (!isElementInDetail(e.target)) { const resultElement = getResultElement(e.target); if (resultElement && resultElement.getAttribute("data-vim-selected") === null) { highlightResult(resultElement)(true); } if (isImageResult(resultElement)) { searxng.selectImage(resultElement); } } }, true ); /* common base for layouts */ const baseKeyBinding = { Escape: { key: "ESC", fun: removeFocus, 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, 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 = { default: Object.assign( { /* SearXNG layout */ 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: Object.assign( { /* Vim-like Key Layout. */ b: { key: "b", fun: scrollPage(-window.innerHeight), des: "scroll one page up", cat: "Navigation" }, f: { key: "f", fun: scrollPage(window.innerHeight), des: "scroll one page down", cat: "Navigation" }, u: { key: "u", fun: scrollPage(-window.innerHeight / 2), des: "scroll half a page up", cat: "Navigation" }, d: { key: "d", fun: scrollPage(window.innerHeight / 2), des: "scroll half a page down", cat: "Navigation" }, g: { key: "g", fun: scrollPageTo(-document.body.scrollHeight, "top"), des: "scroll to the top of the page", cat: "Navigation" }, v: { key: "v", fun: scrollPageTo(document.body.scrollHeight, "bottom"), des: "scroll to the bottom of the page", cat: "Navigation" }, k: { key: "k", fun: highlightResult("up"), des: "select previous search result", cat: "Results" }, j: { key: "j", fun: highlightResult("down"), des: "select next search result", cat: "Results" }, y: { key: "y", fun: copyURLToClipboard, des: "copy url of the selected result to the clipboard", cat: "Results" } }, baseKeyBinding ) }; const keyBindings = keyBindingLayouts[searxng.settings.hotkeys] || keyBindingLayouts.default; searxng.on(document, "keydown", (e) => { // check for modifiers so we don't break browser's hotkeys if ( // biome-ignore lint/suspicious/noPrototypeBuiltins: FIXME: support for Chromium 93-87, Firefox 92-78, Safari 15.4-14 Object.prototype.hasOwnProperty.call(keyBindings, e.key) && !e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey ) { const tagName = e.target.tagName.toLowerCase(); if (e.key === "Escape") { keyBindings[e.key].fun(e); } else { if (e.target === document.body || tagName === "a" || tagName === "button") { e.preventDefault(); keyBindings[e.key].fun(); } } } }); function highlightResult(which) { return (noScroll, keepFocus) => { let current = document.querySelector(".result[data-vim-selected]"), effectiveWhich = which; if (current === null) { // no selection : choose the first one current = document.querySelector(".result"); if (current === null) { // 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, results = document.querySelectorAll(".result"); results = Array.from(results); // convert NodeList to Array for further use 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++) { next = results[i]; const etop = next.offsetTop; const ebot = etop + next.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": /* falls through */ 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"); if (link !== null) { link.focus(); } } if (!noScroll) { scrollPageToSelected(); } } }; } function reloadPage() { document.location.reload(true); } function removeFocus(e) { const tagName = e.target.tagName.toLowerCase(); if (document.activeElement && (tagName === "input" || tagName === "select" || tagName === "textarea")) { document.activeElement.blur(); } else { searxng.closeDetail(); } } function pageButtonClick(css_selector) { return () => { const button = document.querySelector(css_selector); if (button) { button.click(); } }; } function GoToNextPage() { return pageButtonClick('nav#pagination .next_page button[type="submit"]'); } function GoToPreviousPage() { return pageButtonClick('nav#pagination .previous_page button[type="submit"]'); } function scrollPageToSelected() { const sel = document.querySelector(".result[data-vim-selected]"); if (sel === null) { return; } const wtop = document.documentElement.scrollTop || document.body.scrollTop, wheight = document.documentElement.clientHeight, etop = sel.offsetTop, ebot = etop + sel.clientHeight, offset = 120; // first element ? if (sel.previousElementSibling === null && ebot < wheight) { // 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 + wheight; if (wbot < ebot + offset) { window.scroll(window.scrollX, ebot - wheight + offset); } } } function scrollPage(amount) { return () => { window.scrollBy(0, amount); highlightResult("visible")(); }; } function scrollPageTo(position, nav) { return () => { window.scrollTo(0, position); highlightResult(nav)(); }; } function searchInputFocus() { window.scrollTo(0, 0); const q = document.querySelector("#q"); q.focus(); if (q.setSelectionRange) { const len = q.value.length; q.setSelectionRange(len, len); } } function openResult(newTab) { return () => { let link = document.querySelector(".result[data-vim-selected] h3 a"); if (link === null) { link = document.querySelector(".result[data-vim-selected] > a"); } if (link !== null) { const url = link.getAttribute("href"); if (newTab) { window.open(url); } else { window.location.href = url; } } }; } function initHelpContent(divElement) { const categories = {}; for (const k in keyBindings) { const key = keyBindings[k]; categories[key.cat] = categories[key.cat] || []; categories[key.cat].push(key); } const sorted = Object.keys(categories).sort((a, b) => categories[b].length - categories[a].length); if (sorted.length === 0) { return; } let html = '×'; html += "

How to navigate SearXNG with hotkeys

"; html += ""; for (let i = 0; i < sorted.length; i++) { const cat = categories[sorted[i]]; const lastCategory = i === sorted.length - 1; const first = i % 2 === 0; if (first) { html += ""; } html += ""; // col-sm-* if (!first || lastCategory) { html += ""; // row } } html += "
"; html += `

${cat[0].cat}

`; html += '
    '; for (const cj in cat) { html += `
  • ${cat[cj].key} ${cat[cj].des}
  • `; } html += "
"; html += "
"; divElement.innerHTML = html; } function toggleHelp() { let helpPanel = document.querySelector("#vim-hotkeys-help"); if (helpPanel === undefined || helpPanel === null) { // first call helpPanel = document.createElement("div"); helpPanel.id = "vim-hotkeys-help"; helpPanel.className = "dialog-modal"; initHelpContent(helpPanel); const body = document.getElementsByTagName("body")[0]; body.appendChild(helpPanel); } else { // toggle hidden helpPanel.classList.toggle("invisible"); } } function copyURLToClipboard() { const currentUrlElement = document.querySelector(".result[data-vim-selected] h3 a"); if (currentUrlElement === null) return; const url = currentUrlElement.getAttribute("href"); navigator.clipboard.writeText(url); } searxng.scrollPageToSelected = scrollPageToSelected; searxng.selectNext = highlightResult("down"); searxng.selectPrevious = highlightResult("up"); });