diff options
| author | Ivan Gabaldon <igabaldon@inetol.net> | 2025-07-06 12:27:28 +0200 |
|---|---|---|
| committer | Markus Heiser <markus.heiser@darmarIT.de> | 2025-08-18 16:38:32 +0200 |
| commit | 60bd8b90f04d5d825fc8ac279cb7fdfde9fe78ea (patch) | |
| tree | 19b2639638e7845597f9aa839eda39a456188a1c /client/simple/src/js | |
| parent | adc4361eb919604889dc0661e75ef6ac8cfc4d23 (diff) | |
[enh] theme/simple: custom router
Lay the foundation for loading scripts granularly depending on the endpoint it's
on.
Remove vendor specific prefixes as there are now managed by browserslist and
LightningCSS.
Enabled quite a few rules in Biome that don't come in recommended to better
catch issues and improve consistency.
Related:
- https://github.com/searxng/searxng/pull/5073#discussion_r2256037965
- https://github.com/searxng/searxng/pull/5073#discussion_r2256057100
Diffstat (limited to 'client/simple/src/js')
| -rw-r--r-- | client/simple/src/js/core/index.ts (renamed from client/simple/src/js/main/index.ts) | 10 | ||||
| -rw-r--r-- | client/simple/src/js/core/listener.ts | 5 | ||||
| -rw-r--r-- | client/simple/src/js/core/router.ts | 38 | ||||
| -rw-r--r-- | client/simple/src/js/core/toolkit.ts | 140 | ||||
| -rw-r--r-- | client/simple/src/js/main/00_toolkit.ts | 118 | ||||
| -rw-r--r-- | client/simple/src/js/main/autocomplete.ts | 129 | ||||
| -rw-r--r-- | client/simple/src/js/main/infinite_scroll.ts | 72 | ||||
| -rw-r--r-- | client/simple/src/js/main/keyboard.ts | 133 | ||||
| -rw-r--r-- | client/simple/src/js/main/mapresult.ts | 151 | ||||
| -rw-r--r-- | client/simple/src/js/main/preferences.ts | 71 | ||||
| -rw-r--r-- | client/simple/src/js/main/results.ts | 324 | ||||
| -rw-r--r-- | client/simple/src/js/main/search.ts | 263 |
12 files changed, 745 insertions, 709 deletions
diff --git a/client/simple/src/js/main/index.ts b/client/simple/src/js/core/index.ts index 4dc86b63b..a4021beb9 100644 --- a/client/simple/src/js/main/index.ts +++ b/client/simple/src/js/core/index.ts @@ -4,10 +4,6 @@ * @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"; +import "./router.ts"; +import "./toolkit.ts"; +import "./listener.ts"; diff --git a/client/simple/src/js/core/listener.ts b/client/simple/src/js/core/listener.ts new file mode 100644 index 000000000..fb41cfa88 --- /dev/null +++ b/client/simple/src/js/core/listener.ts @@ -0,0 +1,5 @@ +import { listen } from "./toolkit.ts"; + +listen("click", ".close", function (this: HTMLElement) { + (this.parentNode as HTMLElement)?.classList.add("invisible"); +}); diff --git a/client/simple/src/js/core/router.ts b/client/simple/src/js/core/router.ts new file mode 100644 index 000000000..05c49ed07 --- /dev/null +++ b/client/simple/src/js/core/router.ts @@ -0,0 +1,38 @@ +import { Endpoints, endpoint, ready, settings } from "./toolkit.ts"; + +ready( + () => { + import("../main/keyboard.ts"); + import("../main/search.ts"); + + if (settings.autocomplete) { + import("../main/autocomplete.ts"); + } + }, + { on: [endpoint === Endpoints.index] } +); + +ready( + () => { + import("../main/keyboard.ts"); + import("../main/mapresult.ts"); + import("../main/results.ts"); + import("../main/search.ts"); + + if (settings.infinite_scroll) { + import("../main/infinite_scroll.ts"); + } + + if (settings.autocomplete) { + import("../main/autocomplete.ts"); + } + }, + { on: [endpoint === Endpoints.results] } +); + +ready( + () => { + import("../main/preferences.ts"); + }, + { on: [endpoint === Endpoints.preferences] } +); diff --git a/client/simple/src/js/core/toolkit.ts b/client/simple/src/js/core/toolkit.ts new file mode 100644 index 000000000..0e95eed14 --- /dev/null +++ b/client/simple/src/js/core/toolkit.ts @@ -0,0 +1,140 @@ +import type { KeyBindingLayout } from "../main/keyboard.ts"; + +// synced with searx/webapp.py get_client_settings +type Settings = { + advanced_search?: boolean; + autocomplete?: string; + autocomplete_min?: number; + doi_resolver?: string; + favicon_resolver?: string; + hotkeys?: KeyBindingLayout; + infinite_scroll?: boolean; + method?: "GET" | "POST"; + query_in_title?: boolean; + results_on_new_tab?: boolean; + safesearch?: 0 | 1 | 2; + search_on_category_select?: boolean; + theme?: string; + theme_static_path?: string; + translations?: Record<string, string>; + url_formatting?: "pretty" | "full" | "host"; +}; + +type HTTPOptions = { + body?: BodyInit; + timeout?: number; +}; + +type ReadyOptions = { + // all values must be truthy for the callback to be executed + on?: (boolean | undefined)[]; +}; + +type AssertElement = (element?: HTMLElement | null) => asserts element is HTMLElement; + +export type EndpointsKeys = keyof typeof Endpoints; + +export const Endpoints = { + index: "index", + results: "results", + preferences: "preferences", + unknown: "unknown" +} as const; + +export const mutable = { + closeDetail: undefined as (() => void) | undefined, + scrollPageToSelected: undefined as (() => void) | undefined, + selectImage: undefined as ((resultElement: HTMLElement) => void) | undefined, + selectNext: undefined as ((openDetailView?: boolean) => void) | undefined, + selectPrevious: undefined as ((openDetailView?: boolean) => void) | undefined +}; + +const getEndpoint = (): EndpointsKeys => { + const metaEndpoint = document.querySelector('meta[name="endpoint"]')?.getAttribute("content"); + + if (metaEndpoint && metaEndpoint in Endpoints) { + return metaEndpoint as EndpointsKeys; + } + + return Endpoints.unknown; +}; + +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 {}; + } +}; + +export const assertElement: AssertElement = (element?: HTMLElement | null): asserts element is HTMLElement => { + if (!element) { + throw new Error("Bad assertion: DOM element not found"); + } +}; + +export const http = async (method: string, url: string | URL, options?: HTTPOptions): Promise<Response> => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), options?.timeout ?? 30_000); + + const res = await fetch(url, { + body: options?.body, + method: method, + signal: controller.signal + }).finally(() => clearTimeout(timeoutId)); + if (!res.ok) { + throw new Error(res.statusText); + } + + return res; +}; + +export const listen = <K extends keyof DocumentEventMap, E extends HTMLElement>( + type: string | K, + target: string | Document | E, + listener: (this: E, event: DocumentEventMap[K]) => void | Promise<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 HTMLElement && node.matches(target)) { + try { + listener.call(node as E, event as DocumentEventMap[K]); + } catch (error) { + console.error(error); + } + break; + } + } + }, + options + ); +}; + +export const ready = (callback: () => void, options?: ReadyOptions): void => { + for (const condition of options?.on ?? []) { + if (!condition) { + return; + } + } + + if (document.readyState !== "loading") { + callback(); + } else { + listen("DOMContentLoaded", document, callback, { once: true }); + } +}; + +export const endpoint: EndpointsKeys = getEndpoint(); +export const settings: Settings = getSettings(); diff --git a/client/simple/src/js/main/00_toolkit.ts b/client/simple/src/js/main/00_toolkit.ts deleted file mode 100644 index 05cfc4b6b..000000000 --- a/client/simple/src/js/main/00_toolkit.ts +++ /dev/null @@ -1,118 +0,0 @@ -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/autocomplete.ts b/client/simple/src/js/main/autocomplete.ts new file mode 100644 index 000000000..c7ed2056b --- /dev/null +++ b/client/simple/src/js/main/autocomplete.ts @@ -0,0 +1,129 @@ +import { assertElement, http, listen, settings } from "../core/toolkit.ts"; + +const fetchResults = async (qInput: HTMLInputElement, query: string): Promise<void> => { + try { + let res: Response; + + if (settings.method === "GET") { + res = await http("GET", `./autocompleter?q=${query}`); + } else { + res = await http("POST", "./autocompleter", { body: 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 === 0) { + const noItemFoundMessage = Object.assign(document.createElement("li"), { + className: "no-item-found", + textContent: 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 }); + + 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); + } +}; + +const qInput = document.getElementById("q") as HTMLInputElement | null; +assertElement(qInput); + +let timeoutId: number; + +listen("input", qInput, () => { + clearTimeout(timeoutId); + + const query = qInput.value; + const minLength = settings.autocomplete_min ?? 2; + + if (query.length < minLength) return; + + timeoutId = window.setTimeout(async () => { + if (query === qInput.value) { + await fetchResults(qInput, query); + } + }, 300); +}); + +const autocomplete: HTMLElement | null = document.querySelector<HTMLElement>(".autocomplete"); +const autocompleteList: HTMLUListElement | null = document.querySelector<HTMLUListElement>(".autocomplete ul"); +if (autocompleteList) { + 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; + default: + 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 ?? ""; + } + } + } + } + }); +} diff --git a/client/simple/src/js/main/infinite_scroll.ts b/client/simple/src/js/main/infinite_scroll.ts index e9f931e51..5c3350266 100644 --- a/client/simple/src/js/main/infinite_scroll.ts +++ b/client/simple/src/js/main/infinite_scroll.ts @@ -1,4 +1,4 @@ -import { assertElement, searxng } from "./00_toolkit"; +import { assertElement, http, settings } from "../core/toolkit.ts"; const newLoadSpinner = (): HTMLDivElement => { return Object.assign(document.createElement("div"), { @@ -13,12 +13,9 @@ const loadNextPage = async (onlyImages: boolean, callback: () => void): Promise< 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; + throw new Error("Form action not defined"); } const paginationElement = document.querySelector<HTMLElement>("#pagination"); @@ -27,7 +24,7 @@ const loadNextPage = async (onlyImages: boolean, callback: () => void): Promise< paginationElement.replaceChildren(newLoadSpinner()); try { - const res = await searxng.http("POST", action, formData); + const res = await http("POST", action, { body: new FormData(form) }); const nextPage = await res.text(); if (!nextPage) return; @@ -39,8 +36,7 @@ const loadNextPage = async (onlyImages: boolean, callback: () => void): Promise< const urlsElement = document.querySelector<HTMLElement>("#urls"); if (!urlsElement) { - console.error("URLs element not found"); - return; + throw new Error("URLs element not found"); } if (articleList.length > 0 && !onlyImages) { @@ -59,7 +55,7 @@ const loadNextPage = async (onlyImages: boolean, callback: () => void): Promise< 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", + textContent: settings.translations?.error_loading_next_page ?? "Error loading next page", className: "dialog-error" }); errorElement.setAttribute("role", "alert"); @@ -67,42 +63,36 @@ const loadNextPage = async (onlyImages: boolean, callback: () => void): Promise< } }; -searxng.ready( - () => { - const resultsElement = document.getElementById("results"); - if (!resultsElement) { - console.error("Results element not found"); - return; - } +const resultsElement: HTMLElement | null = document.getElementById("results"); +if (!resultsElement) { + throw new Error("Results element not found"); +} - const onlyImages = resultsElement.classList.contains("only_template_images"); - const observedSelector = "article.result:last-child"; +const onlyImages: boolean = resultsElement.classList.contains("only_template_images"); +const observedSelector = "article.result:last-child"; - const intersectionObserveOptions: IntersectionObserverInit = { - rootMargin: "320px" - }; +const intersectionObserveOptions: IntersectionObserverInit = { + rootMargin: "320px" +}; - const observer = new IntersectionObserver(async (entries: IntersectionObserverEntry[]) => { - const [paginationEntry] = entries; +const observer: IntersectionObserver = new IntersectionObserver((entries: IntersectionObserverEntry[]) => { + const [paginationEntry] = entries; - if (paginationEntry?.isIntersecting) { - observer.unobserve(paginationEntry.target); + if (paginationEntry?.isIntersecting) { + observer.unobserve(paginationEntry.target); - await loadNextPage(onlyImages, () => { - const nextObservedElement = document.querySelector<HTMLElement>(observedSelector); - if (nextObservedElement) { - observer.observe(nextObservedElement); - } - }); + 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] + }).then(() => { + // wait until promise is resolved + }); } -); +}, intersectionObserveOptions); + +const initialObservedElement: HTMLElement | null = document.querySelector<HTMLElement>(observedSelector); +if (initialObservedElement) { + observer.observe(initialObservedElement); +} diff --git a/client/simple/src/js/main/keyboard.ts b/client/simple/src/js/main/keyboard.ts index 3c6417bbc..46b9bcc20 100644 --- a/client/simple/src/js/main/keyboard.ts +++ b/client/simple/src/js/main/keyboard.ts @@ -1,4 +1,4 @@ -import { assertElement, searxng } from "./00_toolkit.ts"; +import { assertElement, listen, mutable, settings } from "../core/toolkit.ts"; export type KeyBindingLayout = "default" | "vim"; @@ -9,11 +9,13 @@ type KeyBinding = { cat: string; }; +type HighlightResultElement = "down" | "up" | "visible" | "bottom" | "top"; + /* common base for layouts */ const baseKeyBinding: Record<string, KeyBinding> = { Escape: { key: "ESC", - fun: (event) => removeFocus(event), + fun: (event: KeyboardEvent) => removeFocus(event), des: "remove focus from the focused input", cat: "Control" }, @@ -145,12 +147,12 @@ const keyBindingLayouts: Record<KeyBindingLayout, Record<string, KeyBinding>> = } }; -const keyBindings = - searxng.settings.hotkeys && searxng.settings.hotkeys in keyBindingLayouts - ? keyBindingLayouts[searxng.settings.hotkeys] +const keyBindings: Record<string, KeyBinding> = + settings.hotkeys && settings.hotkeys in keyBindingLayouts + ? keyBindingLayouts[settings.hotkeys] : keyBindingLayouts.default; -const isElementInDetail = (element?: Element): boolean => { +const isElementInDetail = (element?: HTMLElement): boolean => { const ancestor = element?.closest(".detail, .result"); return ancestor?.classList.contains("detail") ?? false; }; @@ -159,12 +161,12 @@ const getResultElement = (element?: HTMLElement): HTMLElement | undefined => { return element?.closest(".result") ?? undefined; }; -const isImageResult = (resultElement?: Element): boolean => { +const isImageResult = (resultElement?: HTMLElement): boolean => { return resultElement?.classList.contains("result-images") ?? false; }; const highlightResult = - (which: string | HTMLElement) => + (which: HighlightResultElement | HTMLElement) => (noScroll?: boolean, keepFocus?: boolean): void => { let effectiveWhich = which; let current = document.querySelector<HTMLElement>(".result[data-vim-selected]"); @@ -210,7 +212,7 @@ const highlightResult = next = results[results.indexOf(current) - 1] || current; break; case "bottom": - next = results[results.length - 1]; + next = results.at(-1); break; // biome-ignore lint/complexity/noUselessSwitchCase: fallthrough is intended case "top": @@ -229,7 +231,7 @@ const highlightResult = } if (!noScroll) { - scrollPageToSelected(); + mutable.scrollPageToSelected?.(); } } }; @@ -245,7 +247,7 @@ const removeFocus = (event: KeyboardEvent): void => { if (document.activeElement && (tagName === "input" || tagName === "select" || tagName === "textarea")) { (document.activeElement as HTMLElement).blur(); } else { - searxng.closeDetail?.(); + mutable.closeDetail?.(); } }; @@ -256,23 +258,23 @@ const pageButtonClick = (css_selector: string): void => { } }; -const GoToNextPage = () => { +const GoToNextPage = (): void => { pageButtonClick('nav#pagination .next_page button[type="submit"]'); }; -const GoToPreviousPage = () => { +const GoToPreviousPage = (): void => { pageButtonClick('nav#pagination .previous_page button[type="submit"]'); }; -const scrollPageToSelected = (): void => { +mutable.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; + 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) { @@ -297,7 +299,7 @@ const scrollPage = (amount: number): void => { highlightResult("visible")(); }; -const scrollPageTo = (position: number, nav: string): void => { +const scrollPageTo = (position: number, nav: HighlightResultElement): void => { window.scrollTo(0, position); highlightResult(nav)(); }; @@ -385,7 +387,10 @@ const initHelpContent = (divElement: HTMLElement, keyBindings: typeof baseKeyBin const toggleHelp = (keyBindings: typeof baseKeyBinding): void => { let helpPanel = document.querySelector<HTMLElement>("#vim-hotkeys-help"); - if (!helpPanel) { + if (helpPanel) { + // toggle hidden + helpPanel.classList.toggle("invisible"); + } else { // first call helpPanel = Object.assign(document.createElement("div"), { id: "vim-hotkeys-help", @@ -396,9 +401,6 @@ const toggleHelp = (keyBindings: typeof baseKeyBinding): void => { if (body) { body.appendChild(helpPanel); } - } else { - // toggle hidden - helpPanel.classList.toggle("invisible"); } }; @@ -412,56 +414,53 @@ const copyURLToClipboard = async (): Promise<void> => { } }; -searxng.ready(() => { - searxng.listen("click", ".result", function (this: HTMLElement, event: Event) { - if (!isElementInDetail(event.target as HTMLElement)) { - highlightResult(this)(true, true); +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); + const resultElement = getResultElement(event.target as HTMLElement); - if (resultElement && isImageResult(resultElement)) { - event.preventDefault(); - searxng.selectImage?.(resultElement); - } + if (resultElement && isImageResult(resultElement)) { + event.preventDefault(); + mutable.selectImage?.(resultElement); } - }); - - searxng.listen( - "focus", - ".result a", - (event: Event) => { - if (!isElementInDetail(event.target as HTMLElement)) { - const resultElement = getResultElement(event.target as HTMLElement); + } +}); - if (resultElement && !resultElement.getAttribute("data-vim-selected")) { - highlightResult(resultElement)(true); - } +// 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 && isImageResult(resultElement)) { - searxng.selectImage?.(resultElement); - } + if (resultElement && !resultElement.hasAttribute("data-vim-selected")) { + highlightResult(resultElement)(true); } - }, - { 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); - } + if (resultElement && isImageResult(resultElement)) { + event.preventDefault(); + mutable.selectImage?.(resultElement); } } - }); - - searxng.scrollPageToSelected = scrollPageToSelected; - searxng.selectNext = highlightResult("down"); - searxng.selectPrevious = highlightResult("up"); + }, + { 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"); diff --git a/client/simple/src/js/main/mapresult.ts b/client/simple/src/js/main/mapresult.ts index 421b41f77..378e1e54f 100644 --- a/client/simple/src/js/main/mapresult.ts +++ b/client/simple/src/js/main/mapresult.ts @@ -1,89 +1,84 @@ -import { searxng } from "./00_toolkit.ts"; +import { listen } from "../core/toolkit.ts"; -searxng.ready( - () => { - searxng.listen("click", ".searxng_init_map", async function (this: HTMLElement, event: Event) { - event.preventDefault(); - this.classList.remove("searxng_init_map"); +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 { + 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 { 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 - }); + const lon = Number.parseFloat(mapLon || "0"); + const lat = Number.parseFloat(mapLat || "0"); + const view = new View({ maxZoom: 16, enableRotation: false }); + const map = new OlMap({ + target: target, + layers: [new TileLayer({ source: new OSM({ maxZoom: 16 }) })], + view: view + }); - try { - const markerSource = new VectorSource({ - features: [ - new Feature({ - geometry: new Point(fromLonLat([lon, lat])) - }) - ] - }); + 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" }) - }) - }) - }); + 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); - } + 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" - }) - }); + 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" }) - }) - }); + const geoLayer = new VectorLayer({ + source: geoSource, + style: new Style({ + stroke: new Stroke({ color: "#3050ff", width: 2 }), + fill: new Fill({ color: "#3050ff33" }) + }) + }); - map.addLayer(geoLayer); + 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"] } -); + view.fit(geoSource.getExtent(), { padding: [20, 20, 20, 20] }); + } catch (error) { + console.error("Failed to create GeoJSON layer:", error); + } + } +}); diff --git a/client/simple/src/js/main/preferences.ts b/client/simple/src/js/main/preferences.ts index 6c66018a6..fb81e6558 100644 --- a/client/simple/src/js/main/preferences.ts +++ b/client/simple/src/js/main/preferences.ts @@ -1,9 +1,11 @@ -import { searxng } from "./00_toolkit.ts"; +import { http, listen, settings } from "../core/toolkit.ts"; + +let engineDescriptions: Record<string, [string, string]> | undefined; const loadEngineDescriptions = async (): Promise<void> => { - let engineDescriptions: Record<string, [string, string]> | null = null; + if (engineDescriptions) return; try { - const res = await searxng.http("GET", "engine_descriptions.json"); + const res = await http("GET", "engine_descriptions.json"); engineDescriptions = await res.json(); } catch (error) { console.error("Error fetching engineDescriptions:", error); @@ -12,7 +14,7 @@ const loadEngineDescriptions = async (): Promise<void> => { 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}: ${source}</i>)`; + const sourceText = ` (<i>${settings.translations?.Source}: ${source}</i>)`; for (const element of elements) { element.innerHTML = description + sourceText; @@ -29,43 +31,38 @@ const toggleEngines = (enable: boolean, engineToggles: NodeListOf<HTMLInputEleme } }; -searxng.ready( - () => { - const engineElements = document.querySelectorAll<HTMLElement>("[data-engine-name]"); - for (const engineElement of engineElements) { - searxng.listen("mouseenter", engineElement, loadEngineDescriptions); - } +const engineElements: NodeListOf<HTMLElement> = document.querySelectorAll<HTMLElement>("[data-engine-name]"); +for (const engineElement of engineElements) { + listen("mouseenter", engineElement, loadEngineDescriptions); +} - const engineToggles = document.querySelectorAll<HTMLInputElement>( - "tbody input[type=checkbox][class~=checkbox-onoff]" - ); +const engineToggles: NodeListOf<HTMLInputElement> = 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 enableAllEngines: NodeListOf<HTMLElement> = document.querySelectorAll<HTMLElement>(".enable-all-engines"); +for (const engine of enableAllEngines) { + 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 disableAllEngines: NodeListOf<HTMLElement> = document.querySelectorAll<HTMLElement>(".disable-all-engines"); +for (const engine of disableAllEngines) { + 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 copyHashButton: HTMLElement | null = document.querySelector<HTMLElement>("#copy-hash"); +if (copyHashButton) { + listen("click", copyHashButton, async (event: Event) => { + event.preventDefault(); - const { copiedText, hash } = copyHashButton.dataset; - if (!copiedText || !hash) return; + 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); - } - }); + 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.ts b/client/simple/src/js/main/results.ts index e278c894a..494f38cbc 100644 --- a/client/simple/src/js/main/results.ts +++ b/client/simple/src/js/main/results.ts @@ -1,181 +1,175 @@ import "../../../node_modules/swiped-events/src/swiped-events.js"; -import { assertElement, searxng } from "./00_toolkit.ts"; +import { assertElement, listen, mutable, settings } from "../core/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(); +let imgTimeoutID: number; - // set handlers in the on-properties - imgLoader.onload = () => { - onSuccess(); +const imageLoader = (resultElement: HTMLElement): void => { + if (imgTimeoutID) clearTimeout(imgTimeoutID); + + const imgElement = resultElement.querySelector<HTMLImageElement>(".result-images-source img"); + if (!imgElement) return; + + // use thumbnail until full image loads + const thumbnail = resultElement.querySelector<HTMLImageElement>(".image_thumbnail"); + if (thumbnail) { + if (thumbnail.src === `${settings.theme_static_path}/img/img_load_error.svg`) return; + + imgElement.onerror = (): void => { + imgElement.src = thumbnail.src; + }; + + imgElement.src = thumbnail.src; + } + + const imgSource = imgElement.getAttribute("data-src"); + if (!imgSource) return; + + // unsafe nodejs specific, cast to https://developer.mozilla.org/en-US/docs/Web/API/Window/setTimeout#return_value + // https://github.com/searxng/searxng/pull/5073#discussion_r2265767231 + imgTimeoutID = setTimeout(() => { + imgElement.src = imgSource; + imgElement.removeAttribute("data-src"); + }, 1000) as unknown as number; +}; + +const imageThumbnails: NodeListOf<HTMLImageElement> = + document.querySelectorAll<HTMLImageElement>("#urls img.image_thumbnail"); +for (const thumbnail of imageThumbnails) { + if (thumbnail.complete && thumbnail.naturalWidth === 0) { + thumbnail.src = `${settings.theme_static_path}/img/img_load_error.svg`; + } + + thumbnail.onerror = (): void => { + thumbnail.src = `${settings.theme_static_path}/img/img_load_error.svg`; }; +} + +const copyUrlButton: HTMLButtonElement | null = + document.querySelector<HTMLButtonElement>("#search_url button#copy_url"); +copyUrlButton?.style.setProperty("display", "block"); + +mutable.selectImage = (resultElement: HTMLElement): 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"; + + mutable.scrollPageToSelected?.(); - imgLoader.src = imgSrc; + // if there is no element given by the caller, stop here + if (!resultElement) return; + + imageLoader(resultElement); }; -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`; - }; - } +mutable.closeDetail = (): void => { + const resultsElement = document.getElementById("results"); + resultsElement?.classList.remove("image-detail-open"); - 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"); - }); - }; + // remove #image-viewer hash from url by navigating back + if (window.location.hash === "#image-viewer") { + window.history.back(); + } - searxng.closeDetail = (): void => { - const resultsElement = document.getElementById("results"); - resultsElement?.classList.remove("image-detail-open"); + mutable.scrollPageToSelected?.(); +}; - // remove #image-viewer hash from url by navigating back - if (window.location.hash === "#image-viewer") { - window.history.back(); - } +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"); - searxng.scrollPageToSelected?.(); - }; + if (!(target && btnLabelCollapsed && btnLabelNotCollapsed)) return; + + const targetElement = document.querySelector<HTMLElement>(target); + assertElement(targetElement); - 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); - }); + 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"); +}); + +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); } + } +}); + +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; + } +}); + +listen("click", ".result-detail-close", (event: Event) => { + event.preventDefault(); + mutable.closeDetail?.(); +}); + +listen("click", ".result-detail-previous", (event: Event) => { + event.preventDefault(); + mutable.selectPrevious?.(false); +}); + +listen("click", ".result-detail-next", (event: Event) => { + event.preventDefault(); + mutable.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") { + mutable.closeDetail?.(); + } +}); + +const swipeHorizontal: NodeListOf<HTMLElement> = document.querySelectorAll<HTMLElement>(".swipe-horizontal"); +for (const element of swipeHorizontal) { + listen("swiped-left", element, () => { + mutable.selectNext?.(false); + }); + + listen("swiped-right", element, () => { + mutable.selectPrevious?.(false); + }); +} + +window.addEventListener( + "scroll", + () => { + const backToTopElement = document.getElementById("backToTop"); + const resultsElement = document.getElementById("results"); - 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 - ); + if (backToTopElement && resultsElement) { + const scrollTop = document.documentElement.scrollTop || document.body.scrollTop; + const isScrolling = scrollTop >= 100; + resultsElement.classList.toggle("scrolling", isScrolling); + } }, - { on: [searxng.endpoint === "results"] } + true ); diff --git a/client/simple/src/js/main/search.ts b/client/simple/src/js/main/search.ts index 5e68965b1..508dc702a 100644 --- a/client/simple/src/js/main/search.ts +++ b/client/simple/src/js/main/search.ts @@ -1,4 +1,4 @@ -import { assertElement, searxng } from "./00_toolkit.ts"; +import { assertElement, listen, settings } from "../core/toolkit.ts"; const submitIfQuery = (qInput: HTMLInputElement): void => { if (qInput.value.length > 0) { @@ -17,217 +17,88 @@ const createClearButton = (qInput: HTMLInputElement): void => { updateClearButton(qInput, cs); - searxng.listen("click", cs, (event: MouseEvent) => { + listen("click", cs, (event: MouseEvent) => { event.preventDefault(); qInput.value = ""; qInput.focus(); updateClearButton(qInput, cs); }); - searxng.listen("input", qInput, () => updateClearButton(qInput, cs), { passive: true }); + 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); +const qInput = document.getElementById("q") as HTMLInputElement | null; +assertElement(qInput); + +const isMobile: boolean = window.matchMedia("(max-width: 50em)").matches; +const isResultsPage: boolean = document.querySelector("main")?.id === "main_results"; + +// focus search input on large screens +if (!(isMobile || isResultsPage)) { + qInput.focus(); +} + +createClearButton(qInput); + +// 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 ( + 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) { + listen("change", safesearchElement, () => submitIfQuery(qInput)); } -}; - -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 ?? ""; - } - } - } - } - }); - } - } + const timeRangeElement = document.getElementById("time_range"); + if (timeRangeElement) { + listen("change", timeRangeElement, () => submitIfQuery(qInput)); + } - // 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 languageElement = document.getElementById("language"); + if (languageElement) { + listen("change", languageElement, () => submitIfQuery(qInput)); + } +} + +const categoryButtons: HTMLButtonElement[] = [ + ...document.querySelectorAll<HTMLButtonElement>("button.category_button") +]; +for (const button of categoryButtons) { + listen("click", button, (event: MouseEvent) => { + if (event.shiftKey) { + event.preventDefault(); + button.classList.toggle("selected"); + return; } - 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); - } - }); + // deselect all other categories + for (const categoryButton of categoryButtons) { + categoryButton.classList.toggle("selected", categoryButton === button); } + }); +} - const form = document.querySelector<HTMLFormElement>("#search"); - assertElement(form); +const form: HTMLFormElement | null = document.querySelector<HTMLFormElement>("#search"); +assertElement(form); - // override form submit action to update the actually selected categories - searxng.listen("submit", form, (event: Event) => { - event.preventDefault(); +// override form submit action to update the actually selected categories +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_", "")); + 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(","); - } + categoryValuesInput.value = categoryValues.join(","); + } - form.submit(); - }); - }, - { on: [searxng.endpoint === "index" || searxng.endpoint === "results"] } -); + form.submit(); +}); |