PageSourceSearch

https://doc.quickrouter.ai/js/search.js?v=80fcf033af262494

js quickrouter.ai collected 2026-10-05 12:28:51 UTC 6,905 bytes, 193 lines download raw bytes

1// Client-side docs search + TOC scroll-spy
2(function() {
3  'use strict';
4
5  // === Search ===
6  var input = document.getElementById('searchInput');
7  var resultsBox = document.getElementById('searchResults');
8  var index = null;
9  var indexPromise = null;
10  var activeIdx = -1;
11
12  function loadIndex() {
13    if (!indexPromise) {
14      indexPromise = fetch('/search-index.json')
15        .then(function(r) { return r.json(); })
16        .then(function(data) { index = data; return data; })
17        .catch(function() { index = []; return []; });
18    }
19    return indexPromise;
20  }
21
22  function esc(s) {
23    return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
24  }
25
26  function highlight(text, query) {
27    var lower = text.toLowerCase();
28    var q = query.toLowerCase();
29    var pos = lower.indexOf(q);
30    if (pos === -1) return esc(text);
31    return esc(text.slice(0, pos)) + '<mark>' + esc(text.slice(pos, pos + query.length)) + '</mark>' + esc(text.slice(pos + query.length));
32  }
33
34  function search(query) {
35    if (!index) return [];
36    var q = query.trim().toLowerCase();
37    if (!q) return [];
38    var titleHits = [];
39    var headingHits = [];
40    var excerptHits = [];
41    for (var i = 0; i < index.length; i++) {
42      var p = index[i];
43      p._matchedHeading = null;
44      var title = (p.title || '').toLowerCase();
45      var headingList = p.headings || [];
46      var matchedHeading = null;
47      for (var j = 0; j < headingList.length; j++) {
48        if (headingList[j].toLowerCase().indexOf(q) !== -1) { matchedHeading = headingList[j]; break; }
49      }
50      var excerpt = (p.excerpt || '').toLowerCase();
51      var keywordMatch = (p.keywords || []).some(function(keyword) { return keyword.toLowerCase().indexOf(q) !== -1; });
52      if (title.indexOf(q) !== -1) {
53        titleHits.push(p);
54      } else if (matchedHeading) {
55        p._matchedHeading = matchedHeading;
56        headingHits.push(p);
57      } else if (excerpt.indexOf(q) !== -1 || keywordMatch) {
58        excerptHits.push(p);
59      }
60      if (titleHits.length >= 8 && headingHits.length >= 8 && excerptHits.length >= 6) break;
61    }
62    return titleHits.concat(headingHits).concat(excerptHits).slice(0, 12);
63  }
64
65  function render(query) {
66    var q = query.trim();
67    if (!q) {
68      resultsBox.hidden = true;
69      resultsBox.innerHTML = '';
70      activeIdx = -1;
71      return;
72    }
73    var hits = search(q);
74    if (!hits.length) {
75      resultsBox.innerHTML = '<div class="search-empty">未找到与「' + esc(q) + '」相关的页面</div>';
76      resultsBox.hidden = false;
77      activeIdx = -1;
78      return;
79    }
80    var html = '';
81    for (var i = 0; i < hits.length; i++) {
82      var p = hits[i];
83      var sub = p._matchedHeading ? '章节:' + p._matchedHeading : (p.excerpt || '').slice(0, 60);
84      html += '<a class="search-result-item" data-idx="' + i + '" href="' + p.url + '">' +
85        '<span class="search-result-title">' + highlight(p.title || p.slug, q) + '</span>' +
86        '<span class="search-result-sub">' + esc(sub) + '</span>' +
87        '</a>';
88    }
89    resultsBox.innerHTML = html;
90    resultsBox.hidden = false;
91    activeIdx = -1;
92  }
93
94  function moveActive(delta) {
95    var items = resultsBox.querySelectorAll('.search-result-item');
96    if (!items.length) return;
97    activeIdx = (activeIdx + delta + items.length) % items.length;
98    items.forEach(function(it, i) { it.classList.toggle('active', i === activeIdx); });
99    items[activeIdx].scrollIntoView({ block: 'nearest' });
100  }
101
102  if (input && resultsBox) {
103    var debounce = null;
104    input.addEventListener('input', function() {
105      clearTimeout(debounce);
106      var q = this.value;
107      debounce = setTimeout(function() {
108        loadIndex().then(function() { render(q); });
109      }, 120);
110    });
111    input.addEventListener('focus', function() {
112      loadIndex();
113      if (this.value.trim()) render(this.value);
114    });
115    input.addEventListener('keydown', function(e) {
116      if (e.key === 'ArrowDown') { e.preventDefault(); moveActive(1); }
117      else if (e.key === 'ArrowUp') { e.preventDefault(); moveActive(-1); }
118      else if (e.key === 'Enter') {
119        var items = resultsBox.querySelectorAll('.search-result-item');
120        var target = activeIdx >= 0 ? items[activeIdx] : items[0];
121        if (target) { window.location.href = target.getAttribute('href'); }
122      } else if (e.key === 'Escape') {
123        resultsBox.hidden = true;
124        this.blur();
125      }
126    });
127    document.addEventListener('click', function(e) {
128      if (!e.target.closest('.sidebar-search') && !e.target.closest('.search-results')) {
129        resultsBox.hidden = true;
130      }
131    });
132    // "/" shortcut focuses search
133    document.addEventListener('keydown', function(e) {
134      if (e.key === '/' && !e.target.closest('input, textarea, select')) {
135        e.preventDefault();
136        input.focus();
137        // On mobile the sidebar may be closed
138        var sidebar = document.getElementById('sidebar');
139        if (sidebar && window.innerWidth <= 1024 && !sidebar.classList.contains('open')) {
140          var toggle = document.getElementById('sidebarToggle');
141          if (toggle) toggle.click();
142        }
143      }
144    });
145  }
146
147  // === TOC scroll-spy ===
148  var tocNav = document.getElementById('tocNav');
149  if (tocNav) {
150    var links = tocNav.querySelectorAll('.toc-link');
151    var headings = [];
152    links.forEach(function(link) {
153      var el = document.getElementById(link.getAttribute('data-toc-id'));
154      if (el) headings.push({ el: el, link: link });
155    });
156
157    if (headings.length) {
158      var currentLink = null;
159      function setActive(link) {
160        if (currentLink === link) return;
161        if (currentLink) currentLink.classList.remove('active');
162        if (link) link.classList.add('active');
163        currentLink = link;
164      }
165
166      var observer = new IntersectionObserver(function(entries) {
167        entries.forEach(function(entry) {
168          if (entry.isIntersecting) {
169            for (var i = 0; i < headings.length; i++) {
170              if (headings[i].el === entry.target) { setActive(headings[i].link); break; }
171            }
172          }
173        });
174      }, { rootMargin: '-80px 0px -70% 0px', threshold: 0 });
175
176      headings.forEach(function(h) { observer.observe(h.el); });
177
178      // Smooth-scroll on click
179      tocNav.addEventListener('click', function(e) {
180        var link = e.target.closest('.toc-link');
181        if (!link) return;
182        e.preventDefault();
183        var el = document.getElementById(link.getAttribute('data-toc-id'));
184        if (el) {
185          var y = el.getBoundingClientRect().top + window.pageYOffset - 90;
186          window.scrollTo({ top: y, behavior: 'smooth' });
187          history.replaceState(null, '', '#' + link.getAttribute('data-toc-id'));
188          setActive(link);
189        }
190      });
191    }
192  }
193})();

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.