diff options
| author | Markus Heiser <markus.heiser@darmarit.de> | 2025-01-23 11:10:40 +0100 |
|---|---|---|
| committer | Markus Heiser <markus.heiser@darmarIT.de> | 2025-02-28 12:27:41 +0100 |
| commit | a1132deaa4618f228e82252397247a150081a5f3 (patch) | |
| tree | 0445fbe04c8932acdfbe5362db40ea1782f38539 /client/simple/src/js | |
| parent | b6487b70aaa199aba6ae999a9c99b340b5e98884 (diff) | |
[web-client] simple theme: move sources to client/simple/src
Signed-off-by: Markus Heiser <markus.heiser@darmarit.de>
Diffstat (limited to 'client/simple/src/js')
| -rw-r--r-- | client/simple/src/js/head/00_init.js | 20 | ||||
| -rw-r--r-- | client/simple/src/js/main/00_toolkit.js | 165 | ||||
| -rw-r--r-- | client/simple/src/js/main/infinite_scroll.js | 88 | ||||
| -rw-r--r-- | client/simple/src/js/main/keyboard.js | 461 | ||||
| -rw-r--r-- | client/simple/src/js/main/mapresult.js | 74 | ||||
| -rw-r--r-- | client/simple/src/js/main/preferences.js | 53 | ||||
| -rw-r--r-- | client/simple/src/js/main/results.js | 181 | ||||
| -rw-r--r-- | client/simple/src/js/main/search.js | 210 |
8 files changed, 1252 insertions, 0 deletions
diff --git a/client/simple/src/js/head/00_init.js b/client/simple/src/js/head/00_init.js new file mode 100644 index 000000000..a7c61c43e --- /dev/null +++ b/client/simple/src/js/head/00_init.js @@ -0,0 +1,20 @@ +/* SPDX-License-Identifier: AGPL-3.0-or-later */ +(function (w, d) { + 'use strict'; + + // add data- properties + var script = d.currentScript || (function () { + var scripts = d.getElementsByTagName('script'); + return scripts[scripts.length - 1]; + })(); + + w.searxng = { + settings: JSON.parse(atob(script.getAttribute('client_settings'))) + }; + + // update the css + var 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 new file mode 100644 index 000000000..4e374a019 --- /dev/null +++ b/client/simple/src/js/main/00_toolkit.js @@ -0,0 +1,165 @@ +/** + * @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 = (function (w, d) { + + 'use strict'; + + // 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) { + (function (ElementPrototype) { + ElementPrototype.matches = ElementPrototype.matches || + ElementPrototype.matchesSelector || + ElementPrototype.webkitMatchesSelector || + ElementPrototype.msMatchesSelector || + function (selector) { + var node = this, nodes = (node.parentNode || node.document).querySelectorAll(selector), i = -1; + while (nodes[++i] && nodes[i] != node); + return !!nodes[i]; + }; + })(Element.prototype); + } + + function callbackSafe (callback, el, e) { + try { + callback.call(el, e); + } catch (exception) { + console.log(exception); + } + } + + var searxng = window.searxng || {}; + + searxng.on = function (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, function (e) { + var el = e.target || e.srcElement, found = false; + while (el && el.matches && el !== d && !(found = el.matches(obj))) el = el.parentElement; + if (found) callbackSafe(callback, el, e); + }, useCapture); + } + }; + + searxng.ready = function (callback) { + if (document.readyState != 'loading') { + callback.call(w); + } else { + w.addEventListener('DOMContentLoaded', callback.bind(w)); + } + }; + + searxng.http = function (method, url, data = null) { + return new Promise(function (resolve, reject) { + try { + var req = new XMLHttpRequest(); + req.open(method, url, true); + req.timeout = 20000; + + // On load + req.onload = function () { + if (req.status == 200) { + resolve(req.response, req.responseType); + } else { + reject(Error(req.statusText)); + } + }; + + // Handle network errors + req.onerror = function () { + reject(Error("Network Error")); + }; + + req.onabort = function () { + reject(Error("Transaction is aborted")); + }; + + req.ontimeout = function () { + reject(Error("Timeout")); + } + + // Make the request + if (data) { + req.send(data) + } else { + req.send(); + } + } catch (ex) { + reject(ex); + } + }); + }; + + searxng.loadStyle = function (src) { + var path = searxng.settings.theme_static_path + "/" + src, + id = "style_" + src.replace('.', '_'), + 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 = function (src, callback) { + var path = searxng.settings.theme_static_path + "/" + src, + id = "script_" + src.replace('.', '_'), + s = d.getElementById(id); + if (s === null) { + s = d.createElement('script'); + s.setAttribute('id', id); + s.setAttribute('src', path); + s.onload = callback; + s.onerror = function () { + 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 = function (newNode, referenceNode) { + referenceNode.parentNode.insertBefore(newNode, referenceNode); + }; + + searxng.insertAfter = function (newNode, referenceNode) { + referenceNode.parentNode.insertAfter(newNode, referenceNode.nextSibling); + }; + + searxng.on('.close', 'click', function () { + this.parentNode.classList.add('invisible'); + }); + + function getEndpoint () { + for (var 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/infinite_scroll.js b/client/simple/src/js/main/infinite_scroll.js new file mode 100644 index 000000000..07db3305a --- /dev/null +++ b/client/simple/src/js/main/infinite_scroll.js @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +/* global searxng */ + +searxng.ready(function () { + 'use strict'; + + 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; + } + + let d = document; + var onlyImages = d.getElementById('results').classList.contains('only_template_images'); + + function newLoadSpinner () { + var 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) { + var form = d.querySelector('#pagination form.next_page'); + if (!form) { + return + } + replaceChildrenWith(d.querySelector('#pagination'), [ newLoadSpinner() ]); + var formData = new FormData(form); + searxng.http('POST', d.querySelector('#search').getAttribute('action'), formData).then( + function (response) { + var nextPageDoc = new DOMParser().parseFromString(response, 'text/html'); + var articleList = nextPageDoc.querySelectorAll('#urls article'); + var 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( + function (err) { + console.log(err); + var 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/keyboard.js b/client/simple/src/js/main/keyboard.js new file mode 100644 index 000000000..e16134579 --- /dev/null +++ b/client/simple/src/js/main/keyboard.js @@ -0,0 +1,461 @@ +/* SPDX-License-Identifier: AGPL-3.0-or-later */ +/* global searxng */ + +searxng.ready(function () { + + 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 && resultElement.classList.contains('result-images'); + } + + searxng.on('.result', 'click', function (e) { + if (!isElementInDetail(e.target)) { + highlightResult(this)(true, true); + let resultElement = getResultElement(e.target); + if (isImageResult(resultElement)) { + e.preventDefault(); + searxng.selectImage(resultElement); + } + } + }); + + searxng.on('.result a', 'focus', function (e) { + if (!isElementInDetail(e.target)) { + let 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 */ + var 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' + }, + }; + var 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) + } + + var keyBindings = keyBindingLayouts[searxng.settings.hotkeys] || keyBindingLayouts.default; + + searxng.on(document, "keydown", function (e) { + // check for modifiers so we don't break browser's hotkeys + if ( + Object.prototype.hasOwnProperty.call(keyBindings, e.key) + && !e.ctrlKey && !e.altKey + && !e.shiftKey && !e.metaKey + ) { + var 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 function (noScroll, keepFocus) { + var 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; + } + } + + var 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': + var top = document.documentElement.scrollTop || document.body.scrollTop; + var bot = top + document.documentElement.clientHeight; + + for (var i = 0; i < results.length; i++) { + next = results[i]; + var etop = next.offsetTop; + var 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; + case 'top': + /* falls through */ + default: + next = results[0]; + } + } + + if (next) { + current.removeAttribute('data-vim-selected'); + next.setAttribute('data-vim-selected', 'true'); + if (!keepFocus) { + var 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 function () { + var 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 () { + var sel = document.querySelector('.result[data-vim-selected]'); + if (sel === null) { + return; + } + var 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 { + var wbot = wtop + wheight; + if (wbot < (ebot + offset)) { + window.scroll(window.scrollX, ebot - wheight + offset); + } + } + } + + function scrollPage (amount) { + return function () { + window.scrollBy(0, amount); + highlightResult('visible')(); + }; + } + + function scrollPageTo (position, nav) { + return function () { + window.scrollTo(0, position); + highlightResult(nav)(); + }; + } + + function searchInputFocus () { + window.scrollTo(0, 0); + var q = document.querySelector('#q'); + q.focus(); + if (q.setSelectionRange) { + var len = q.value.length; + q.setSelectionRange(len, len); + } + } + + function openResult (newTab) { + return function () { + var link = document.querySelector('.result[data-vim-selected] h3 a'); + if (link === null) { + link = document.querySelector('.result[data-vim-selected] > a'); + } + if (link !== null) { + var url = link.getAttribute('href'); + if (newTab) { + window.open(url); + } else { + window.location.href = url; + } + } + }; + } + + function initHelpContent (divElement) { + var categories = {}; + + for (var k in keyBindings) { + var key = keyBindings[k]; + categories[key.cat] = categories[key.cat] || []; + categories[key.cat].push(key); + } + + var sorted = Object.keys(categories).sort(function (a, b) { + return categories[b].length - categories[a].length; + }); + + if (sorted.length === 0) { + return; + } + + var html = '<a href="#" class="close" aria-label="close" title="close">×</a>'; + html += '<h3>How to navigate SearXNG with hotkeys</h3>'; + html += '<table>'; + + for (var i = 0; i < sorted.length; i++) { + var cat = categories[sorted[i]]; + + var lastCategory = i === (sorted.length - 1); + var first = i % 2 === 0; + + if (first) { + html += '<tr>'; + } + html += '<td>'; + + html += '<h4>' + cat[0].cat + '</h4>'; + html += '<ul class="list-unstyled">'; + + for (var 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 () { + var 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); + var body = document.getElementsByTagName('body')[0]; + body.appendChild(helpPanel); + } else { + // toggle hidden + helpPanel.classList.toggle('invisible'); + return; + } + } + + function copyURLToClipboard () { + var 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/mapresult.js b/client/simple/src/js/main/mapresult.js new file mode 100644 index 000000000..2c3777678 --- /dev/null +++ b/client/simple/src/js/main/mapresult.js @@ -0,0 +1,74 @@ +/* SPDX-License-Identifier: AGPL-3.0-or-later */ +/* global L */ +(function (w, d, searxng) { + 'use strict'; + + searxng.ready(function () { + searxng.on('.searxng_init_map', 'click', function (event) { + // no more request + this.classList.remove("searxng_init_map"); + + // + var leaflet_target = this.dataset.leafletTarget; + var map_lon = parseFloat(this.dataset.mapLon); + var map_lat = parseFloat(this.dataset.mapLat); + var map_zoom = parseFloat(this.dataset.mapZoom); + var map_boundingbox = JSON.parse(this.dataset.mapBoundingbox); + var map_geojson = JSON.parse(this.dataset.mapGeojson); + + searxng.loadStyle('css/leaflet.css'); + searxng.loadScript('js/leaflet.js', function () { + var map_bounds = null; + if (map_boundingbox) { + var southWest = L.latLng(map_boundingbox[0], map_boundingbox[2]); + var northEast = L.latLng(map_boundingbox[1], map_boundingbox[3]); + map_bounds = L.latLngBounds(southWest, northEast); + } + + // init map + var map = L.map(leaflet_target); + // create the tile layer with correct attribution + var osmMapnikUrl = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'; + var osmMapnikAttrib = 'Map data © <a href="https://openstreetmap.org">OpenStreetMap</a> contributors'; + var osmMapnik = new L.TileLayer(osmMapnikUrl, {minZoom: 1, maxZoom: 19, attribution: osmMapnikAttrib}); + var osmWikimediaUrl = 'https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png'; + var osmWikimediaAttrib = 'Wikimedia maps | Maps data © <a href="https://openstreetmap.org">OpenStreetMap contributors</a>'; + var 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(function () { + 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); + + var 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/preferences.js b/client/simple/src/js/main/preferences.js new file mode 100644 index 000000000..a0b853d61 --- /dev/null +++ b/client/simple/src/js/main/preferences.js @@ -0,0 +1,53 @@ +/* SPDX-License-Identifier: AGPL-3.0-or-later */ +(function (w, d, searxng) { + 'use strict'; + + if (searxng.endpoint !== 'preferences') { + return; + } + + searxng.ready(function () { + let engine_descriptions = null; + function load_engine_descriptions () { + if (engine_descriptions == null) { + searxng.http("GET", "engine_descriptions.json").then(function (content) { + engine_descriptions = JSON.parse(content); + for (const [engine_name, description] of Object.entries(engine_descriptions)) { + let elements = d.querySelectorAll('[data-engine-name="' + engine_name + '"] .engine-description'); + for (const element of elements) { + let 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/results.js b/client/simple/src/js/main/results.js new file mode 100644 index 000000000..cc7c7efcd --- /dev/null +++ b/client/simple/src/js/main/results.js @@ -0,0 +1,181 @@ +/* SPDX-License-Identifier: AGPL-3.0-or-later */ +(function (w, d, searxng) { + 'use strict'; + + if (searxng.endpoint !== 'results') { + return; + } + + searxng.ready(function () { + 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 () { + var btnLabelCollapsed = this.getAttribute('data-btn-text-collapsed'); + var btnLabelNotCollapsed = this.getAttribute('data-btn-text-not-collapsed'); + var target = this.getAttribute('data-target'); + var targetElement = d.querySelector(target); + var 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 () { + var target = this.getAttribute('data-target'); + var iframe_load = d.querySelector(target + ' > iframe'); + var 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 () { + var 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 = function () { + 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', function () { + searxng.selectNext(false); + }); + obj.addEventListener('swiped-right', function () { + searxng.selectPrevious(false); + }); + } + ); + + w.addEventListener('scroll', function () { + var 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/search.js b/client/simple/src/js/main/search.js new file mode 100644 index 000000000..aca146c41 --- /dev/null +++ b/client/simple/src/js/main/search.js @@ -0,0 +1,210 @@ +/* SPDX-License-Identifier: AGPL-3.0-or-later */ +/* exported AutoComplete */ + +import AutoComplete from "../../../node_modules/autocomplete-js/dist/autocomplete.js"; + +(function (w, d, searxng) { + 'use strict'; + + var qinput_id = "q", qinput; + + const isMobile = window.matchMedia("only screen and (max-width: 50em)").matches; + + function submitIfQuery () { + if (qinput.value.length > 0) { + var search = document.getElementById('search'); + setTimeout(search.submit.bind(search), 0); + } + } + + function createClearButton (qinput) { + var cs = document.getElementById('clear_search'); + var updateClearButton = function () { + if (qinput.value.length === 0) { + cs.classList.add("empty"); + } else { + cs.classList.remove("empty"); + } + }; + + // update status, event listener + updateClearButton(); + cs.addEventListener('click', function (ev) { + qinput.value = ''; + qinput.focus(); + updateClearButton(); + ev.preventDefault(); + }); + qinput.addEventListener('input', updateClearButton, false); + } + + searxng.ready(function () { + qinput = d.getElementById(qinput_id); + + if (qinput !== null) { + // clear button + createClearButton(qinput); + + // autocompleter + if (searxng.settings.autocomplete) { + searxng.autocomplete = AutoComplete.call(w, { + Url: "./autocompleter", + EmptyMessage: searxng.settings.translations.no_item_found, + HttpMethod: searxng.settings.method, + HttpHeaders: { + "Content-type": "application/x-www-form-urlencoded", + "X-Requested-With": "XMLHttpRequest" + }, + MinChars: searxng.settings.autocomplete_min, + Delay: 300, + _Position: function () {}, + _Open: function () { + var params = this; + Array.prototype.forEach.call(this.DOMResults.getElementsByTagName("li"), function (li) { + if (li.getAttribute("class") != "locked") { + li.onmousedown = function () { + params._Select(li); + }; + } + }); + }, + _Select: function (item) { + AutoComplete.defaults._Select.call(this, item); + var form = item.closest('form'); + if (form) { + form.submit(); + } + }, + _MinChars: function () { + if (this.Input.value.indexOf('!') > -1) { + return 0; + } else { + return AutoComplete.defaults._MinChars.call(this); + } + }, + KeyboardMappings: Object.assign({}, AutoComplete.defaults.KeyboardMappings, { + "KeyUpAndDown_up": Object.assign({}, AutoComplete.defaults.KeyboardMappings.KeyUpAndDown_up, { + Callback: function (event) { + AutoComplete.defaults.KeyboardMappings.KeyUpAndDown_up.Callback.call(this, event); + var liActive = this.DOMResults.querySelector("li.active"); + if (liActive) { + AutoComplete.defaults._Select.call(this, liActive); + } + }, + }), + "Tab": Object.assign({}, AutoComplete.defaults.KeyboardMappings.Enter, { + Conditions: [{ + Is: 9, + Not: false + }], + Callback: function (event) { + if (this.DOMResults.getAttribute("class").indexOf("open") != -1) { + var liActive = this.DOMResults.querySelector("li.active"); + if (liActive !== null) { + AutoComplete.defaults._Select.call(this, liActive); + event.preventDefault(); + } + } + }, + }) + }), + }, "#" + qinput_id); + } + + /* + Monkey patch autocomplete.js to fix a bug + With the POST method, the values are not URL encoded: query like "1 + 1" are sent as "1 1" since space are URL encoded as plus. + See HTML specifications: + * HTML5: https://url.spec.whatwg.org/#concept-urlencoded-serializer + * HTML4: https://www.w3.org/TR/html401/interact/forms.html#h-17.13.4.1 + + autocomplete.js does not URL encode the name and values: + https://github.com/autocompletejs/autocomplete.js/blob/87069524f3b95e68f1b54d8976868e0eac1b2c83/src/autocomplete.ts#L665 + + The monkey patch overrides the compiled version of the ajax function. + See https://github.com/autocompletejs/autocomplete.js/blob/87069524f3b95e68f1b54d8976868e0eac1b2c83/dist/autocomplete.js#L143-L158 + The patch changes only the line 156 from + params.Request.send(params._QueryArg() + "=" + params._Pre()); + to + params.Request.send(encodeURIComponent(params._QueryArg()) + "=" + encodeURIComponent(params._Pre())); + + Related to: + * https://github.com/autocompletejs/autocomplete.js/issues/78 + * https://github.com/searxng/searxng/issues/1695 + */ + AutoComplete.prototype.ajax = function (params, request, timeout) { + if (timeout === void 0) { timeout = true; } + if (params.$AjaxTimer) { + window.clearTimeout(params.$AjaxTimer); + } + if (timeout === true) { + params.$AjaxTimer = window.setTimeout(AutoComplete.prototype.ajax.bind(null, params, request, false), params.Delay); + } else { + if (params.Request) { + params.Request.abort(); + } + params.Request = request; + params.Request.send(encodeURIComponent(params._QueryArg()) + "=" + encodeURIComponent(params._Pre())); + } + }; + + if (!isMobile && document.querySelector('.index_endpoint')) { + qinput.focus(); + } + } + + // 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 (let 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 (let 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) { + let categoryValues = []; + for (let categoryButton of categoryButtons) { + if (categoryButton.classList.contains("selected")) { + categoryValues.push(categoryButton.name.replace("category_", "")); + } + } + categoryValuesInput.value = categoryValues.join(","); + } + form.submit(); + }); + } + }); + +})(window, document, window.searxng); |