/* 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 += "";
html += `${cat[0].cat}`;
html += '';
for (const cj in cat) {
html += `- ${cat[cj].key} ${cat[cj].des}
`;
}
html += " ";
html += " | "; // col-sm-*
if (!first || lastCategory) {
html += "
"; // row
}
}
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");
});