// This file contains the code that drives the search system UX. // It's included in every HTML file. // Depends on library files searchlib.js and ajax.js (and of course lunr.js) // Ideas for improvments: // - Include filtering buttons: // - search only in the current module // - have a query frontend that helps build complex queries // - Filter out results that have score > 0.001 by default and show them on demand. // - Should we change the default term presence to be MUST and not SHOULD ? // -> Hack something like 'name index -value' -> '+name +index -value' // -> 'name ?index -value' -> '+name index -value' // - Highlight can use https://github.com/bep/docuapi/blob/5bfdc7d366ef2de58dc4e52106ad474d06410907/assets/js/helpers/highlight.js#L1 // Better: Add support for AND and OR with parenthesis, ajust this code https://stackoverflow.com/a/20374128 //////// GLOBAL VARS ///////// let input = document.getElementById('search-box'); let results_container = document.getElementById('search-results-container'); let results_list = document.getElementById('search-results'); let searchInDocstringsButton = document.getElementById('search-docstrings-button'); let searchInDocstringsCheckbox = document.getElementById('toggle-search-in-docstrings-checkbox'); var isSearchReadyPromise = null; // setTimeout variable to warn when a search takes too long var _setLongSearchInfosTimeout = null; //////// UI META INFORMATIONS FUNCTIONS ///////// // Taken from https://stackoverflow.com/a/14130005 // For security. function htmlEncode(str) { return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function _setInfos(message, box_id, text_id) { document.getElementById(text_id).textContent = message; if (message.length>0){ document.getElementById(box_id).style.display = 'block'; } else{ document.getElementById(box_id).style.display = 'none'; } } /** * Set the search status. */ function setStatus(message) { document.getElementById('search-status').textContent = message; } /** * Show a warning, hide warning box if empty string. */ function setWarning(message) { _setInfos(message, 'search-warn-box', 'search-warn'); } /** * Say that Something went wrong. */ function setErrorStatus() { resetLongSearchTimerInfo() setStatus("Something went wrong."); setErrorInfos(); } /** * Show additional error infos (used to show query parser errors infos) or tell to go check the console. * @param message: (optional) string */ function setErrorInfos(message) { if (message != undefined){ setWarning(message); } else{ setWarning("Error: See development console for details."); } } /** * Reset the long search timer warning. */ function resetLongSearchTimerInfo(){ if (_setLongSearchInfosTimeout){ clearTimeout(_setLongSearchInfosTimeout); } } function launchLongSearchTimerInfo(){ // After 10 seconds of searching, warn that this is taking more time than usual. _setLongSearchInfosTimeout = setTimeout(setLongSearchInfos, 10000); } /** * Say that this search is taking longer than usual. */ function setLongSearchInfos(){ setWarning("This is taking longer than usual... You can keep waiting for the search to complete, or retry the search with other terms."); } //////// UI SHOW/HIDE FUNCTIONS ///////// function hideResultContainer(){ results_container.style.display = 'none'; if (!document.body.classList.contains("search-help-hidden")){ document.body.classList.add("search-help-hidden"); } } function showResultContainer(){ results_container.style.display = 'block'; updateClearSearchBtn(); } function toggleSearchHelpText() { document.body.classList.toggle("search-help-hidden"); if (document.body.classList.contains("search-help-hidden") && input.value.length==0){ hideResultContainer(); } else{ showResultContainer(); } } function resetResultList(){ resetLongSearchTimerInfo(); results_list.innerHTML = ''; setWarning(''); setStatus(''); } function clearSearch(){ stopSearching(); input.value = ''; updateClearSearchBtn(); } function stopSearching(){ // UI hideResultContainer(); resetResultList(); // NOT UI _stopSearchingProcess(); } function _stopSearchingProcess(){ abortSearch(); restartSearchWorker(); } /** * Show and hide the (X) button depending on the current search input. * We do not show the (X) button when there is no search going on. */ function updateClearSearchBtn(){ if (input.value.length>0){ document.getElementById('search-clear-button').style.display = 'inline-block'; } else{ document.getElementById('search-clear-button').style.display = 'none'; } } //////// SEARCH WARPPER FUNCTIONS ///////// // Values configuring the search-as-you-type feature. var SEARCH_DEFAULT_DELAY = 100; // in miliseconds var SEARCH_INCREASED_DELAY = 200; var SEARCH_INDEX_SIZE_TRESH_INCREASE_DELAY = 10; // in MB var SEARCH_INDEX_SIZE_TRESH_DISABLE_SEARCH_AS_YOU_TYPE = 20; var SEARCH_AUTO_WILDCARD = true; // Search delay depends on index size. function _getIndexSizePromise(indexURL){ return httpGetPromise(indexURL).then((responseText) => { if (responseText==null){ return 0; } let indexSizeApprox = responseText.length / 1000000; // in MB return indexSizeApprox; }); } function _getSearchDelayPromise(indexURL){ // -> Promise of a Search delay number. return _getIndexSizePromise(indexURL).then((size) => { var searchDelay = SEARCH_DEFAULT_DELAY; if (size===0){ return searchDelay; } if (size>SEARCH_INDEX_SIZE_TRESH_INCREASE_DELAY){ // For better UX searchDelay = SEARCH_INCREASED_DELAY; // in miliseconds, this avoids searching several times when typing several leters very rapidly } return searchDelay; }); } function _getIsSearchReadyPromise(){ return Promise.all([ httpGetPromise("all-documents.html"), httpGetPromise("searchindex.json"), httpGetPromise("fullsearchindex.json"), httpGetPromise("lunr.js"), ]); } // Launch search as user types if the size of the index is small enought, // else say "Press 'Enter' to search". function searchAsYouType(){ if (input.value.length>0){ showResultContainer(); } _getIndexSizePromise("searchindex.json").then((indexSizeApprox) => { if (indexSizeApprox > SEARCH_INDEX_SIZE_TRESH_DISABLE_SEARCH_AS_YOU_TYPE){ // Not searching as we type if "default" index size if greater than 20MB. if (input.value.length===0){ // No actual query, this only resets some UI components. launchSearch(); } else{ setTimeout(() => { _stopSearchingProcess(); resetResultList(); setStatus("Press 'Enter' to search."); }); } } else{ launchSearch(); } }); } searchEventsEnv.addEventListener("searchStarted", (ev) => { setStatus("Searching..."); }); var _lastSearchStartTime = null; var _lastSearchInput = null; /** * Do the actual searching business * Main entrypoint to [re]launch the search. * Called everytime the search bar is edited. */ function launchSearch(noDelay){ let _searchStartTime = performance.now(); // Get the query terms let _query = input.value; // In chrome, two events are triggered simultaneously for the input event. // So we discard consecutive (within the same 0.001s) requests that have the same search query. if (( (_searchStartTime-_lastSearchStartTime) < (0.001*1000) ) && (_query === _lastSearchInput) ){ return; } updateClearSearchBtn(); // Setup query meta infos. _lastSearchStartTime = _searchStartTime _lastSearchInput = _query; if (_query.length===0){ stopSearching(); return; } if (!window.Worker) { setStatus("Cannot search: JavaScript Worker API is not supported in your browser. "); return; } resetResultList(); showResultContainer(); setStatus("..."); // Determine indexURL let indexURL = _isSearchInDocstringsEnabled() ? "fullsearchindex.json" : "searchindex.json"; // If search in docstring is enabled: // -> customize query function to include docstring for clauses applicable for all fields let _fields = _isSearchInDocstringsEnabled() ? ["name", "names", "qname", "docstring"] : ["name", "names", "qname"]; resetLongSearchTimerInfo(); launchLongSearchTimerInfo(); // Get search delay, wait the all search resources to be cached and actually launch the search return _getSearchDelayPromise(indexURL).then((searchDelay) => { if (isSearchReadyPromise==null){ isSearchReadyPromise = _getIsSearchReadyPromise() } return isSearchReadyPromise.then((r)=>{ return lunrSearch(_query, indexURL, _fields, "lunr.js", !noDelay?searchDelay:0, SEARCH_AUTO_WILDCARD).then((lunrResults) => { // outdated query results if (_searchStartTime != _lastSearchStartTime){return;} if (!lunrResults){ setErrorStatus(); throw new Error("No data to show"); } if (lunrResults.length == 0){ setStatus('No results matches "' + htmlEncode(_query) + '"'); resetLongSearchTimerInfo(); return; } setStatus("One sec..."); // Get result data return fetchResultsData(lunrResults, "all-documents.html").then((documentResults) => { // outdated query results if (_searchStartTime != _lastSearchStartTime){return;} // Edit DOM resetLongSearchTimerInfo(); displaySearchResults(_query, documentResults, lunrResults) // Log stats console.log('Search for "' + _query + '" took ' + ((performance.now() - _searchStartTime)/1000).toString() + ' seconds.') // End }) }); // lunrResults promise resolved }); }).catch((err) => {_handleErr(err);}); } // end search() function function _handleErr(err){ console.dir(err); setStatus('') if (err.message){ resetLongSearchTimerInfo(); setWarning(err.message) // Here we show the error because it's likely a query parser error. } else{ setErrorStatus(); } } /** * Given the query string, documentResults and lunrResults as used in search(), * edit the DOM to add them in the search results list. */ function displaySearchResults(_query, documentResults, lunrResults){ resetResultList(); documentResults.forEach((dobj) => { results_list.appendChild(buildSearchResult(dobj)); }); if (lunrResults.length > 500){ setWarning("Your search yielded a lot of results! Maybe try with other terms?"); } let publicResults = documentResults.filter(function(value){ return !value.querySelector('.privacy').innerHTML.includes("PRIVATE"); }) if (publicResults.length==0){ setStatus('No results matches "' + htmlEncode(_query) + '". Some private objects matches your search though.'); } else{ setStatus( 'Search for "' + htmlEncode(_query) + '" yielded ' + publicResults.length + ' ' + (publicResults.length === 1 ? 'result' : 'results') + '.'); } } function _isSearchInDocstringsEnabled() { return searchInDocstringsCheckbox.checked; } function toggleSearchInDocstrings() { if (searchInDocstringsCheckbox.checked){ searchInDocstringsButton.classList.add('label-success') } else{ if (searchInDocstringsButton.classList.contains('label-success')){ searchInDocstringsButton.classList.remove('label-success') } } if (input.value.length>0){ launchSearch(true) } } ////// SETUP ////// // Attach launchSearch() to search text field update events. input.oninput = (event) => { setTimeout(() =>{ searchAsYouType(); }, 0); }; input.onkeyup = (event) => { if (event.key === 'Enter') { launchSearch(true); } }; input.onfocus = (event) => { // Ensure the search bar is set-up. // Load fullsearchindex.json, searchindex.json and all-documents.html to have them in the cache asap. isSearchReadyPromise = _getIsSearchReadyPromise(); } document.onload = (event) => { // Set-up search bar. setTimeout(() =>{ isSearchReadyPromise = _getIsSearchReadyPromise(); }, 500); } // Close the dropdown if the user clicks on echap key document.addEventListener('keyup', (evt) => { evt = evt || window.event; if (evt.key === "Escape" || evt.key === "Esc") { hideResultContainer(); } }); // Init search and help text. // search box is not visible by default because // we don't want to show it if the browser do not support JS. window.addEventListener('load', (event) => { document.getElementById('search-box-container').style.display = 'block'; document.getElementById('search-help-box').style.display = 'block'; hideResultContainer(); }); // This listener does 3 things. window.addEventListener("click", (event) => { if (event){ // 1. Hide the dropdown if the user clicks outside of it if (!event.target.closest('#search-results-container') && !event.target.closest('#search-box') && !event.target.closest('#search-help-button')){ hideResultContainer(); return; } // 2. Show the dropdown if the user clicks inside the search box if (event.target.closest('#search-box')){ if (input.value.length>0){ showResultContainer(); return; } } // 3.Hide the dropdown if the user clicks on a link that brings them to the same page. // This includes links in summaries. link = event.target.closest('#search-results a') if (link){ page_parts = document.location.pathname.split('/') current_page = page_parts[page_parts.length-1] href = link.getAttribute("href"); if (!href.startsWith(current_page)){ // The link points to something not in the same page, so don't hide the dropdown. // The page will be reloaded anyway, but this ensure that if we go back, the dropdown will // still be expanded. return; } if (event.ctrlKey || event.shiftKey || event.metaKey){ // The link is openned in a new window/tab so don't hide the dropdown. return; } hideResultContainer(); } } });