1class Search { 2 constructor() { 3 this.fuse = null; 4 this.searchString = null; 5 this.timeout = null; 6 this.fixedWindowWidth = null; 7 8 // CSS classes to use for different modes. 9 this.classes = Object.freeze({ 10 selectedEntry: 'active', 11 searchingMode: 'navbar-search', 12 openSearchbox: 'searchbox-open', 13 }); 14 15 // CSS selectors for accessing page elements. 16 this.selectors = Object.freeze({ 17 searchbox: '#searchbox', 18 results: '#omnisearch-suggestions', 19 resultElements: '#omnisearch-suggestions *', 20 resultAllEntries: '#omnisearch-suggestions a', 21 resultSelectedEntry: `#omnisearch-suggestions a.${this.classes.selectedEntry}`, 22 resultsFirstEntry: '#omnisearch-suggestions a:first-of-type', 23 nav: 'body', 24 show: '#searchShow', 25 hide: '#searchHide', 26 }); 27 28 // Search index types. 29 this.types = Object.freeze({ 30 title: 'title', 31 perex: 'perex', 32 content: 'content', 33 }); 34 35 // Keycodes used in navigation shortcuts. 36 this.shortcuts = Object.freeze({ 37 escape: 27, 38 enter: 13, 39 up: 38, 40 down: 40, 41 }); 42 43 // Options for the fuse library. 44 this.fuseOptions = Object.freeze({ 45 shouldSort: true, 46 threshold: 0.3, 47 ignoreLocation: true, 48 maxPatternLength: 32, 49 minMatchCharLength: 2, 50 includeMatches: true, 51 includeScore: true, 52 keys: [ 53 { 54 name: this.types.title, 55 weight: 0.2 // give title more importance 56 }, 57 { 58 name: this.types.perex, 59 weight: 0.3 // give perex more importance 60 }, 61 { 62 name: this.types.content, 63 weight: 0.6 64 } 65 ] 66 }); 67 68 $(this.selectors.show).on('click', $.proxy(this.showSearch, this)); 69 $(this.selectors.hide).on('click', $.proxy(this.hideSearch, this)); 70 71 $(this.selectors.searchbox).on('blur', $.proxy(this.maybeHideSearch, this)); 72 $(this.selectors.searchbox).on('keydown', $.proxy(this.navigate, this)); 73 $(this.selectors.searchbox).on('focus', $.proxy(this.instantSearch, this)); 74 $(this.selectors.searchbox).on('keyup', $.proxy(this.delayedSearch, this)); 75 76 $(this.selectors.results).on('blur', $.proxy(this.maybeHideSearch, this)); 77 78 // With open suggestions we set a fixed body width which does not work with 79 // resizing content. Hide search on resize to avoid this glitch. 80 $(window).on('resize', $.proxy(this.onResize, this)); 81 } 82 83 /** 84 * Event callbacks. 85 */ 86 showSearch() { 87 $(this.selectors.nav).addClass(this.classes.searchingMode); 88 $(this.selectors.searchbox).focus(); 89 } 90 91 hideSearch() { 92 $(this.selectors.nav).removeClass(this.classes.searchingMode); 93 this.hideSuggestions(); 94 } 95 96 maybeHideSearch(event) { 97 // Do not hide if 98 // a) focus is lost to either the search box or the results list, or 99 // b) the whole window loses focus (e.g. when switching keyboard layout). 100 if ($(this.selectors.searchbox)[0].contains(event.relatedTarget) || 101 $(this.selectors.results)[0].contains(event.relatedTarget) || 102 document.activeElement === event.target) 103 { 104 return; 105 } 106 this.hideSearch(); 107 } 108 109 onResize() { 110 if (this.fixedWindowWidth === null || 111 this.fixedWindowWidth == window.innerWidth) 112 { 113 return; 114 } 115 // Hide the searchbox if the width of the window has changed since the 116 // search results box got opened. This avoids weird artifacts of 117 // resizing the body while it's width is fixed (fixing the width itself 118 // protects the body from scrolling while search results are open). 119 this.hideSearch(); 120 } 121 122 navigate(e) { 123 switch (e.keyCode) { 124 case this.shortcuts.escape: 125 e.preventDefault(); 126 this.hideSearch(); 127 return; 128 case this.shortcuts.enter: 129 $(this.selectors.resultSelectedEntry)[0].click(); 130 return false;
131 case this.shortcuts.down: 132 e.preventDefault(); 133 this.move(true); 134 return; 135 case this.shortcuts.up: 136 e.preventDefault(); 137 this.move(false); 138 return; 139 } 140 } 141 142 move(down) { 143 let allEntries = $(this.selectors.resultAllEntries); 144 for (let i = 0; i < allEntries.length; i++) { 145 if (allEntries.eq(i).hasClass(this.classes.selectedEntry)) { 146 let next = i + (down ? 1 : -1); 147 if (next < 0 || next >= allEntries.length) { 148 return; 149 } 150 allEntries.eq(i).removeClass(this.classes.selectedEntry); 151 allEntries.eq(next).addClass(this.classes.selectedEntry); 152 allEntries.get(next).scrollIntoView({block: "nearest", inline: "nearest"}); 153 break; 154 } 155 } 156 } 157 158 isShortcut(keyCode) { 159 return Object.values(this.shortcuts).includes(keyCode); 160 } 161 162 instantSearch(e) { 163 // Skip for special shortcuts, these are handled by navigate(). 164 if (this.isShortcut(e.keyCode)) { 165 return; 166 } 167 this.search(); 168 } 169 170 delayedSearch(e) { 171 // Skip for special shortcuts, these are handled by navigate(). 172 if (this.isShortcut(e.keyCode)) { 173 return; 174 } 175 window.clearTimeout(this.timeout); 176 this.timeout = window.setTimeout($.proxy(this.search, this), 200); 177 } 178 179 /** 180 * Private implementation functions. 181 */ 182 183 search() { 184 if (this.fuse === null) { 185 jQuery.get("/search.json", $.proxy(this.onSiteJsonSucceeded, this)).fail($.proxy(this.onSiteJsonFailed, this)); 186 return; 187 } 188 189 let newSearchString = $(this.selectors.searchbox).val(); 190 if (newSearchString != this.searchString) { 191 this.searchString = newSearchString; 192 let searchResultsHtml = ''; 193 if (this.searchString.length > 1) { 194 let searchResults = this.fuse.search(this.searchString); 195 searchResultsHtml = searchResults.length ? 196 this.getResultsHtml(searchResults) : 197 this.getEmptyResultsHtml('Nic nenalezeno'); 198 } else { 199 searchResultsHtml = ''; 200 } 201 this.updateResults(searchResultsHtml); 202 } 203 if ($(this.selectors.resultElements).length > 0) { 204 this.showSuggestions(); 205 } else { 206 this.hideSuggestions(); 207 } 208 } 209 210 onSiteJsonSucceeded (data) { 211 this.fuse = new Fuse(data, this.fuseOptions); 212 this.search(); 213 } 214 215 onSiteJsonFailed () { 216 this.updateResults(this.getEmptyResultsHtml('Hledánà nefunguje. Možná je chyba v pÅipojenà k internetu?')); 217 this.showSuggestions(); 218 } 219 220 updateResults(resultsHtml) { 221 $(this.selectors.results).html(resultsHtml); 222 $(this.selectors.resultsFirstEntry).addClass(this.classes.selectedEntry); 223 } 224 225 showSuggestions() { 226 let $body = $(document.body); 227 let oldWidth = $body.innerWidth(); 228 $body.width(oldWidth); 229 $(this.selectors.nav).width(oldWidth); 230 $body.css("overflow", "hidden"); 231 $(this.selectors.results).show(); 232 $(this.selectors.searchbox).addClass(this.classes.openSearchbox); 233 this.fixedWindowWidth = window.innerWidth; 234 } 235 236 hideSuggestions() { 237 $(this.selectors.results).hide(); 238 this.fixedWindowWidth = null; 239 let $body = $(document.body); 240 $body.css("overflow", "auto"); 241 $body.width("auto"); 242 $(this.selectors.nav).width("auto"); 243 $(this.selectors.searchbox).removeClass(this.classes.openSearchbox); 244 } 245 246 getResultsHtml(results) { 247 var resultsHtml = ''; 248 results.forEach(res => { 249 let item = res.item; 250 let title = this.getTitle(res.matches, item.title); 251 let snippet = this.getSnippet(res.matches); 252 let card = `<div class="title">${title}</div>` + 253 `<div class="search-preview card">${item.block}</div>` + 254 `<div class="snippet">${snippet}</div>`; 255 let url = `${item.url}?q=${encodeURIComponent(this.searchString)}`; 256 let is_url_absolute = /^(?:[a-z]+:)?\/\//i.test(url); 257 let target = is_url_absolute ? ' target="_blank"' : "";
258 resultsHtml += `<a class="dropdown-item clearfix" href="${url}"${target} tabindex="0">${card}</a>`; 259 }); 260 return resultsHtml; 261 } 262 263 getEmptyResultsHtml(infoText) { 264 return `<div class="dropdown-item empty">${infoText}</div>`; 265 } 266 267 getSnippet(matches) { 268 let instances = this.getKeywordInstances(matches, false); 269 if (instances.length == 0) 270 return ''; 271 272 let maxSnippets = 2; 273 let context = 30; 274 275 let results = []; 276 for (let i = 0; i < instances.length && i < maxSnippets; i++) { 277 let instance = instances[i]; 278 let prefix = instance.text.substring(instance.start - context, instance.start); 279 let suffix = instance.text.substring(instance.end, instance.end + context); 280 let result = ''; 281 if (instance.start - context - 1 >= 0) result += '...'; 282 if (prefix) result += prefix; 283 result += this.getKeywordHTML(instance.text.substring(instance.start, instance.end)); 284 if (suffix) result += suffix; 285 if (instance.end + context + 1 < instance.text.length) result += '...'; 286 results.push(result); 287 } 288 return results.join(' '); 289 } 290 291 getTitle(matches, unmatchedTitle) { 292 let instances = this.getKeywordInstances(matches, true); 293 if (instances.length == 0) 294 return unmatchedTitle; 295 // Only consider the first. 296 let instance = instances[0]; 297 let prefix = instance.text.substring(0, instance.start); 298 let suffix = instance.text.substring(instance.end); 299 let result = ''; 300 if (prefix) result += prefix; 301 result += this.getKeywordHTML(instance.text.substring(instance.start, instance.end)); 302 if (suffix) result += suffix; 303 return result; 304 } 305 306 getKeywordHTML(text) { 307 return `<span class="keyword">${text}</span>`; 308 } 309 310 getKeywordInstances(matches, shouldMatchTitle) { 311 let instances = []; 312 if (!matches) 313 return instances; 314 for (const match of matches) { 315 let isTitle = (match.key == this.types.title); 316 if (isTitle != shouldMatchTitle) { 317 continue; 318 } 319 for (const index of match.indices) { 320 // Ignore matches of length 1; 321 if (index[1] - index[0] == 0) continue; 322 instances.push({start: index[0], end: index[1] + 1, text: match.value}); 323 } 324 } 325 // Sort by match length, descending. 326 instances.sort((a,b) => (b.end - b.start) - (a.end - a.start)); 327 return instances; 328 } 329}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.