mirror of
https://github.com/searxng/searxng.git
synced 2025-12-29 15:10:02 +00:00
Bumps the minor group in /client/simple with 6 updates: | Package | From | To | | --- | --- | --- | | [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.3.8` | `2.3.10` | | [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.0.1` | `25.0.3` | | [edge.js](https://github.com/edge-js/edge) | `6.3.0` | `6.4.0` | | [less](https://github.com/less/less.js) | `4.4.2` | `4.5.1` | | [sort-package-json](https://github.com/keithamus/sort-package-json) | `3.5.1` | `3.6.0` | | [vite-bundle-analyzer](https://github.com/nonzzz/vite-bundle-analyzer) | `1.3.1` | `1.3.2` | Updates `@biomejs/biome` from 2.3.8 to 2.3.10 - [Release notes](https://github.com/biomejs/biome/releases) - [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md) - [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.3.10/packages/@biomejs/biome) Updates `@types/node` from 25.0.1 to 25.0.3 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) Updates `edge.js` from 6.3.0 to 6.4.0 - [Release notes](https://github.com/edge-js/edge/releases) - [Changelog](https://github.com/edge-js/edge/blob/6.x/CHANGELOG.md) - [Commits](https://github.com/edge-js/edge/compare/v6.3.0...v6.4.0) Updates `less` from 4.4.2 to 4.5.1 - [Release notes](https://github.com/less/less.js/releases) - [Changelog](https://github.com/less/less.js/blob/master/CHANGELOG.md) - [Commits](https://github.com/less/less.js/commits) Updates `sort-package-json` from 3.5.1 to 3.6.0 - [Release notes](https://github.com/keithamus/sort-package-json/releases) - [Commits](https://github.com/keithamus/sort-package-json/compare/v3.5.1...v3.6.0) Updates `vite-bundle-analyzer` from 1.3.1 to 1.3.2 - [Release notes](https://github.com/nonzzz/vite-bundle-analyzer/releases) - [Changelog](https://github.com/nonzzz/vite-bundle-analyzer/blob/master/CHANGELOG.md) - [Commits](https://github.com/nonzzz/vite-bundle-analyzer/compare/v1.3.1...v1.3.2) --- updated-dependencies: - dependency-name: "@biomejs/biome" dependency-version: 2.3.10 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: minor - dependency-name: "@types/node" dependency-version: 25.0.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: minor - dependency-name: edge.js dependency-version: 6.4.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: minor - dependency-name: less dependency-version: 4.5.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: minor - dependency-name: sort-package-json dependency-version: 3.6.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: minor - dependency-name: vite-bundle-analyzer dependency-version: 1.3.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: minor ... Signed-off-by: dependabot[bot] <support@github.com>
489 lines
13 KiB
TypeScript
489 lines
13 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||
|
||
import { listen, mutable, settings } from "../toolkit.ts";
|
||
import { assertElement } from "../util/assertElement.ts";
|
||
|
||
export type KeyBindingLayout = "default" | "vim";
|
||
|
||
type KeyBinding = {
|
||
key: string;
|
||
fun: (event: KeyboardEvent) => void;
|
||
des: string;
|
||
cat: string;
|
||
};
|
||
|
||
type HighlightResultElement = "down" | "up" | "visible" | "bottom" | "top";
|
||
|
||
/* common base for layouts */
|
||
const baseKeyBinding: Record<string, KeyBinding> = {
|
||
Escape: {
|
||
key: "ESC",
|
||
fun: (event: KeyboardEvent) => 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: Record<string, KeyBinding> =
|
||
settings.hotkeys && settings.hotkeys in keyBindingLayouts
|
||
? keyBindingLayouts[settings.hotkeys]
|
||
: keyBindingLayouts.default;
|
||
|
||
const isElementInDetail = (element?: HTMLElement): 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?: HTMLElement): boolean => {
|
||
return resultElement?.classList.contains("result-images") ?? false;
|
||
};
|
||
|
||
const highlightResult =
|
||
(which: HighlightResultElement | HTMLElement) =>
|
||
(noScroll?: boolean, keepFocus?: boolean): void => {
|
||
let effectiveWhich = which;
|
||
let current = document.querySelector<HTMLElement>(".result[data-vim-selected]");
|
||
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;
|
||
}
|
||
}
|
||
|
||
const results = Array.from(document.querySelectorAll<HTMLElement>(".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.at(-1);
|
||
break;
|
||
// biome-ignore lint/complexity/noUselessSwitchCase: fallthrough is intended
|
||
case "top":
|
||
default:
|
||
[next] = results;
|
||
}
|
||
}
|
||
|
||
if (next) {
|
||
current.removeAttribute("data-vim-selected");
|
||
next.setAttribute("data-vim-selected", "true");
|
||
|
||
if (!keepFocus) {
|
||
const link = next.querySelector<HTMLAnchorElement>("h3 a") || next.querySelector<HTMLAnchorElement>("a");
|
||
link?.focus();
|
||
}
|
||
|
||
if (!noScroll) {
|
||
mutable.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 {
|
||
mutable.closeDetail?.();
|
||
}
|
||
};
|
||
|
||
const pageButtonClick = (css_selector: string): void => {
|
||
const button = document.querySelector<HTMLButtonElement>(css_selector);
|
||
if (button) {
|
||
button.click();
|
||
}
|
||
};
|
||
|
||
const GoToNextPage = (): void => {
|
||
pageButtonClick('nav#pagination .next_page button[type="submit"]');
|
||
};
|
||
|
||
const GoToPreviousPage = (): void => {
|
||
pageButtonClick('nav#pagination .previous_page button[type="submit"]');
|
||
};
|
||
|
||
mutable.scrollPageToSelected = (): void => {
|
||
const sel = document.querySelector<HTMLElement>(".result[data-vim-selected]");
|
||
if (!sel) return;
|
||
|
||
const wtop = document.documentElement.scrollTop || document.body.scrollTop;
|
||
const height = document.documentElement.clientHeight;
|
||
const etop = sel.offsetTop;
|
||
const ebot = etop + sel.clientHeight;
|
||
const 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: HighlightResultElement): 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;
|
||
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) {
|
||
// toggle hidden
|
||
helpPanel.classList.toggle("invisible");
|
||
} else {
|
||
// first call
|
||
helpPanel = Object.assign(document.createElement("div"), {
|
||
id: "vim-hotkeys-help",
|
||
className: "dialog-modal"
|
||
});
|
||
initHelpContent(helpPanel, keyBindings);
|
||
const [body] = document.getElementsByTagName("body");
|
||
if (body) {
|
||
body.appendChild(helpPanel);
|
||
}
|
||
}
|
||
};
|
||
|
||
const copyURLToClipboard = async (): Promise<void> => {
|
||
const selectedResult = document.querySelector<HTMLElement>(".result[data-vim-selected]");
|
||
if (!selectedResult) return;
|
||
|
||
const resultAnchor = selectedResult.querySelector<HTMLAnchorElement>("a");
|
||
assertElement(resultAnchor);
|
||
|
||
const url = resultAnchor.getAttribute("href");
|
||
if (url) {
|
||
if (window.isSecureContext) {
|
||
await navigator.clipboard.writeText(url);
|
||
} else {
|
||
const selection = window.getSelection();
|
||
if (selection) {
|
||
const node = document.createElement("span");
|
||
node.textContent = url;
|
||
resultAnchor.appendChild(node);
|
||
|
||
const range = document.createRange();
|
||
range.selectNodeContents(node);
|
||
selection.removeAllRanges();
|
||
selection.addRange(range);
|
||
document.execCommand("copy");
|
||
node.remove();
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
listen("click", ".result", function (this: HTMLElement, event: PointerEvent) {
|
||
if (!isElementInDetail(event.target as HTMLElement)) {
|
||
highlightResult(this)(true, true);
|
||
|
||
const resultElement = getResultElement(event.target as HTMLElement);
|
||
|
||
if (resultElement && isImageResult(resultElement)) {
|
||
event.preventDefault();
|
||
mutable.selectImage?.(resultElement);
|
||
}
|
||
}
|
||
});
|
||
|
||
// FIXME: Focus might also trigger Pointer event ^^^
|
||
listen(
|
||
"focus",
|
||
".result a",
|
||
(event: FocusEvent) => {
|
||
if (!isElementInDetail(event.target as HTMLElement)) {
|
||
const resultElement = getResultElement(event.target as HTMLElement);
|
||
|
||
if (resultElement && !resultElement.hasAttribute("data-vim-selected")) {
|
||
highlightResult(resultElement)(true);
|
||
}
|
||
|
||
if (resultElement && isImageResult(resultElement)) {
|
||
event.preventDefault();
|
||
mutable.selectImage?.(resultElement);
|
||
}
|
||
}
|
||
},
|
||
{ capture: true }
|
||
);
|
||
|
||
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 HTMLElement)?.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);
|
||
}
|
||
}
|
||
});
|
||
|
||
mutable.selectNext = highlightResult("down");
|
||
mutable.selectPrevious = highlightResult("up");
|