diff options
Diffstat (limited to 'client/simple/src')
20 files changed, 1309 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}: ${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}: ${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"; diff --git a/client/simple/src/less/code.less b/client/simple/src/less/code.less index dd0998b23..d83bb1f6f 100644 --- a/client/simple/src/less/code.less +++ b/client/simple/src/less/code.less @@ -24,6 +24,7 @@ &::-moz-selection { background: transparent; /* Gecko Browsers */ } + margin-right: 8px; text-align: right; } |