summaryrefslogtreecommitdiff
path: root/client/simple/src/js
diff options
context:
space:
mode:
Diffstat (limited to 'client/simple/src/js')
-rw-r--r--client/simple/src/js/head/00_init.js19
-rw-r--r--client/simple/src/js/main/00_toolkit.js178
-rw-r--r--client/simple/src/js/main/00_toolkit.ts118
-rw-r--r--client/simple/src/js/main/index.ts13
-rw-r--r--client/simple/src/js/main/infinite_scroll.js84
-rw-r--r--client/simple/src/js/main/infinite_scroll.ts108
-rw-r--r--client/simple/src/js/main/keyboard.js473
-rw-r--r--client/simple/src/js/main/keyboard.ts469
-rw-r--r--client/simple/src/js/main/mapresult.js77
-rw-r--r--client/simple/src/js/main/mapresult.ts89
-rw-r--r--client/simple/src/js/main/preferences.js52
-rw-r--r--client/simple/src/js/main/preferences.ts71
-rw-r--r--client/simple/src/js/main/results.js182
-rw-r--r--client/simple/src/js/main/results.ts181
-rw-r--r--client/simple/src/js/main/search.js197
-rw-r--r--client/simple/src/js/main/search.ts233
-rw-r--r--client/simple/src/js/pkg/ol.ts26
-rw-r--r--client/simple/src/js/searxng.head.js1
-rw-r--r--client/simple/src/js/searxng.js7
19 files changed, 1308 insertions, 1270 deletions
diff --git a/client/simple/src/js/head/00_init.js b/client/simple/src/js/head/00_init.js
deleted file mode 100644
index 7aec676a4..000000000
--- a/client/simple/src/js/head/00_init.js
+++ /dev/null
@@ -1,19 +0,0 @@
-/* SPDX-License-Identifier: AGPL-3.0-or-later */
-((w, d) => {
- // add data- properties
- const getLastScriptElement = () => {
- const scripts = d.getElementsByTagName("script");
- return scripts[scripts.length - 1];
- };
-
- const script = d.currentScript || getLastScriptElement();
-
- w.searxng = {
- settings: JSON.parse(atob(script.getAttribute("client_settings")))
- };
-
- // update the css
- const htmlElement = d.getElementsByTagName("html")[0];
- htmlElement.classList.remove("no-js");
- htmlElement.classList.add("js");
-})(window, document);
diff --git a/client/simple/src/js/main/00_toolkit.js b/client/simple/src/js/main/00_toolkit.js
deleted file mode 100644
index 81e5071f9..000000000
--- a/client/simple/src/js/main/00_toolkit.js
+++ /dev/null
@@ -1,178 +0,0 @@
-/**
- * @license
- * (C) Copyright Contributors to the SearXNG project.
- * (C) Copyright Contributors to the searx project (2014 - 2021).
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-window.searxng = ((w, d) => {
- // not invented here toolkit with bugs fixed elsewhere
- // purposes : be just good enough and as small as possible
-
- // from https://plainjs.com/javascript/events/live-binding-event-handlers-14/
- if (w.Element) {
- ((ElementPrototype) => {
- ElementPrototype.matches =
- ElementPrototype.matches ||
- ElementPrototype.matchesSelector ||
- ElementPrototype.webkitMatchesSelector ||
- ElementPrototype.msMatchesSelector ||
- function (selector) {
- const nodes = (this.parentNode || this.document).querySelectorAll(selector);
- let i = -1;
- while (nodes[++i] && nodes[i] !== this);
- return !!nodes[i];
- };
- })(Element.prototype);
- }
-
- function callbackSafe(callback, el, e) {
- try {
- callback.call(el, e);
- } catch (exception) {
- console.log(exception);
- }
- }
-
- const searxng = window.searxng || {};
-
- searxng.on = (obj, eventType, callback, useCapture) => {
- useCapture = useCapture || false;
- if (typeof obj !== "string") {
- // obj HTMLElement, HTMLDocument
- obj.addEventListener(eventType, callback, useCapture);
- } else {
- // obj is a selector
- d.addEventListener(
- eventType,
- (e) => {
- let el = e.target || e.srcElement;
- let found = false;
-
- while (el?.matches && el !== d) {
- found = el.matches(obj);
-
- if (found) break;
-
- el = el.parentElement;
- }
-
- if (found) {
- callbackSafe(callback, el, e);
- }
- },
- useCapture
- );
- }
- };
-
- searxng.ready = (callback) => {
- if (document.readyState !== "loading") {
- callback.call(w);
- } else {
- w.addEventListener("DOMContentLoaded", callback.bind(w));
- }
- };
-
- searxng.http = (method, url, data = null) =>
- new Promise((resolve, reject) => {
- try {
- const req = new XMLHttpRequest();
- req.open(method, url, true);
- req.timeout = 20000;
-
- // On load
- req.onload = () => {
- if (req.status === 200) {
- resolve(req.response, req.responseType);
- } else {
- reject(Error(req.statusText));
- }
- };
-
- // Handle network errors
- req.onerror = () => {
- reject(Error("Network Error"));
- };
-
- req.onabort = () => {
- reject(Error("Transaction is aborted"));
- };
-
- req.ontimeout = () => {
- reject(Error("Timeout"));
- };
-
- // Make the request
- if (data) {
- req.send(data);
- } else {
- req.send();
- }
- } catch (ex) {
- reject(ex);
- }
- });
-
- searxng.loadStyle = (src) => {
- const path = `${searxng.settings.theme_static_path}/${src}`;
- const id = `style_${src.replace(".", "_")}`;
- let s = d.getElementById(id);
- if (s === null) {
- s = d.createElement("link");
- s.setAttribute("id", id);
- s.setAttribute("rel", "stylesheet");
- s.setAttribute("type", "text/css");
- s.setAttribute("href", path);
- d.body.appendChild(s);
- }
- };
-
- searxng.loadScript = (src, callback) => {
- const path = `${searxng.settings.theme_static_path}/${src}`;
- const id = `script_${src.replace(".", "_")}`;
- let s = d.getElementById(id);
- if (s === null) {
- s = d.createElement("script");
- s.setAttribute("id", id);
- s.setAttribute("src", path);
- s.onload = callback;
- s.onerror = () => {
- s.setAttribute("error", "1");
- };
- d.body.appendChild(s);
- } else if (!s.hasAttribute("error")) {
- try {
- callback.apply(s, []);
- } catch (exception) {
- console.log(exception);
- }
- } else {
- console.log(`callback not executed : script '${path}' not loaded.`);
- }
- };
-
- searxng.insertBefore = (newNode, referenceNode) => {
- referenceNode.parentNode.insertBefore(newNode, referenceNode);
- };
-
- searxng.insertAfter = (newNode, referenceNode) => {
- referenceNode.parentNode.insertAfter(newNode, referenceNode.nextSibling);
- };
-
- searxng.on(".close", "click", function () {
- this.parentNode.classList.add("invisible");
- });
-
- function getEndpoint() {
- for (const className of d.getElementsByTagName("body")[0].classList.values()) {
- if (className.endsWith("_endpoint")) {
- return className.split("_")[0];
- }
- }
- return "";
- }
-
- searxng.endpoint = getEndpoint();
-
- return searxng;
-})(window, document);
diff --git a/client/simple/src/js/main/00_toolkit.ts b/client/simple/src/js/main/00_toolkit.ts
new file mode 100644
index 000000000..05cfc4b6b
--- /dev/null
+++ b/client/simple/src/js/main/00_toolkit.ts
@@ -0,0 +1,118 @@
+import type { KeyBindingLayout } from "./keyboard.ts";
+
+type Settings = {
+ theme_static_path?: string;
+ method?: string;
+ hotkeys?: KeyBindingLayout;
+ infinite_scroll?: boolean;
+ autocomplete?: boolean;
+ autocomplete_min?: number;
+ search_on_category_select?: boolean;
+ translations?: Record<string, string>;
+ [key: string]: unknown;
+};
+
+type ReadyOptions = {
+ // all values must be truthy for the callback to be executed
+ on?: (boolean | undefined)[];
+};
+
+const getEndpoint = (): string => {
+ const endpointClass = Array.from(document.body.classList).find((className) => className.endsWith("_endpoint"));
+ return endpointClass?.split("_")[0] ?? "";
+};
+
+const getSettings = (): Settings => {
+ const settings = document.querySelector("script[client_settings]")?.getAttribute("client_settings");
+ if (!settings) return {};
+
+ try {
+ return JSON.parse(atob(settings));
+ } catch (error) {
+ console.error("Failed to load client_settings:", error);
+ return {};
+ }
+};
+
+type AssertElement = (element?: Element | null) => asserts element is Element;
+export const assertElement: AssertElement = (element?: Element | null): asserts element is Element => {
+ if (!element) {
+ throw new Error("Bad assertion: DOM element not found");
+ }
+};
+
+export const searxng = {
+ // dynamic functions
+ closeDetail: undefined as (() => void) | undefined,
+ scrollPageToSelected: undefined as (() => void) | undefined,
+ selectImage: undefined as ((resultElement: Element) => void) | undefined,
+ selectNext: undefined as ((openDetailView?: boolean) => void) | undefined,
+ selectPrevious: undefined as ((openDetailView?: boolean) => void) | undefined,
+
+ endpoint: getEndpoint(),
+
+ http: async (method: string, url: string | URL, data?: BodyInit): Promise<Response> => {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 30000);
+
+ const res = await fetch(url, {
+ body: data,
+ method,
+ signal: controller.signal
+ }).finally(() => clearTimeout(timeoutId));
+ if (!res.ok) {
+ throw new Error(res.statusText);
+ }
+
+ return res;
+ },
+
+ listen: <K extends keyof DocumentEventMap, E extends Element>(
+ type: string | K,
+ target: string | Document | E,
+ listener: (this: E, event: DocumentEventMap[K]) => void,
+ options?: AddEventListenerOptions
+ ): void => {
+ if (typeof target !== "string") {
+ target.addEventListener(type, listener as EventListener, options);
+ return;
+ }
+
+ document.addEventListener(
+ type,
+ (event: Event) => {
+ for (const node of event.composedPath()) {
+ if (node instanceof Element && node.matches(target)) {
+ try {
+ listener.call(node as E, event as DocumentEventMap[K]);
+ } catch (error) {
+ console.error(error);
+ }
+ break;
+ }
+ }
+ },
+ options
+ );
+ },
+
+ ready: (callback: () => void, options?: ReadyOptions): void => {
+ for (const condition of options?.on ?? []) {
+ if (!condition) {
+ return;
+ }
+ }
+
+ if (document.readyState !== "loading") {
+ callback();
+ } else {
+ searxng.listen("DOMContentLoaded", document, callback, { once: true });
+ }
+ },
+
+ settings: getSettings()
+};
+
+searxng.listen("click", ".close", function (this: Element) {
+ (this.parentNode as Element)?.classList.add("invisible");
+});
diff --git a/client/simple/src/js/main/index.ts b/client/simple/src/js/main/index.ts
new file mode 100644
index 000000000..4dc86b63b
--- /dev/null
+++ b/client/simple/src/js/main/index.ts
@@ -0,0 +1,13 @@
+/**
+ * @preserve (C) Copyright Contributors to the SearXNG project.
+ * @preserve (C) Copyright Contributors to the searx project (2014 - 2021).
+ * @license AGPL-3.0-or-later
+ */
+
+import "./00_toolkit.ts";
+import "./infinite_scroll.ts";
+import "./keyboard.ts";
+import "./mapresult.ts";
+import "./preferences.ts";
+import "./results.ts";
+import "./search.ts";
diff --git a/client/simple/src/js/main/infinite_scroll.js b/client/simple/src/js/main/infinite_scroll.js
deleted file mode 100644
index 12ecd83be..000000000
--- a/client/simple/src/js/main/infinite_scroll.js
+++ /dev/null
@@ -1,84 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-/* global searxng */
-
-searxng.ready(() => {
- searxng.infinite_scroll_supported =
- "IntersectionObserver" in window &&
- "IntersectionObserverEntry" in window &&
- "intersectionRatio" in window.IntersectionObserverEntry.prototype;
-
- if (searxng.endpoint !== "results") {
- return;
- }
-
- if (!searxng.infinite_scroll_supported) {
- console.log("IntersectionObserver not supported");
- return;
- }
-
- const d = document;
- const onlyImages = d.getElementById("results").classList.contains("only_template_images");
-
- function newLoadSpinner() {
- const loader = d.createElement("div");
- loader.classList.add("loader");
- return loader;
- }
-
- function replaceChildrenWith(element, children) {
- element.textContent = "";
- children.forEach((child) => element.appendChild(child));
- }
-
- function loadNextPage(callback) {
- const form = d.querySelector("#pagination form.next_page");
- if (!form) {
- return;
- }
- replaceChildrenWith(d.querySelector("#pagination"), [newLoadSpinner()]);
- const formData = new FormData(form);
- searxng
- .http("POST", d.querySelector("#search").getAttribute("action"), formData)
- .then((response) => {
- const nextPageDoc = new DOMParser().parseFromString(response, "text/html");
- const articleList = nextPageDoc.querySelectorAll("#urls article");
- const paginationElement = nextPageDoc.querySelector("#pagination");
- d.querySelector("#pagination").remove();
- if (articleList.length > 0 && !onlyImages) {
- // do not add <hr> element when there are only images
- d.querySelector("#urls").appendChild(d.createElement("hr"));
- }
- articleList.forEach((articleElement) => {
- d.querySelector("#urls").appendChild(articleElement);
- });
- if (paginationElement) {
- d.querySelector("#results").appendChild(paginationElement);
- callback();
- }
- })
- .catch((err) => {
- console.log(err);
- const e = d.createElement("div");
- e.textContent = searxng.settings.translations.error_loading_next_page;
- e.classList.add("dialog-error");
- e.setAttribute("role", "alert");
- replaceChildrenWith(d.querySelector("#pagination"), [e]);
- });
- }
-
- if (searxng.settings.infinite_scroll && searxng.infinite_scroll_supported) {
- const intersectionObserveOptions = {
- rootMargin: "20rem"
- };
- const observedSelector = "article.result:last-child";
- const observer = new IntersectionObserver((entries) => {
- const paginationEntry = entries[0];
- if (paginationEntry.isIntersecting) {
- observer.unobserve(paginationEntry.target);
- loadNextPage(() => observer.observe(d.querySelector(observedSelector), intersectionObserveOptions));
- }
- });
- observer.observe(d.querySelector(observedSelector), intersectionObserveOptions);
- }
-});
diff --git a/client/simple/src/js/main/infinite_scroll.ts b/client/simple/src/js/main/infinite_scroll.ts
new file mode 100644
index 000000000..e9f931e51
--- /dev/null
+++ b/client/simple/src/js/main/infinite_scroll.ts
@@ -0,0 +1,108 @@
+import { assertElement, searxng } from "./00_toolkit";
+
+const newLoadSpinner = (): HTMLDivElement => {
+ return Object.assign(document.createElement("div"), {
+ className: "loader"
+ });
+};
+
+const loadNextPage = async (onlyImages: boolean, callback: () => void): Promise<void> => {
+ const searchForm = document.querySelector<HTMLFormElement>("#search");
+ assertElement(searchForm);
+
+ const form = document.querySelector<HTMLFormElement>("#pagination form.next_page");
+ assertElement(form);
+
+ const formData = new FormData(form);
+
+ const action = searchForm.getAttribute("action");
+ if (!action) {
+ console.error("Form action not found");
+ return;
+ }
+
+ const paginationElement = document.querySelector<HTMLElement>("#pagination");
+ assertElement(paginationElement);
+
+ paginationElement.replaceChildren(newLoadSpinner());
+
+ try {
+ const res = await searxng.http("POST", action, formData);
+ const nextPage = await res.text();
+ if (!nextPage) return;
+
+ const nextPageDoc = new DOMParser().parseFromString(nextPage, "text/html");
+ const articleList = nextPageDoc.querySelectorAll<HTMLElement>("#urls article");
+ const nextPaginationElement = nextPageDoc.querySelector<HTMLElement>("#pagination");
+
+ document.querySelector("#pagination")?.remove();
+
+ const urlsElement = document.querySelector<HTMLElement>("#urls");
+ if (!urlsElement) {
+ console.error("URLs element not found");
+ return;
+ }
+
+ if (articleList.length > 0 && !onlyImages) {
+ // do not add <hr> element when there are only images
+ urlsElement.appendChild(document.createElement("hr"));
+ }
+
+ urlsElement.append(...Array.from(articleList));
+
+ if (nextPaginationElement) {
+ const results = document.querySelector<HTMLElement>("#results");
+ results?.appendChild(nextPaginationElement);
+ callback();
+ }
+ } catch (error) {
+ console.error("Error loading next page:", error);
+
+ const errorElement = Object.assign(document.createElement("div"), {
+ textContent: searxng.settings.translations?.error_loading_next_page ?? "Error loading next page",
+ className: "dialog-error"
+ });
+ errorElement.setAttribute("role", "alert");
+ document.querySelector("#pagination")?.replaceChildren(errorElement);
+ }
+};
+
+searxng.ready(
+ () => {
+ const resultsElement = document.getElementById("results");
+ if (!resultsElement) {
+ console.error("Results element not found");
+ return;
+ }
+
+ const onlyImages = resultsElement.classList.contains("only_template_images");
+ const observedSelector = "article.result:last-child";
+
+ const intersectionObserveOptions: IntersectionObserverInit = {
+ rootMargin: "320px"
+ };
+
+ const observer = new IntersectionObserver(async (entries: IntersectionObserverEntry[]) => {
+ const [paginationEntry] = entries;
+
+ if (paginationEntry?.isIntersecting) {
+ observer.unobserve(paginationEntry.target);
+
+ await loadNextPage(onlyImages, () => {
+ const nextObservedElement = document.querySelector<HTMLElement>(observedSelector);
+ if (nextObservedElement) {
+ observer.observe(nextObservedElement);
+ }
+ });
+ }
+ }, intersectionObserveOptions);
+
+ const initialObservedElement = document.querySelector<HTMLElement>(observedSelector);
+ if (initialObservedElement) {
+ observer.observe(initialObservedElement);
+ }
+ },
+ {
+ on: [searxng.endpoint === "results", searxng.settings.infinite_scroll]
+ }
+);
diff --git a/client/simple/src/js/main/keyboard.js b/client/simple/src/js/main/keyboard.js
deleted file mode 100644
index e707c397f..000000000
--- a/client/simple/src/js/main/keyboard.js
+++ /dev/null
@@ -1,473 +0,0 @@
-/* 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 <div class="detail"> 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 = '<a href="#" class="close" aria-label="close" title="close">×</a>';
- html += "<h3>How to navigate SearXNG with hotkeys</h3>";
- html += "<table>";
-
- 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 += "<tr>";
- }
- html += "<td>";
-
- html += `<h4>${cat[0].cat}</h4>`;
- html += '<ul class="list-unstyled">';
-
- for (const cj in cat) {
- html += `<li><kbd>${cat[cj].key}</kbd> ${cat[cj].des}</li>`;
- }
-
- html += "</ul>";
- html += "</td>"; // col-sm-*
-
- if (!first || lastCategory) {
- html += "</tr>"; // row
- }
- }
-
- html += "</table>";
-
- 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");
-});
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");
+});
diff --git a/client/simple/src/js/main/mapresult.js b/client/simple/src/js/main/mapresult.js
deleted file mode 100644
index 3f2d06548..000000000
--- a/client/simple/src/js/main/mapresult.js
+++ /dev/null
@@ -1,77 +0,0 @@
-/* SPDX-License-Identifier: AGPL-3.0-or-later */
-/* global L */
-((_w, _d, searxng) => {
- searxng.ready(() => {
- searxng.on(".searxng_init_map", "click", function (event) {
- // no more request
- this.classList.remove("searxng_init_map");
-
- //
- const leaflet_target = this.dataset.leafletTarget;
- const map_lon = parseFloat(this.dataset.mapLon);
- const map_lat = parseFloat(this.dataset.mapLat);
- const map_zoom = parseFloat(this.dataset.mapZoom);
- const map_boundingbox = JSON.parse(this.dataset.mapBoundingbox);
- const map_geojson = JSON.parse(this.dataset.mapGeojson);
-
- searxng.loadStyle("css/leaflet.css");
- searxng.loadScript("js/leaflet.js", () => {
- let map_bounds = null;
- if (map_boundingbox) {
- const southWest = L.latLng(map_boundingbox[0], map_boundingbox[2]);
- const northEast = L.latLng(map_boundingbox[1], map_boundingbox[3]);
- map_bounds = L.latLngBounds(southWest, northEast);
- }
-
- // init map
- const map = L.map(leaflet_target);
- // create the tile layer with correct attribution
- const osmMapnikUrl = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png";
- const osmMapnikAttrib = 'Map data © <a href="https://openstreetmap.org">OpenStreetMap</a> contributors';
- const osmMapnik = new L.TileLayer(osmMapnikUrl, { minZoom: 1, maxZoom: 19, attribution: osmMapnikAttrib });
- const osmWikimediaUrl = "https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png";
- const osmWikimediaAttrib =
- 'Wikimedia maps | Maps data © <a href="https://openstreetmap.org">OpenStreetMap contributors</a>';
- const osmWikimedia = new L.TileLayer(osmWikimediaUrl, {
- minZoom: 1,
- maxZoom: 19,
- attribution: osmWikimediaAttrib
- });
- // init map view
- if (map_bounds) {
- // TODO hack: https://github.com/Leaflet/Leaflet/issues/2021
- // Still useful ?
- setTimeout(() => {
- map.fitBounds(map_bounds, {
- maxZoom: 17
- });
- }, 0);
- } else if (map_lon && map_lat) {
- if (map_zoom) {
- map.setView(new L.LatLng(map_lat, map_lon), map_zoom);
- } else {
- map.setView(new L.LatLng(map_lat, map_lon), 8);
- }
- }
-
- map.addLayer(osmMapnik);
-
- const baseLayers = {
- "OSM Mapnik": osmMapnik,
- "OSM Wikimedia": osmWikimedia
- };
-
- L.control.layers(baseLayers).addTo(map);
-
- if (map_geojson) {
- L.geoJson(map_geojson).addTo(map);
- } /* else if(map_bounds) {
- L.rectangle(map_bounds, {color: "#ff7800", weight: 3, fill:false}).addTo(map);
- } */
- });
-
- // this event occur only once per element
- event.preventDefault();
- });
- });
-})(window, document, window.searxng);
diff --git a/client/simple/src/js/main/mapresult.ts b/client/simple/src/js/main/mapresult.ts
new file mode 100644
index 000000000..421b41f77
--- /dev/null
+++ b/client/simple/src/js/main/mapresult.ts
@@ -0,0 +1,89 @@
+import { searxng } from "./00_toolkit.ts";
+
+searxng.ready(
+ () => {
+ searxng.listen("click", ".searxng_init_map", async function (this: HTMLElement, event: Event) {
+ event.preventDefault();
+ this.classList.remove("searxng_init_map");
+
+ const {
+ View,
+ OlMap,
+ TileLayer,
+ VectorLayer,
+ OSM,
+ VectorSource,
+ Style,
+ Stroke,
+ Fill,
+ Circle,
+ fromLonLat,
+ GeoJSON,
+ Feature,
+ Point
+ } = await import("../pkg/ol.ts");
+ import("ol/ol.css");
+
+ const { leafletTarget: target, mapLon, mapLat, mapGeojson } = this.dataset;
+
+ const lon = parseFloat(mapLon || "0");
+ const lat = parseFloat(mapLat || "0");
+ const view = new View({ maxZoom: 16, enableRotation: false });
+ const map = new OlMap({
+ target,
+ layers: [new TileLayer({ source: new OSM({ maxZoom: 16 }) })],
+ view
+ });
+
+ try {
+ const markerSource = new VectorSource({
+ features: [
+ new Feature({
+ geometry: new Point(fromLonLat([lon, lat]))
+ })
+ ]
+ });
+
+ const markerLayer = new VectorLayer({
+ source: markerSource,
+ style: new Style({
+ image: new Circle({
+ radius: 6,
+ fill: new Fill({ color: "#3050ff" })
+ })
+ })
+ });
+
+ map.addLayer(markerLayer);
+ } catch (error) {
+ console.error("Failed to create marker layer:", error);
+ }
+
+ if (mapGeojson) {
+ try {
+ const geoSource = new VectorSource({
+ features: new GeoJSON().readFeatures(JSON.parse(mapGeojson), {
+ dataProjection: "EPSG:4326",
+ featureProjection: "EPSG:3857"
+ })
+ });
+
+ const geoLayer = new VectorLayer({
+ source: geoSource,
+ style: new Style({
+ stroke: new Stroke({ color: "#3050ff", width: 2 }),
+ fill: new Fill({ color: "#3050ff33" })
+ })
+ });
+
+ map.addLayer(geoLayer);
+
+ view.fit(geoSource.getExtent(), { padding: [20, 20, 20, 20] });
+ } catch (error) {
+ console.error("Failed to create GeoJSON layer:", error);
+ }
+ }
+ });
+ },
+ { on: [searxng.endpoint === "results"] }
+);
diff --git a/client/simple/src/js/main/preferences.js b/client/simple/src/js/main/preferences.js
deleted file mode 100644
index ac080e290..000000000
--- a/client/simple/src/js/main/preferences.js
+++ /dev/null
@@ -1,52 +0,0 @@
-/* SPDX-License-Identifier: AGPL-3.0-or-later */
-((_w, d, searxng) => {
- if (searxng.endpoint !== "preferences") {
- return;
- }
-
- searxng.ready(() => {
- let engine_descriptions = null;
-
- function load_engine_descriptions() {
- if (engine_descriptions == null) {
- searxng.http("GET", "engine_descriptions.json").then((content) => {
- engine_descriptions = JSON.parse(content);
- for (const [engine_name, description] of Object.entries(engine_descriptions)) {
- const elements = d.querySelectorAll(`[data-engine-name="${engine_name}"] .engine-description`);
- for (const element of elements) {
- const source = ` (<i>${searxng.settings.translations.Source}:&nbsp;${description[1]}</i>)`;
- element.innerHTML = description[0] + source;
- }
- }
- });
- }
- }
-
- for (const el of d.querySelectorAll("[data-engine-name]")) {
- searxng.on(el, "mouseenter", load_engine_descriptions);
- }
-
- const enableAllEngines = d.querySelectorAll(".enable-all-engines");
- const disableAllEngines = d.querySelectorAll(".disable-all-engines");
- const engineToggles = d.querySelectorAll("tbody input[type=checkbox][class~=checkbox-onoff]");
- const toggleEngines = (enable) => {
- for (const el of engineToggles) {
- // check if element visible, so that only engines of the current category are modified
- if (el.offsetParent !== null) el.checked = !enable;
- }
- };
- for (const el of enableAllEngines) {
- searxng.on(el, "click", () => toggleEngines(true));
- }
- for (const el of disableAllEngines) {
- searxng.on(el, "click", () => toggleEngines(false));
- }
-
- const copyHashButton = d.querySelector("#copy-hash");
- searxng.on(copyHashButton, "click", (e) => {
- e.preventDefault();
- navigator.clipboard.writeText(copyHashButton.dataset.hash);
- copyHashButton.innerText = copyHashButton.dataset.copiedText;
- });
- });
-})(window, document, window.searxng);
diff --git a/client/simple/src/js/main/preferences.ts b/client/simple/src/js/main/preferences.ts
new file mode 100644
index 000000000..6c66018a6
--- /dev/null
+++ b/client/simple/src/js/main/preferences.ts
@@ -0,0 +1,71 @@
+import { searxng } from "./00_toolkit.ts";
+
+const loadEngineDescriptions = async (): Promise<void> => {
+ let engineDescriptions: Record<string, [string, string]> | null = null;
+ try {
+ const res = await searxng.http("GET", "engine_descriptions.json");
+ engineDescriptions = await res.json();
+ } catch (error) {
+ console.error("Error fetching engineDescriptions:", error);
+ }
+ if (!engineDescriptions) return;
+
+ for (const [engine_name, [description, source]] of Object.entries(engineDescriptions)) {
+ const elements = document.querySelectorAll<HTMLElement>(`[data-engine-name="${engine_name}"] .engine-description`);
+ const sourceText = ` (<i>${searxng.settings.translations?.Source}:&nbsp;${source}</i>)`;
+
+ for (const element of elements) {
+ element.innerHTML = description + sourceText;
+ }
+ }
+};
+
+const toggleEngines = (enable: boolean, engineToggles: NodeListOf<HTMLInputElement>): void => {
+ for (const engineToggle of engineToggles) {
+ // check if element visible, so that only engines of the current category are modified
+ if (engineToggle.offsetParent) {
+ engineToggle.checked = !enable;
+ }
+ }
+};
+
+searxng.ready(
+ () => {
+ const engineElements = document.querySelectorAll<HTMLElement>("[data-engine-name]");
+ for (const engineElement of engineElements) {
+ searxng.listen("mouseenter", engineElement, loadEngineDescriptions);
+ }
+
+ const engineToggles = document.querySelectorAll<HTMLInputElement>(
+ "tbody input[type=checkbox][class~=checkbox-onoff]"
+ );
+
+ const enableAllEngines = document.querySelectorAll<HTMLElement>(".enable-all-engines");
+ for (const engine of enableAllEngines) {
+ searxng.listen("click", engine, () => toggleEngines(true, engineToggles));
+ }
+
+ const disableAllEngines = document.querySelectorAll<HTMLElement>(".disable-all-engines");
+ for (const engine of disableAllEngines) {
+ searxng.listen("click", engine, () => toggleEngines(false, engineToggles));
+ }
+
+ const copyHashButton = document.querySelector<HTMLElement>("#copy-hash");
+ if (copyHashButton) {
+ searxng.listen("click", copyHashButton, async (event: Event) => {
+ event.preventDefault();
+
+ const { copiedText, hash } = copyHashButton.dataset;
+ if (!copiedText || !hash) return;
+
+ try {
+ await navigator.clipboard.writeText(hash);
+ copyHashButton.innerText = copiedText;
+ } catch (error) {
+ console.error("Failed to copy hash:", error);
+ }
+ });
+ }
+ },
+ { on: [searxng.endpoint === "preferences"] }
+);
diff --git a/client/simple/src/js/main/results.js b/client/simple/src/js/main/results.js
deleted file mode 100644
index cf7829912..000000000
--- a/client/simple/src/js/main/results.js
+++ /dev/null
@@ -1,182 +0,0 @@
-/* SPDX-License-Identifier: AGPL-3.0-or-later */
-
-import "../../../node_modules/swiped-events/src/swiped-events.js";
-
-((w, d, searxng) => {
- if (searxng.endpoint !== "results") {
- return;
- }
-
- searxng.ready(() => {
- d.querySelectorAll("#urls img").forEach((img) =>
- img.addEventListener(
- "error",
- () => {
- // console.log("ERROR can't load: " + img.src);
- img.src = `${window.searxng.settings.theme_static_path}/img/img_load_error.svg`;
- },
- { once: true }
- )
- );
-
- if (d.querySelector("#search_url button#copy_url")) {
- d.querySelector("#search_url button#copy_url").style.display = "block";
- }
-
- searxng.on(".btn-collapse", "click", function () {
- const btnLabelCollapsed = this.getAttribute("data-btn-text-collapsed");
- const btnLabelNotCollapsed = this.getAttribute("data-btn-text-not-collapsed");
- const target = this.getAttribute("data-target");
- const targetElement = d.querySelector(target);
- let html = this.innerHTML;
- if (this.classList.contains("collapsed")) {
- html = html.replace(btnLabelCollapsed, btnLabelNotCollapsed);
- } else {
- html = html.replace(btnLabelNotCollapsed, btnLabelCollapsed);
- }
- this.innerHTML = html;
- this.classList.toggle("collapsed");
- targetElement.classList.toggle("invisible");
- });
-
- searxng.on(".media-loader", "click", function () {
- const target = this.getAttribute("data-target");
- const iframe_load = d.querySelector(`${target} > iframe`);
- const srctest = iframe_load.getAttribute("src");
- if (srctest === null || srctest === undefined || srctest === false) {
- iframe_load.setAttribute("src", iframe_load.getAttribute("data-src"));
- }
- });
-
- searxng.on("#copy_url", "click", function () {
- const target = this.parentElement.querySelector("pre");
- navigator.clipboard.writeText(target.innerText);
- this.innerText = this.dataset.copiedText;
- });
-
- // searxng.selectImage (gallery)
- // -----------------------------
-
- // setTimeout() ID, needed to cancel *last* loadImage
- let imgTimeoutID;
-
- // progress spinner, while an image is loading
- const imgLoaderSpinner = d.createElement("div");
- imgLoaderSpinner.classList.add("loader");
-
- // singleton image object, which is used for all loading processes of a
- // detailed image
- const imgLoader = new Image();
-
- const loadImage = (imgSrc, onSuccess) => {
- // if defered image load exists, stop defered task.
- if (imgTimeoutID) clearTimeout(imgTimeoutID);
-
- // defer load of the detail image for 1 sec
- imgTimeoutID = setTimeout(() => {
- imgLoader.src = imgSrc;
- }, 1000);
-
- // set handlers in the on-properties
- imgLoader.onload = () => {
- onSuccess();
- imgLoaderSpinner.remove();
- };
- imgLoader.onerror = () => {
- imgLoaderSpinner.remove();
- };
- };
-
- searxng.selectImage = (resultElement) => {
- // add a class that can be evaluated in the CSS and indicates that the
- // detail view is open
- d.getElementById("results").classList.add("image-detail-open");
-
- // add a hash to the browser history so that pressing back doesn't return
- // to the previous page this allows us to dismiss the image details on
- // pressing the back button on mobile devices
- window.location.hash = "#image-viewer";
-
- searxng.scrollPageToSelected();
-
- // if there is none element given by the caller, stop here
- if (!resultElement) return;
-
- // find <img> object in the element, if there is none, stop here.
- const img = resultElement.querySelector(".result-images-source img");
- if (!img) return;
-
- // <img src="" data-src="http://example.org/image.jpg">
- const src = img.getAttribute("data-src");
-
- // already loaded high-res image or no high-res image available
- if (!src) return;
-
- // use the image thumbnail until the image is fully loaded
- const thumbnail = resultElement.querySelector(".image_thumbnail");
- img.src = thumbnail.src;
-
- // show a progress spinner
- const detailElement = resultElement.querySelector(".detail");
- detailElement.appendChild(imgLoaderSpinner);
-
- // load full size image in background
- loadImage(src, () => {
- // after the singelton loadImage has loaded the detail image into the
- // cache, it can be used in the origin <img> as src property.
- img.src = src;
- img.removeAttribute("data-src");
- });
- };
-
- searxng.closeDetail = () => {
- d.getElementById("results").classList.remove("image-detail-open");
- // remove #image-viewer hash from url by navigating back
- if (window.location.hash === "#image-viewer") window.history.back();
- searxng.scrollPageToSelected();
- };
- searxng.on(".result-detail-close", "click", (e) => {
- e.preventDefault();
- searxng.closeDetail();
- });
- searxng.on(".result-detail-previous", "click", (e) => {
- e.preventDefault();
- searxng.selectPrevious(false);
- });
- searxng.on(".result-detail-next", "click", (e) => {
- e.preventDefault();
- searxng.selectNext(false);
- });
-
- // listen for the back button to be pressed and dismiss the image details when called
- window.addEventListener("hashchange", () => {
- if (window.location.hash !== "#image-viewer") searxng.closeDetail();
- });
-
- d.querySelectorAll(".swipe-horizontal").forEach((obj) => {
- obj.addEventListener("swiped-left", () => {
- searxng.selectNext(false);
- });
- obj.addEventListener("swiped-right", () => {
- searxng.selectPrevious(false);
- });
- });
-
- w.addEventListener(
- "scroll",
- () => {
- const e = d.getElementById("backToTop"),
- scrollTop = document.documentElement.scrollTop || document.body.scrollTop,
- results = d.getElementById("results");
- if (e !== null) {
- if (scrollTop >= 100) {
- results.classList.add("scrolling");
- } else {
- results.classList.remove("scrolling");
- }
- }
- },
- true
- );
- });
-})(window, document, window.searxng);
diff --git a/client/simple/src/js/main/results.ts b/client/simple/src/js/main/results.ts
new file mode 100644
index 000000000..e278c894a
--- /dev/null
+++ b/client/simple/src/js/main/results.ts
@@ -0,0 +1,181 @@
+import "../../../node_modules/swiped-events/src/swiped-events.js";
+import { assertElement, searxng } from "./00_toolkit.ts";
+
+const loadImage = (imgSrc: string, onSuccess: () => void): void => {
+ // singleton image object, which is used for all loading processes of a detailed image
+ const imgLoader = new Image();
+
+ // set handlers in the on-properties
+ imgLoader.onload = () => {
+ onSuccess();
+ };
+
+ imgLoader.src = imgSrc;
+};
+
+searxng.ready(
+ () => {
+ const imageThumbnails = document.querySelectorAll<HTMLImageElement>("#urls img.image_thumbnail");
+ for (const thumbnail of imageThumbnails) {
+ if (thumbnail.complete && thumbnail.naturalWidth === 0) {
+ thumbnail.src = `${searxng.settings.theme_static_path}/img/img_load_error.svg`;
+ }
+
+ thumbnail.onerror = () => {
+ thumbnail.src = `${searxng.settings.theme_static_path}/img/img_load_error.svg`;
+ };
+ }
+
+ const copyUrlButton = document.querySelector<HTMLButtonElement>("#search_url button#copy_url");
+ copyUrlButton?.style.setProperty("display", "block");
+
+ searxng.listen("click", ".btn-collapse", function (this: HTMLElement) {
+ const btnLabelCollapsed = this.getAttribute("data-btn-text-collapsed");
+ const btnLabelNotCollapsed = this.getAttribute("data-btn-text-not-collapsed");
+ const target = this.getAttribute("data-target");
+
+ if (!target || !btnLabelCollapsed || !btnLabelNotCollapsed) return;
+
+ const targetElement = document.querySelector<HTMLElement>(target);
+ assertElement(targetElement);
+
+ const isCollapsed = this.classList.contains("collapsed");
+ const newLabel = isCollapsed ? btnLabelNotCollapsed : btnLabelCollapsed;
+ const oldLabel = isCollapsed ? btnLabelCollapsed : btnLabelNotCollapsed;
+
+ this.innerHTML = this.innerHTML.replace(oldLabel, newLabel);
+ this.classList.toggle("collapsed");
+
+ targetElement.classList.toggle("invisible");
+ });
+
+ searxng.listen("click", ".media-loader", function (this: HTMLElement) {
+ const target = this.getAttribute("data-target");
+ if (!target) return;
+
+ const iframeLoad = document.querySelector<HTMLIFrameElement>(`${target} > iframe`);
+ assertElement(iframeLoad);
+
+ const srctest = iframeLoad.getAttribute("src");
+ if (!srctest) {
+ const dataSrc = iframeLoad.getAttribute("data-src");
+ if (dataSrc) {
+ iframeLoad.setAttribute("src", dataSrc);
+ }
+ }
+ });
+
+ searxng.listen("click", "#copy_url", async function (this: HTMLElement) {
+ const target = this.parentElement?.querySelector<HTMLPreElement>("pre");
+ assertElement(target);
+
+ await navigator.clipboard.writeText(target.innerText);
+ const copiedText = this.dataset.copiedText;
+ if (copiedText) {
+ this.innerText = copiedText;
+ }
+ });
+
+ searxng.selectImage = (resultElement: Element): void => {
+ // add a class that can be evaluated in the CSS and indicates that the
+ // detail view is open
+ const resultsElement = document.getElementById("results");
+ resultsElement?.classList.add("image-detail-open");
+
+ // add a hash to the browser history so that pressing back doesn't return
+ // to the previous page this allows us to dismiss the image details on
+ // pressing the back button on mobile devices
+ window.location.hash = "#image-viewer";
+
+ searxng.scrollPageToSelected?.();
+
+ // if there is no element given by the caller, stop here
+ if (!resultElement) return;
+
+ // find image element, if there is none, stop here
+ const img = resultElement.querySelector<HTMLImageElement>(".result-images-source img");
+ if (!img) return;
+
+ // <img src="" data-src="http://example.org/image.jpg">
+ const src = img.getAttribute("data-src");
+ if (!src) return;
+
+ // use thumbnail until full image loads
+ const thumbnail = resultElement.querySelector<HTMLImageElement>(".image_thumbnail");
+ if (thumbnail) {
+ img.src = thumbnail.src;
+ }
+
+ // load full size image
+ loadImage(src, () => {
+ img.src = src;
+ img.onerror = () => {
+ img.src = `${searxng.settings.theme_static_path}/img/img_load_error.svg`;
+ };
+
+ img.removeAttribute("data-src");
+ });
+ };
+
+ searxng.closeDetail = (): void => {
+ const resultsElement = document.getElementById("results");
+ resultsElement?.classList.remove("image-detail-open");
+
+ // remove #image-viewer hash from url by navigating back
+ if (window.location.hash === "#image-viewer") {
+ window.history.back();
+ }
+
+ searxng.scrollPageToSelected?.();
+ };
+
+ searxng.listen("click", ".result-detail-close", (event: Event) => {
+ event.preventDefault();
+ searxng.closeDetail?.();
+ });
+
+ searxng.listen("click", ".result-detail-previous", (event: Event) => {
+ event.preventDefault();
+ searxng.selectPrevious?.(false);
+ });
+
+ searxng.listen("click", ".result-detail-next", (event: Event) => {
+ event.preventDefault();
+ searxng.selectNext?.(false);
+ });
+
+ // listen for the back button to be pressed and dismiss the image details when called
+ window.addEventListener("hashchange", () => {
+ if (window.location.hash !== "#image-viewer") {
+ searxng.closeDetail?.();
+ }
+ });
+
+ const swipeHorizontal = document.querySelectorAll<HTMLElement>(".swipe-horizontal");
+ for (const element of swipeHorizontal) {
+ searxng.listen("swiped-left", element, () => {
+ searxng.selectNext?.(false);
+ });
+
+ searxng.listen("swiped-right", element, () => {
+ searxng.selectPrevious?.(false);
+ });
+ }
+
+ window.addEventListener(
+ "scroll",
+ () => {
+ const backToTopElement = document.getElementById("backToTop");
+ const resultsElement = document.getElementById("results");
+
+ if (backToTopElement && resultsElement) {
+ const scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
+ const isScrolling = scrollTop >= 100;
+ resultsElement.classList.toggle("scrolling", isScrolling);
+ }
+ },
+ true
+ );
+ },
+ { on: [searxng.endpoint === "results"] }
+);
diff --git a/client/simple/src/js/main/search.js b/client/simple/src/js/main/search.js
deleted file mode 100644
index 5d1e18bde..000000000
--- a/client/simple/src/js/main/search.js
+++ /dev/null
@@ -1,197 +0,0 @@
-/* SPDX-License-Identifier: AGPL-3.0-or-later */
-/* exported AutoComplete */
-
-((_w, d, searxng) => {
- const qinput_id = "q";
- let qinput;
-
- const isMobile = window.matchMedia("only screen and (max-width: 50em)").matches;
- const isResultsPage = document.querySelector("main").id === "main_results";
-
- function submitIfQuery() {
- if (qinput.value.length > 0) {
- const search = document.getElementById("search");
- setTimeout(search.submit.bind(search), 0);
- }
- }
-
- function createClearButton(qinput) {
- const cs = document.getElementById("clear_search");
- const updateClearButton = () => {
- if (qinput.value.length === 0) {
- cs.classList.add("empty");
- } else {
- cs.classList.remove("empty");
- }
- };
-
- // update status, event listener
- updateClearButton();
- cs.addEventListener("click", (ev) => {
- qinput.value = "";
- qinput.focus();
- updateClearButton();
- ev.preventDefault();
- });
- qinput.addEventListener("input", updateClearButton, false);
- }
-
- const fetchResults = async (query) => {
- let request;
- if (searxng.settings.method === "GET") {
- const reqParams = new URLSearchParams();
- reqParams.append("q", query);
- request = fetch(`./autocompleter?${reqParams.toString()}`);
- } else {
- const formData = new FormData();
- formData.append("q", query);
- request = fetch("./autocompleter", {
- method: "POST",
- body: formData
- });
- }
-
- request.then(async (response) => {
- const results = await response.json();
-
- if (!results) return;
-
- const autocomplete = d.querySelector(".autocomplete");
- const autocompleteList = d.querySelector(".autocomplete ul");
- autocomplete.classList.add("open");
- autocompleteList.innerHTML = "";
-
- // show an error message that no result was found
- if (!results[1] || results[1].length === 0) {
- const noItemFoundMessage = document.createElement("li");
- noItemFoundMessage.classList.add("no-item-found");
- noItemFoundMessage.innerHTML = searxng.settings.translations.no_item_found;
- autocompleteList.appendChild(noItemFoundMessage);
- return;
- }
-
- for (const result of results[1]) {
- const li = document.createElement("li");
- li.innerText = result;
-
- searxng.on(li, "mousedown", () => {
- qinput.value = result;
- const form = d.querySelector("#search");
- form.submit();
- autocomplete.classList.remove("open");
- });
- autocompleteList.appendChild(li);
- }
- });
- };
-
- searxng.ready(() => {
- // focus search input on large screens
- if (!isMobile && !isResultsPage) document.getElementById("q").focus();
-
- qinput = d.getElementById(qinput_id);
- const autocomplete = d.querySelector(".autocomplete");
- const autocompleteList = d.querySelector(".autocomplete ul");
-
- if (qinput !== null) {
- // clear button
- createClearButton(qinput);
-
- // autocompleter
- if (searxng.settings.autocomplete) {
- searxng.on(qinput, "input", () => {
- const query = qinput.value;
- if (query.length < searxng.settings.autocomplete_min) return;
-
- setTimeout(() => {
- if (query === qinput.value) fetchResults(query);
- }, 300);
- });
-
- searxng.on(qinput, "keyup", (e) => {
- let currentIndex = -1;
- const listItems = autocompleteList.children;
- for (let i = 0; i < listItems.length; i++) {
- if (listItems[i].classList.contains("active")) {
- currentIndex = i;
- break;
- }
- }
-
- let newCurrentIndex = -1;
- if (e.key === "ArrowUp") {
- if (currentIndex >= 0) listItems[currentIndex].classList.remove("active");
- // we need to add listItems.length to the index calculation here because the JavaScript modulos
- // operator doesn't work with negative numbers
- newCurrentIndex = (currentIndex - 1 + listItems.length) % listItems.length;
- } else if (e.key === "ArrowDown") {
- if (currentIndex >= 0) listItems[currentIndex].classList.remove("active");
- newCurrentIndex = (currentIndex + 1) % listItems.length;
- } else if (e.key === "Tab" || e.key === "Enter") {
- autocomplete.classList.remove("open");
- }
-
- if (newCurrentIndex !== -1) {
- const selectedItem = listItems[newCurrentIndex];
- selectedItem.classList.add("active");
-
- if (!selectedItem.classList.contains("no-item-found")) qinput.value = selectedItem.innerText;
- }
- });
- }
- }
-
- // Additionally to searching when selecting a new category, we also
- // automatically start a new search request when the user changes a search
- // filter (safesearch, time range or language) (this requires JavaScript
- // though)
- if (
- qinput !== null &&
- searxng.settings.search_on_category_select &&
- // If .search_filters is undefined (invisible) we are on the homepage and
- // hence don't have to set any listeners
- d.querySelector(".search_filters") != null
- ) {
- searxng.on(d.getElementById("safesearch"), "change", submitIfQuery);
- searxng.on(d.getElementById("time_range"), "change", submitIfQuery);
- searxng.on(d.getElementById("language"), "change", submitIfQuery);
- }
-
- const categoryButtons = d.querySelectorAll("button.category_button");
- for (const button of categoryButtons) {
- searxng.on(button, "click", (event) => {
- if (event.shiftKey) {
- event.preventDefault();
- button.classList.toggle("selected");
- return;
- }
-
- // manually deselect the old selection when a new category is selected
- const selectedCategories = d.querySelectorAll("button.category_button.selected");
- for (const categoryButton of selectedCategories) {
- categoryButton.classList.remove("selected");
- }
- button.classList.add("selected");
- });
- }
-
- // override form submit action to update the actually selected categories
- const form = d.querySelector("#search");
- if (form != null) {
- searxng.on(form, "submit", (event) => {
- event.preventDefault();
- const categoryValuesInput = d.querySelector("#selected-categories");
- if (categoryValuesInput) {
- const categoryValues = [];
- for (const categoryButton of categoryButtons) {
- if (categoryButton.classList.contains("selected")) {
- categoryValues.push(categoryButton.name.replace("category_", ""));
- }
- }
- categoryValuesInput.value = categoryValues.join(",");
- }
- form.submit();
- });
- }
- });
-})(window, document, window.searxng);
diff --git a/client/simple/src/js/main/search.ts b/client/simple/src/js/main/search.ts
new file mode 100644
index 000000000..5e68965b1
--- /dev/null
+++ b/client/simple/src/js/main/search.ts
@@ -0,0 +1,233 @@
+import { assertElement, searxng } from "./00_toolkit.ts";
+
+const submitIfQuery = (qInput: HTMLInputElement): void => {
+ if (qInput.value.length > 0) {
+ const search = document.getElementById("search") as HTMLFormElement | null;
+ search?.submit();
+ }
+};
+
+const updateClearButton = (qInput: HTMLInputElement, cs: HTMLElement): void => {
+ cs.classList.toggle("empty", qInput.value.length === 0);
+};
+
+const createClearButton = (qInput: HTMLInputElement): void => {
+ const cs = document.getElementById("clear_search");
+ assertElement(cs);
+
+ updateClearButton(qInput, cs);
+
+ searxng.listen("click", cs, (event: MouseEvent) => {
+ event.preventDefault();
+ qInput.value = "";
+ qInput.focus();
+ updateClearButton(qInput, cs);
+ });
+
+ searxng.listen("input", qInput, () => updateClearButton(qInput, cs), { passive: true });
+};
+
+const fetchResults = async (qInput: HTMLInputElement, query: string): Promise<void> => {
+ try {
+ let res: Response;
+
+ if (searxng.settings.method === "GET") {
+ res = await searxng.http("GET", `./autocompleter?q=${query}`);
+ } else {
+ res = await searxng.http("POST", "./autocompleter", new URLSearchParams({ q: query }));
+ }
+
+ const results = await res.json();
+
+ const autocomplete = document.querySelector<HTMLElement>(".autocomplete");
+ assertElement(autocomplete);
+
+ const autocompleteList = document.querySelector<HTMLUListElement>(".autocomplete ul");
+ assertElement(autocompleteList);
+
+ autocomplete.classList.add("open");
+ autocompleteList.replaceChildren();
+
+ // show an error message that no result was found
+ if (!results?.[1]?.length) {
+ const noItemFoundMessage = Object.assign(document.createElement("li"), {
+ className: "no-item-found",
+ textContent: searxng.settings.translations?.no_item_found ?? "No results found"
+ });
+ autocompleteList.append(noItemFoundMessage);
+ return;
+ }
+
+ const fragment = new DocumentFragment();
+
+ for (const result of results[1]) {
+ const li = Object.assign(document.createElement("li"), { textContent: result });
+
+ searxng.listen("mousedown", li, () => {
+ qInput.value = result;
+
+ const form = document.querySelector<HTMLFormElement>("#search");
+ form?.submit();
+
+ autocomplete.classList.remove("open");
+ });
+
+ fragment.append(li);
+ }
+
+ autocompleteList.append(fragment);
+ } catch (error) {
+ console.error("Error fetching autocomplete results:", error);
+ }
+};
+
+searxng.ready(
+ () => {
+ const qInput = document.getElementById("q") as HTMLInputElement | null;
+ assertElement(qInput);
+
+ const isMobile = window.matchMedia("(max-width: 50em)").matches;
+ const isResultsPage = document.querySelector("main")?.id === "main_results";
+
+ // focus search input on large screens
+ if (!isMobile && !isResultsPage) {
+ qInput.focus();
+ }
+
+ createClearButton(qInput);
+
+ // autocompleter
+ if (searxng.settings.autocomplete) {
+ let timeoutId: number;
+
+ searxng.listen("input", qInput, () => {
+ clearTimeout(timeoutId);
+
+ const query = qInput.value;
+ const minLength = searxng.settings.autocomplete_min ?? 2;
+
+ if (query.length < minLength) return;
+
+ timeoutId = window.setTimeout(async () => {
+ if (query === qInput.value) {
+ await fetchResults(qInput, query);
+ }
+ }, 300);
+ });
+
+ const autocomplete = document.querySelector<HTMLElement>(".autocomplete");
+ const autocompleteList = document.querySelector<HTMLUListElement>(".autocomplete ul");
+ if (autocompleteList) {
+ searxng.listen("keyup", qInput, (event: KeyboardEvent) => {
+ const listItems = [...autocompleteList.children] as HTMLElement[];
+
+ const currentIndex = listItems.findIndex((item) => item.classList.contains("active"));
+ let newCurrentIndex = -1;
+
+ switch (event.key) {
+ case "ArrowUp": {
+ const currentItem = listItems[currentIndex];
+ if (currentItem && currentIndex >= 0) {
+ currentItem.classList.remove("active");
+ }
+ // we need to add listItems.length to the index calculation here because the JavaScript modulos
+ // operator doesn't work with negative numbers
+ newCurrentIndex = (currentIndex - 1 + listItems.length) % listItems.length;
+ break;
+ }
+ case "ArrowDown": {
+ const currentItem = listItems[currentIndex];
+ if (currentItem && currentIndex >= 0) {
+ currentItem.classList.remove("active");
+ }
+ newCurrentIndex = (currentIndex + 1) % listItems.length;
+ break;
+ }
+ case "Tab":
+ case "Enter":
+ if (autocomplete) {
+ autocomplete.classList.remove("open");
+ }
+ break;
+ }
+
+ if (newCurrentIndex !== -1) {
+ const selectedItem = listItems[newCurrentIndex];
+ if (selectedItem) {
+ selectedItem.classList.add("active");
+
+ if (!selectedItem.classList.contains("no-item-found")) {
+ const qInput = document.getElementById("q") as HTMLInputElement | null;
+ if (qInput) {
+ qInput.value = selectedItem.textContent ?? "";
+ }
+ }
+ }
+ }
+ });
+ }
+ }
+
+ // Additionally to searching when selecting a new category, we also
+ // automatically start a new search request when the user changes a search
+ // filter (safesearch, time range or language) (this requires JavaScript
+ // though)
+ if (
+ searxng.settings.search_on_category_select &&
+ // If .search_filters is undefined (invisible) we are on the homepage and
+ // hence don't have to set any listeners
+ document.querySelector(".search_filters")
+ ) {
+ const safesearchElement = document.getElementById("safesearch");
+ if (safesearchElement) {
+ searxng.listen("change", safesearchElement, () => submitIfQuery(qInput));
+ }
+
+ const timeRangeElement = document.getElementById("time_range");
+ if (timeRangeElement) {
+ searxng.listen("change", timeRangeElement, () => submitIfQuery(qInput));
+ }
+
+ const languageElement = document.getElementById("language");
+ if (languageElement) {
+ searxng.listen("change", languageElement, () => submitIfQuery(qInput));
+ }
+ }
+
+ const categoryButtons = [...document.querySelectorAll<HTMLButtonElement>("button.category_button")];
+ for (const button of categoryButtons) {
+ searxng.listen("click", button, (event: MouseEvent) => {
+ if (event.shiftKey) {
+ event.preventDefault();
+ button.classList.toggle("selected");
+ return;
+ }
+
+ // deselect all other categories
+ for (const categoryButton of categoryButtons) {
+ categoryButton.classList.toggle("selected", categoryButton === button);
+ }
+ });
+ }
+
+ const form = document.querySelector<HTMLFormElement>("#search");
+ assertElement(form);
+
+ // override form submit action to update the actually selected categories
+ searxng.listen("submit", form, (event: Event) => {
+ event.preventDefault();
+
+ const categoryValuesInput = document.querySelector<HTMLInputElement>("#selected-categories");
+ if (categoryValuesInput) {
+ const categoryValues = categoryButtons
+ .filter((button) => button.classList.contains("selected"))
+ .map((button) => button.name.replace("category_", ""));
+
+ categoryValuesInput.value = categoryValues.join(",");
+ }
+
+ form.submit();
+ });
+ },
+ { on: [searxng.endpoint === "index" || searxng.endpoint === "results"] }
+);
diff --git a/client/simple/src/js/pkg/ol.ts b/client/simple/src/js/pkg/ol.ts
new file mode 100644
index 000000000..f0f932182
--- /dev/null
+++ b/client/simple/src/js/pkg/ol.ts
@@ -0,0 +1,26 @@
+import { Feature, Map as OlMap, View } from "ol";
+import { createEmpty } from "ol/extent";
+import { GeoJSON } from "ol/format";
+import { Point } from "ol/geom";
+import { Tile as TileLayer, Vector as VectorLayer } from "ol/layer";
+import { fromLonLat } from "ol/proj";
+import { OSM, Vector as VectorSource } from "ol/source";
+import { Circle, Fill, Stroke, Style } from "ol/style";
+
+export {
+ View,
+ OlMap,
+ TileLayer,
+ VectorLayer,
+ OSM,
+ createEmpty,
+ VectorSource,
+ Style,
+ Stroke,
+ Fill,
+ Circle,
+ fromLonLat,
+ GeoJSON,
+ Feature,
+ Point
+};
diff --git a/client/simple/src/js/searxng.head.js b/client/simple/src/js/searxng.head.js
deleted file mode 100644
index 895c95f94..000000000
--- a/client/simple/src/js/searxng.head.js
+++ /dev/null
@@ -1 +0,0 @@
-import "./head/00_init.js";
diff --git a/client/simple/src/js/searxng.js b/client/simple/src/js/searxng.js
deleted file mode 100644
index c9a6eea43..000000000
--- a/client/simple/src/js/searxng.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import "./main/00_toolkit.js";
-import "./main/infinite_scroll.js";
-import "./main/keyboard.js";
-import "./main/mapresult.js";
-import "./main/preferences.js";
-import "./main/results.js";
-import "./main/search.js";