mirror of
https://github.com/searxng/searxng.git
synced 2025-12-23 04:00:02 +00:00
[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
This commit is contained in:
committed by
Markus Heiser
parent
adc4361eb9
commit
60bd8b90f0
@@ -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";
|
||||
5
client/simple/src/js/core/listener.ts
Normal file
5
client/simple/src/js/core/listener.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { listen } from "./toolkit.ts";
|
||||
|
||||
listen("click", ".close", function (this: HTMLElement) {
|
||||
(this.parentNode as HTMLElement)?.classList.add("invisible");
|
||||
});
|
||||
38
client/simple/src/js/core/router.ts
Normal file
38
client/simple/src/js/core/router.ts
Normal file
@@ -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] }
|
||||
);
|
||||
140
client/simple/src/js/core/toolkit.ts
Normal file
140
client/simple/src/js/core/toolkit.ts
Normal file
@@ -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();
|
||||
@@ -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");
|
||||
});
|
||||
129
client/simple/src/js/main/autocomplete.ts
Normal file
129
client/simple/src/js/main/autocomplete.ts
Normal file
@@ -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 ?? "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
if (resultElement && isImageResult(resultElement)) {
|
||||
event.preventDefault();
|
||||
mutable.selectImage?.(resultElement);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// FIXME: Focus might also trigger Pointer event ^^^
|
||||
listen(
|
||||
"focus",
|
||||
".result a",
|
||||
(event: FocusEvent) => {
|
||||
if (!isElementInDetail(event.target as HTMLElement)) {
|
||||
const resultElement = getResultElement(event.target as HTMLElement);
|
||||
|
||||
if (resultElement && !resultElement.hasAttribute("data-vim-selected")) {
|
||||
highlightResult(resultElement)(true);
|
||||
}
|
||||
|
||||
if (resultElement && isImageResult(resultElement)) {
|
||||
event.preventDefault();
|
||||
searxng.selectImage?.(resultElement);
|
||||
mutable.selectImage?.(resultElement);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
{ capture: true }
|
||||
);
|
||||
|
||||
searxng.listen(
|
||||
"focus",
|
||||
".result a",
|
||||
(event: Event) => {
|
||||
if (!isElementInDetail(event.target as HTMLElement)) {
|
||||
const resultElement = getResultElement(event.target as HTMLElement);
|
||||
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 (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);
|
||||
}
|
||||
}
|
||||
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");
|
||||
}
|
||||
});
|
||||
|
||||
mutable.selectNext = highlightResult("down");
|
||||
mutable.selectPrevious = highlightResult("up");
|
||||
|
||||
@@ -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]))
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
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"
|
||||
})
|
||||
});
|
||||
|
||||
try {
|
||||
const markerSource = new VectorSource({
|
||||
features: [
|
||||
new Feature({
|
||||
geometry: new Point(fromLonLat([lon, lat]))
|
||||
})
|
||||
]
|
||||
});
|
||||
const geoLayer = new VectorLayer({
|
||||
source: geoSource,
|
||||
style: new Style({
|
||||
stroke: new Stroke({ color: "#3050ff", width: 2 }),
|
||||
fill: new Fill({ color: "#3050ff33" })
|
||||
})
|
||||
});
|
||||
|
||||
const markerLayer = new VectorLayer({
|
||||
source: markerSource,
|
||||
style: new Style({
|
||||
image: new Circle({
|
||||
radius: 6,
|
||||
fill: new Fill({ color: "#3050ff" })
|
||||
})
|
||||
})
|
||||
});
|
||||
map.addLayer(geoLayer);
|
||||
|
||||
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"] }
|
||||
);
|
||||
view.fit(geoSource.getExtent(), { padding: [20, 20, 20, 20] });
|
||||
} catch (error) {
|
||||
console.error("Failed to create GeoJSON layer:", error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 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"] }
|
||||
const engineToggles: NodeListOf<HTMLInputElement> = document.querySelectorAll<HTMLInputElement>(
|
||||
"tbody input[type=checkbox][class~=checkbox-onoff]"
|
||||
);
|
||||
|
||||
const enableAllEngines: NodeListOf<HTMLElement> = document.querySelectorAll<HTMLElement>(".enable-all-engines");
|
||||
for (const engine of enableAllEngines) {
|
||||
listen("click", engine, () => toggleEngines(true, engineToggles));
|
||||
}
|
||||
|
||||
const disableAllEngines: NodeListOf<HTMLElement> = document.querySelectorAll<HTMLElement>(".disable-all-engines");
|
||||
for (const engine of disableAllEngines) {
|
||||
listen("click", engine, () => toggleEngines(false, engineToggles));
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(hash);
|
||||
copyHashButton.innerText = copiedText;
|
||||
} catch (error) {
|
||||
console.error("Failed to copy hash:", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
imgLoader.src = imgSrc;
|
||||
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;
|
||||
};
|
||||
|
||||
searxng.ready(
|
||||
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?.();
|
||||
|
||||
// if there is no element given by the caller, stop here
|
||||
if (!resultElement) return;
|
||||
|
||||
imageLoader(resultElement);
|
||||
};
|
||||
|
||||
mutable.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();
|
||||
}
|
||||
|
||||
mutable.scrollPageToSelected?.();
|
||||
};
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
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 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`;
|
||||
}
|
||||
const backToTopElement = document.getElementById("backToTop");
|
||||
const resultsElement = document.getElementById("results");
|
||||
|
||||
thumbnail.onerror = () => {
|
||||
thumbnail.src = `${searxng.settings.theme_static_path}/img/img_load_error.svg`;
|
||||
};
|
||||
if (backToTopElement && resultsElement) {
|
||||
const scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
|
||||
const isScrolling = scrollTop >= 100;
|
||||
resultsElement.classList.toggle("scrolling", isScrolling);
|
||||
}
|
||||
|
||||
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"] }
|
||||
true
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
const qInput = document.getElementById("q") as HTMLInputElement | null;
|
||||
assertElement(qInput);
|
||||
|
||||
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 isMobile: boolean = window.matchMedia("(max-width: 50em)").matches;
|
||||
const isResultsPage: boolean = document.querySelector("main")?.id === "main_results";
|
||||
|
||||
const results = await res.json();
|
||||
// focus search input on large screens
|
||||
if (!(isMobile || isResultsPage)) {
|
||||
qInput.focus();
|
||||
}
|
||||
|
||||
const autocomplete = document.querySelector<HTMLElement>(".autocomplete");
|
||||
assertElement(autocomplete);
|
||||
createClearButton(qInput);
|
||||
|
||||
const autocompleteList = document.querySelector<HTMLUListElement>(".autocomplete ul");
|
||||
assertElement(autocompleteList);
|
||||
// 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));
|
||||
}
|
||||
|
||||
autocomplete.classList.add("open");
|
||||
autocompleteList.replaceChildren();
|
||||
const timeRangeElement = document.getElementById("time_range");
|
||||
if (timeRangeElement) {
|
||||
listen("change", timeRangeElement, () => submitIfQuery(qInput));
|
||||
}
|
||||
|
||||
// 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);
|
||||
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 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);
|
||||
// deselect all other categories
|
||||
for (const categoryButton of categoryButtons) {
|
||||
categoryButton.classList.toggle("selected", categoryButton === button);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
autocompleteList.append(fragment);
|
||||
} catch (error) {
|
||||
console.error("Error fetching autocomplete results:", error);
|
||||
const form: HTMLFormElement | null = document.querySelector<HTMLFormElement>("#search");
|
||||
assertElement(form);
|
||||
|
||||
// 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_", ""));
|
||||
|
||||
categoryValuesInput.value = categoryValues.join(",");
|
||||
}
|
||||
};
|
||||
|
||||
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"] }
|
||||
);
|
||||
form.submit();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user