Browse Source

[refactor] search.js: use custom auto completion implementation

The previously used library is unmaintained for 6 years now [1] and the solution
had know issues [2][3]

[1] https://github.com/searxng/searxng/pull/4284#discussion_r1954493434
[2] https://github.com/searxng/searxng/pull/4318#issuecomment-2731576657
[3] https://github.com/privau/searxng/issues/56
Bnyro 2 months ago
parent
commit
32823ecb69

+ 0 - 3
client/simple/package.json

@@ -34,8 +34,5 @@
     "vite-plugin-stylelint": "^6.0.0",
     "webpack": "^5.99.8",
     "webpack-cli": "^6.0.1"
-  },
-  "dependencies": {
-    "autocomplete-js": "^2.7.1"
   }
 }

+ 90 - 102
client/simple/src/js/main/search.js

@@ -1,8 +1,6 @@
 /* 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';
 
@@ -38,8 +36,62 @@ import AutoComplete from  "../../../node_modules/autocomplete-js/dist/autocomple
     qinput.addEventListener('input', updateClearButton, false);
   }
 
+  const fetchResults = async (query) => {
+    let request;
+    if (searxng.settings.method === 'GET') {
+      const reqParams = new URLSearchParams();
+      reqParams.append("q", query);
+      request = fetch("./autocompleter?" + reqParams.toString());
+    } else {
+      const formData = new FormData();
+      formData.append("q", query);
+      request = fetch("./autocompleter", {
+        method: 'POST',
+        body: formData,
+      });
+    }
+
+    request.then(async function (response) {
+      const results = await response.json();
+
+      if (!results) return;
+
+      const autocomplete = d.querySelector(".autocomplete");
+      const autocompleteList = d.querySelector(".autocomplete ul");
+      autocomplete.classList.add("open");
+      autocompleteList.innerHTML = "";
+
+      // show an error message that no result was found
+      if (!results[1] || results[1].length == 0) {
+        const noItemFoundMessage = document.createElement("li");
+        noItemFoundMessage.classList.add('no-item-found');
+        noItemFoundMessage.innerHTML = searxng.settings.translations.no_item_found;
+        autocompleteList.appendChild(noItemFoundMessage);
+        return;
+      }
+
+      for (let result of results[1]) {
+        const li = document.createElement("li");
+        li.innerText = result;
+
+        searxng.on(li, 'mousedown', () => {
+          qinput.value = result;
+          const form = d.querySelector("#search");
+          form.submit();
+          autocomplete.classList.remove('open');
+        });
+        autocompleteList.appendChild(li);
+      }
+    });
+  };
+
   searxng.ready(function () {
+    // focus search input on large screens
+    if (!isMobile) document.getElementById("q").focus();
+
     qinput = d.getElementById(qinput_id);
+    const autocomplete = d.querySelector(".autocomplete");
+    const autocompleteList = d.querySelector(".autocomplete ul");
 
     if (qinput !== null) {
       // clear button
@@ -47,109 +99,45 @@ import AutoComplete from  "../../../node_modules/autocomplete-js/dist/autocomple
 
       // 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);
+        searxng.on(qinput, 'input', () => {
+          const query = qinput.value;
+          if (query.length < searxng.settings.autocomplete_min) return;
+
+          setTimeout(() => {
+            if (query == qinput.value) fetchResults(query);
+          }, 300);
+        });
+
+        searxng.on(qinput, 'keyup', (e) => {
+          let currentIndex = -1;
+          const listItems = autocompleteList.children;
+          for (let i = 0; i < listItems.length; i++) {
+            if (listItems[i].classList.contains('active')) {
+              currentIndex = i;
+              break;
             }
-          },
-          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();
+          let newCurrentIndex = -1;
+          if (e.key === "ArrowUp") {
+            if (currentIndex >= 0) listItems[currentIndex].classList.remove('active');
+            // we need to add listItems.length to the index calculation here because the JavaScript modulos
+            // operator doesn't work with negative numbers
+            newCurrentIndex = (currentIndex - 1 + listItems.length) % listItems.length;
+          } else if (e.key === "ArrowDown") {
+            if (currentIndex >= 0) listItems[currentIndex].classList.remove('active');
+            newCurrentIndex = (currentIndex + 1) % listItems.length;
+          } else if (e.key === "Tab" || e.key === "Enter") {
+            autocomplete.classList.remove('open');
           }
-          params.Request = request;
-          params.Request.send(encodeURIComponent(params._QueryArg()) + "=" + encodeURIComponent(params._Pre()));
-        }
-      };
 
-      if (!isMobile && document.querySelector('.index_endpoint')) {
-        qinput.focus();
+          if (newCurrentIndex != -1) {
+            const selectedItem = listItems[newCurrentIndex];
+            selectedItem.classList.add('active');
+
+            if (!selectedItem.classList.contains('no-item-found')) qinput.value = selectedItem.innerText;
+          }
+        });
       }
     }
 
@@ -184,7 +172,7 @@ import AutoComplete from  "../../../node_modules/autocomplete-js/dist/autocomple
           categoryButton.classList.remove("selected");
         }
         button.classList.add("selected");
-      })
+      });
     }
 
     // override form submit action to update the actually selected categories

+ 1 - 2
client/simple/src/less/autocomplete.less

@@ -3,6 +3,7 @@
 .autocomplete {
   position: absolute;
   width: @search-width;
+  max-width: calc(100% - 2 * @search-padding-horizontal);
   max-height: 0;
   overflow-y: hidden;
   .ltr-text-align-left();
@@ -65,8 +66,6 @@
 
 @media screen and (max-width: @phone) {
   .autocomplete {
-    width: 100%;
-
     > ul > li {
       padding: 1rem;
     }

+ 2 - 1
client/simple/src/less/definitions.less

@@ -287,8 +287,9 @@
 @results-image-row-height: 12rem;
 @results-image-row-height-phone: 10rem;
 @search-width: 44rem;
-// heigh of #search, see detail.less
+// height of #search, see detail.less
 @search-height: 13rem;
+@search-padding-horizontal: 0.5rem;
 
 /// Device Size
 /// @desktop > @tablet

+ 4 - 4
client/simple/src/less/search.less

@@ -131,7 +131,7 @@ button.category_button {
 }
 
 #search_view {
-  padding: 0.5rem 0.3rem 0 0.5rem;
+  padding: 0.5rem @search-padding-horizontal 0 @search-padding-horizontal;
   grid-area: search;
 
   body.results_endpoint & {
@@ -141,7 +141,8 @@ button.category_button {
 
 .search_box {
   border-radius: 0.8rem;
-  width: @search-width;
+  width: 100%;
+  max-width: @search-width;
   display: inline-flex;
   flex-direction: row;
   white-space: nowrap;
@@ -291,8 +292,7 @@ html.no-js #clear_search.hide_if_nojs {
   }
 
   .search_box {
-    width: 98%;
-    display: flex;
+    width: 100%;
   }
 
   #q {

+ 1 - 0
searx/templates/simple/search.html

@@ -9,6 +9,7 @@
         <input id="q" name="q" type="text" placeholder="{{ _('Search for...') }}" tabindex="1" autocomplete="off" autocapitalize="none" spellcheck="false" autocorrect="off" dir="auto" value="{{ q or '' }}">
         <button id="clear_search" type="reset" aria-label="{{ _('clear') }}" class="hide_if_nojs"><span>{{ icon_big('close') }}</span><span class="show_if_nojs">{{ _('clear') }}</span></button>
         <button id="send_search" type="submit" {%- if search_on_category_select -%}name="category_{{ selected_categories[0]|replace(' ', '_') }}"{%- endif -%} aria-label="{{ _('search') }}"><span class="hide_if_nojs">{{ icon_big('search') }}</span><span class="show_if_nojs">{{ _('search') }}</span></button>
+        <div class="autocomplete hide_if_nojs"><ul></ul></div>
       </div>
     </div>
     {% set display_tooltip = true %}

+ 1 - 0
searx/templates/simple/simple_search.html

@@ -5,6 +5,7 @@
         <input id="q" name="q" type="text" placeholder="{{ _('Search for...') }}" autocomplete="off" autocapitalize="none" spellcheck="false" autocorrect="off" dir="auto" value="{{ q or '' }}">
         <button id="clear_search" type="reset" aria-label="{{ _('clear') }}"><span class="hide_if_nojs">{{ icon_big('close') }}</span><span class="show_if_nojs">{{ _('clear') }}</span></button>
         <button id="send_search" type="submit" aria-label="{{ _('search') }}"><span class="hide_if_nojs">{{ icon_big('search') }}</span><span class="show_if_nojs">{{ _('search') }}</span></button>
+        <div class="autocomplete hide_if_nojs"><ul></ul></div>
       </div>
     </div>
   </div>