1(function () { 2 'use strict'; 3 4 var MIN_CHARS = 2; 5 var DEBOUNCE_MS = 250; 6 7 function debounce(fn, delay) { 8 var timer; 9 return function () { 10 var args = arguments; 11 var context = this; 12 clearTimeout(timer); 13 timer = setTimeout(function () { 14 fn.apply(context, args); 15 }, delay); 16 }; 17 } 18 19 function escapeHtml(text) { 20 var div = document.createElement('div'); 21 div.textContent = String(text == null ? '' : text); 22 return div.innerHTML; 23 } 24 25 function escapeRegExp(value) { 26 return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 27 } 28 29 function highlight(text, query) { 30 var escaped = escapeHtml(text); 31 var safe = escapeRegExp(String(query || '').trim()); 32 if (!safe || safe.length < MIN_CHARS) { 33 return escaped; 34 } 35 return escaped.replace(new RegExp('(' + safe + ')', 'gi'), '<mark>$1</mark>'); 36 } 37 38 function buildCreateUrl(query, parsed) { 39 var params = new URLSearchParams(); 40 var topics = (parsed && parsed.topics) ? parsed.topics : query; 41 if (topics) { 42 params.set('topics', topics); 43 } 44 if (parsed && parsed.course) { 45 params.set('course', parsed.course); 46 } 47 if (parsed && parsed.class) { 48 params.set('class', parsed.class); 49 } 50 var encoded = params.toString(); 51 return encoded ? ('exercise.php?' + encoded) : 'exercise.php'; 52 } 53 54 function trackHeaderSearch(details) { 55 var params = new URLSearchParams(); 56 var userID = null; 57 try { 58 userID = window.localStorage.getItem('userID'); 59 } catch (err) { 60 userID = null; 61 } 62 if (userID) { 63 params.append('userID', userID); 64 } 65 params.append('feature', 'header-search'); 66 params.append('details', details); 67 fetch('writebackFeatureUsage.php', { 68 method: 'POST', 69 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 70 body: params.toString(), 71 keepalive: true, 72 }).catch(function () {}); 73 } 74 75 function initHeaderSearch() { 76 var root = document.getElementById('headerSearch'); 77 var btn = document.getElementById('headerSearchBtn'); 78 var input = document.getElementById('headerSearchInput'); 79 var clearBtn = document.getElementById('headerSearchClearBtn'); 80 var resultsEl = document.getElementById('headerSearchResults'); 81 var panel = document.getElementById('headerSearchPanel'); 82 var backdrop = document.getElementById('headerSearchBackdrop'); 83 if (!root || !btn || !input || !resultsEl) { 84 return; 85 } 86 87 var labels = { 88 noResults: root.getAttribute('data-no-results') || '', 89 create: root.getAttribute('data-create') || '', 90 classLabel: root.getAttribute('data-class-label') || 'Klasse', 91 open: root.getAttribute('data-open-label') || '', 92 close: root.getAttribute('data-close-label') || '', 93 createIcon: root.getAttribute('data-create-icon') || '', 94 loading: root.getAttribute('data-loading-label') || '', 95 }; 96 var abortController = null; 97 var activeIndex = -1; 98 var latestQuery = ''; 99 100 function isOpen() { 101 return root.classList.contains('is-open'); 102 } 103 104 function selectableItems() { 105 return resultsEl.querySelectorAll('[data-search-selectable="1"]'); 106 } 107 108 function setActive(index) { 109 var items = selectableItems(); 110 if (!items.length) { 111 activeIndex = -1; 112 input.removeAttribute('aria-activedescendant'); 113 return; 114 } 115 if (index < 0) { 116 index = items.length - 1; 117 } 118 if (index >= items.length) { 119 index = 0; 120 } 121 activeIndex = index; 122 items.forEach(function (item, i) { 123 item.classList.toggle('is-active', i === activeIndex); 124 if (i === activeIndex) { 125 item.setAttribute('aria-selected', 'true'); 126 input.setAttribute('aria-activedescendant', item.id); 127 if (typeof item.scrollIntoView === 'function') { 128 item.scrollIntoView({ block: 'nearest' }); 129 } 130 } else { 131 item.setAttribute('aria-selected', 'false'); 132 } 133 }); 134 } 135 136 function closeMobileNav() { 137 document.body.classList.remove('mobile-nav-active'); 138 } 139 140 function setToggleLabel(open) { 141 var label = open ? labels.close : labels.open; 142 if (label) { 143 btn.setAttribute('aria-label', label); 144 btn.setAttribute('title', label); 145 } 146 } 147 148 function openSearch() { 149 closeMobileNav(); 150 root.classList.add('is-open'); 151 btn.setAttribute('aria-expanded', 'true'); 152 setToggleLabel(true); 153 trackHeaderSearch('open'); 154 if (panel) { 155 panel.inert = false;
156 } 157 if (backdrop) { 158 backdrop.hidden = false; 159 } 160 input.focus(); 161 if (input.value.trim().length >= MIN_CHARS) { 162 renderShell(input.value.trim(), null, null, true); 163 runSearch(input.value.trim()); 164 } 165 } 166 167 function closeSearch() { 168 root.classList.remove('is-open'); 169 btn.setAttribute('aria-expanded', 'false'); 170 setToggleLabel(false); 171 if (panel) { 172 panel.inert = true; 173 } 174 input.setAttribute('aria-expanded', 'false'); 175 if (backdrop) { 176 backdrop.hidden = true; 177 } 178 resultsEl.classList.add('d-none'); 179 resultsEl.innerHTML = ''; 180 activeIndex = -1; 181 setLoading(false); 182 if (abortController) { 183 abortController.abort(); 184 abortController = null; 185 } 186 } 187 188 function toggleSearch() { 189 if (isOpen()) { 190 closeSearch(); 191 } else { 192 openSearch(); 193 } 194 } 195 196 function setLoading(isLoading) { 197 root.classList.toggle('is-loading', isLoading); 198 resultsEl.setAttribute('aria-busy', isLoading ? 'true' : 'false'); 199 } 200
201 function renderSkeletons(list) { 202 var loading = document.createElement('div'); 203 loading.id = 'headerSearchLoading'; 204 loading.className = 'header-search-loading'; 205 loading.setAttribute('role', 'status'); 206 loading.setAttribute('aria-live', 'polite'); 207 var sr = document.createElement('span'); 208 sr.className = 'visually-hidden'; 209 sr.textContent = labels.loading; 210 loading.appendChild(sr); 211 var i; 212 for (i = 0; i < 3; i += 1) { 213 var row = document.createElement('div'); 214 row.className = 'header-search-skeleton'; 215 row.setAttribute('aria-hidden', 'true'); 216 var titleBar = document.createElement('span'); 217 titleBar.className = 'header-search-skeleton-line header-search-skeleton-title'; 218 var metaBar = document.createElement('span'); 219 metaBar.className = 'header-search-skeleton-line header-search-skeleton-meta'; 220 row.appendChild(titleBar); 221 row.appendChild(metaBar); 222 loading.appendChild(row); 223 } 224 list.appendChild(loading); 225 } 226
227 function renderShell(query, parsed, urls, isLoading) { 228 var createHref = (urls && urls.createUrl) || buildCreateUrl(query, parsed); 229 var list = document.createElement('div'); 230 list.className = 'header-search-result-list'; 231 list.id = 'headerSearchResultList'; 232 233 var empty = document.createElement('p'); 234 empty.id = 'headerSearchEmpty'; 235 empty.className = 'header-search-empty d-none'; 236 empty.textContent = labels.noResults; 237 238 var actions = document.createElement('div'); 239 actions.className = 'header-search-actions'; 240 241 var createLink = document.createElement('a'); 242 createLink.id = 'headerSearchCreateBtn'; 243 createLink.href = createHref; 244 createLink.className = 'header-search-action header-search-action-create'; 245 createLink.setAttribute('data-search-selectable', '1'); 246 createLink.setAttribute('role', 'option'); 247 createLink.setAttribute('aria-selected', 'false'); 248 createLink.innerHTML = '<span class="header-search-action-main">' 249 + (labels.createIcon 250 ? '<i class="' + escapeHtml(labels.createIcon) + '" aria-hidden="true"></i>' 251 : '') 252 + '<span class="header-search-action-title">' + escapeHtml(labels.create) + '</span>' 253 + '</span>'; 254 255 actions.appendChild(createLink); 256 257 resultsEl.innerHTML = ''; 258 resultsEl.appendChild(list); 259 if (isLoading) { 260 renderSkeletons(list); 261 } 262 resultsEl.appendChild(empty); 263 resultsEl.appendChild(actions); 264 resultsEl.classList.remove('d-none'); 265 input.setAttribute('aria-expanded', 'true'); 266 activeIndex = -1; 267 setLoading(!!isLoading); 268 } 269 270 function appendMeta(meta, item) { 271 var parts = []; 272 if (item.course) { 273 parts.push(item.course); 274 } 275 if (item.class) { 276 parts.push(labels.classLabel + ' ' + item.class); 277 } 278 parts.forEach(function (text, index) { 279 if (index > 0) { 280 var sep = document.createElement('span'); 281 sep.className = 'header-search-item-meta-sep'; 282 sep.setAttribute('aria-hidden', 'true'); 283 sep.textContent = '·'; 284 meta.appendChild(sep); 285 } 286 var piece = document.createElement('span'); 287 piece.textContent = text; 288 meta.appendChild(piece); 289 }); 290 if (!item.typeLabel && !(item.typeIcon && (item.typeIcon.src || item.typeIcon.iconClass))) { 291 return; 292 } 293 if (parts.length > 0) { 294 var typeSep = document.createElement('span'); 295 typeSep.className = 'header-search-item-meta-sep'; 296 typeSep.setAttribute('aria-hidden', 'true'); 297 typeSep.textContent = '·'; 298 meta.appendChild(typeSep); 299 } 300 var typeWrap = document.createElement('span'); 301 typeWrap.className = 'header-search-item-type'; 302 var icon = item.typeIcon || {}; 303 if (icon.src) { 304 var img = document.createElement('img'); 305 img.src = icon.src; 306 img.alt = ''; 307 img.width = 16; 308 img.height = 16; 309 img.className = 'header-search-type-icon'; 310 img.setAttribute('data-testid', 'header-search-type-icon'); 311 typeWrap.appendChild(img); 312 } else if (icon.iconClass) { 313 var iconEl = document.createElement('i'); 314 iconEl.className = icon.iconClass + ' header-search-type-icon'; 315 iconEl.setAttribute('aria-hidden', 'true'); 316 iconEl.setAttribute('data-testid', 'header-search-type-icon'); 317 typeWrap.appendChild(iconEl); 318 } 319 if (item.typeLabel) { 320 var typeLabel = document.createElement('span'); 321 typeLabel.textContent = item.typeLabel; 322 typeWrap.appendChild(typeLabel); 323 } 324 meta.appendChild(typeWrap); 325 } 326 327 function renderResults(query, data) { 328 renderShell(query, data.parsed, { 329 createUrl: data.createUrl, 330 }, false); 331 var list = document.getElementById('headerSearchResultList'); 332 var empty = document.getElementById('headerSearchEmpty'); 333 var items = Array.isArray(data.results) ? data.results : []; 334 if (!list) { 335 return; 336 } 337 if (items.length === 0) { 338 if (empty) { 339 empty.classList.remove('d-none'); 340 } 341 return; 342 }
343 items.forEach(function (item, index) { 344 var link = document.createElement('a'); 345 link.href = item.url || '#'; 346 link.id = 'headerSearchResult-' + index; 347 link.className = 'header-search-item'; 348 link.setAttribute('data-testid', 'header-search-result'); 349 link.setAttribute('data-search-selectable', '1'); 350 link.setAttribute('role', 'option'); 351 link.setAttribute('aria-selected', 'false'); 352 353 var title = document.createElement('span'); 354 title.className = 'header-search-item-title'; 355 title.innerHTML = highlight(item.title || '', query); 356 357 var meta = document.createElement('span'); 358 meta.className = 'header-search-item-meta'; 359 appendMeta(meta, item); 360 361 link.appendChild(title); 362 link.appendChild(meta); 363 list.appendChild(link); 364 }); 365 } 366 367 function runSearch(query) { 368 latestQuery = query; 369 trackHeaderSearch('search'); 370 if (abortController) { 371 abortController.abort(); 372 } 373 abortController = new AbortController(); 374 fetch('fetchGallerySearch.php?q=' + encodeURIComponent(query), { 375 signal: abortController.signal, 376 headers: { 'Accept': 'application/json' }, 377 }).then(function (res) { 378 return res.json(); 379 }).then(function (data) { 380 if (query !== latestQuery || !isOpen()) { 381 return; 382 } 383 renderResults(query, data || {}); 384 }).catch(function (err) { 385 if (err && err.name === 'AbortError') { 386 return; 387 } 388 if (query !== latestQuery || !isOpen()) { 389 return; 390 } 391 renderShell(query); 392 setLoading(false); 393 var empty = document.getElementById('headerSearchEmpty'); 394 if (empty) { 395 empty.classList.remove('d-none'); 396 } 397 }); 398 } 399 400 var debouncedSearch = debounce(function (query) { 401 runSearch(query); 402 }, DEBOUNCE_MS); 403 404 function onInput() { 405 var query = input.value.trim(); 406 if (clearBtn) { 407 clearBtn.classList.toggle('d-none', query === ''); 408 } 409 if (query.length < MIN_CHARS) { 410 resultsEl.classList.add('d-none'); 411 resultsEl.innerHTML = ''; 412 input.setAttribute('aria-expanded', 'false'); 413 setLoading(false); 414 if (abortController) { 415 abortController.abort(); 416 abortController = null; 417 } 418 return; 419 } 420 renderShell(query, null, null, true); 421 debouncedSearch(query); 422 } 423 424 resultsEl.addEventListener('click', function (event) { 425 if (event.target.closest('#headerSearchCreateBtn')) { 426 trackHeaderSearch('create'); 427 return; 428 } 429 if (event.target.closest('[data-testid="header-search-result"]')) { 430 trackHeaderSearch('open-result'); 431 } 432 }); 433 434 btn.addEventListener('click', function (event) { 435 event.preventDefault(); 436 event.stopPropagation(); 437 toggleSearch(); 438 }); 439 440 if (clearBtn) { 441 clearBtn.addEventListener('click', function (event) { 442 event.preventDefault(); 443 input.value = ''; 444 onInput(); 445 input.focus(); 446 }); 447 } 448 449 if (backdrop) { 450 backdrop.addEventListener('click', function () { 451 closeSearch(); 452 }); 453 } 454 455 input.addEventListener('input', onInput); 456 457 input.addEventListener('keydown', function (event) { 458 if (event.key === 'Escape') { 459 event.preventDefault(); 460 closeSearch(); 461 btn.focus(); 462 return; 463 } 464 var items = selectableItems(); 465 if (event.key === 'ArrowDown') { 466 event.preventDefault(); 467 if (!items.length) { 468 return; 469 } 470 setActive(activeIndex + 1); 471 return; 472 } 473 if (event.key === 'ArrowUp') { 474 event.preventDefault(); 475 if (!items.length) { 476 return; 477 } 478 setActive(activeIndex - 1); 479 return; 480 } 481 if (event.key === 'Enter') { 482 var query = input.value.trim(); 483 if (query.length < MIN_CHARS) { 484 return; 485 } 486 event.preventDefault(); 487 if (activeIndex >= 0 && items[activeIndex]) { 488 items[activeIndex].click(); 489 return; 490 } 491 if (items.length) { 492 items[0].click(); 493 return; 494 } 495 trackHeaderSearch('create'); 496 window.location.href = buildCreateUrl(query); 497 } 498 }); 499 500 document.addEventListener('click', function (event) { 501 if (!isOpen()) { 502 return; 503 } 504 if (root.contains(event.target)) { 505 return; 506 } 507 closeSearch(); 508 }); 509 510 document.addEventListener('keydown', function (event) { 511 if (event.key === 'Escape' && isOpen()) { 512 event.preventDefault(); 513 closeSearch(); 514 btn.focus(); 515 } 516 }, true); 517 } 518 519 document.addEventListener('DOMContentLoaded', initHeaderSearch); 520})();
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.