Skip to content

Commit 9dbb780

Browse files
authored
Add improved search bar with hinting to ReadTheDocs (#93)
* Add readthedocs search-as-you-type * Replace search with custom search with hinting * Add back RTD search as an optional function * Add hint for how to use RTD search * Use "Search for..." result for detailed RTD search * Remove RTD search window, use results page for details * Resize sidebar, reposition search box, and clean up other styling * Increase search result list height
1 parent d7463c1 commit 9dbb780

File tree

4 files changed

+706
-86
lines changed

4 files changed

+706
-86
lines changed

docs/_static/search.js

Lines changed: 385 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,385 @@
1+
var txtSearch = document.getElementById("txtSearch");
2+
var resultPanel = document.getElementById("tocSearchResult");
3+
var searchPanelOutline = document.getElementById("tocSearchPanelInner");
4+
var searchPanel = document.getElementById("tocSearchPanel");
5+
6+
let highlightedIndex = -1;
7+
8+
function escapeHTML(str) {
9+
return str.replace(/&/g, '&')
10+
.replace(/</g, '&lt;')
11+
.replace(/>/g, '&gt;')
12+
.replace(/"/g, '&quot;')
13+
.replace(/'/g, '&#039;');
14+
}
15+
16+
// This getFullText function is designed to replicate the original GH Pages logic
17+
// for constructing the display string, including stop conditions and popping.
18+
function getFullText(furoATagElement) {
19+
var sb = [];
20+
21+
if (furoATagElement && furoATagElement.textContent) {
22+
sb.push(furoATagElement.textContent.trim());
23+
}
24+
25+
var currentDOMNode = furoATagElement ? furoATagElement.parentElement : null;
26+
27+
while (currentDOMNode != null) {
28+
if (currentDOMNode.classList &&
29+
(currentDOMNode.classList.contains('sidebar-tree') ||
30+
currentDOMNode.classList.contains('sidebar-scroll') ||
31+
currentDOMNode.classList.contains('sidebar-sticky') ||
32+
currentDOMNode.tagName === 'NAV'
33+
)) {
34+
break;
35+
}
36+
37+
if (currentDOMNode.tagName === 'LI' && currentDOMNode.className && currentDOMNode.className.includes('toctree-l')) {
38+
let liChildren = Array.from(currentDOMNode.children);
39+
let currentLiATag = null;
40+
for(let child of liChildren) {
41+
if (child.tagName === 'A' && child.classList.contains('reference') && child.classList.contains('internal')) {
42+
currentLiATag = child;
43+
break;
44+
} else if (child.tagName === 'P') {
45+
let pLink = child.querySelector('a.reference.internal');
46+
if (pLink) {
47+
currentLiATag = pLink;
48+
break;
49+
}
50+
}
51+
}
52+
53+
if (currentLiATag && currentLiATag.textContent) {
54+
var token = currentLiATag.textContent.trim();
55+
if (sb.length > 0) {
56+
if (token.indexOf(" ") !== -1 || token === "Interfaces" || token === "Types") {
57+
break;
58+
}
59+
}
60+
if (sb.length === 0 || sb[sb.length -1] !== token) {
61+
sb.push(token);
62+
}
63+
}
64+
}
65+
currentDOMNode = currentDOMNode.parentElement;
66+
}
67+
68+
if (sb.length > 2) {
69+
sb.pop();
70+
sb.pop();
71+
}
72+
73+
sb.reverse();
74+
return sb.join(".");
75+
}
76+
77+
function txtSearchFocus(event) {
78+
var searchText = txtSearch.value;
79+
if (searchText.length > 0 && resultPanel.children.length > 0 && resultPanel.textContent !== "No results found") {
80+
resultPanel.style.display = "block";
81+
}
82+
if (searchPanelOutline) searchPanelOutline.classList.add("search_panel_focused");
83+
if (searchPanelOutline) searchPanelOutline.classList.remove("search_panel_unfocused");
84+
}
85+
86+
function isChildOf(child, parent) {
87+
while (child != null) {
88+
if (child == parent) return true;
89+
child = child.parentNode;
90+
}
91+
return false;
92+
}
93+
94+
function closePanel() {
95+
if (resultPanel) resultPanel.style.display = "none";
96+
}
97+
98+
function openPanel() {
99+
if (resultPanel) resultPanel.style.display = "block";
100+
positDropdown();
101+
}
102+
103+
function txtSearchLostFocus(event) {
104+
if (searchPanelOutline) searchPanelOutline.classList.add("search_panel_unfocused");
105+
if (searchPanelOutline) searchPanelOutline.classList.remove("search_panel_focused");
106+
}
107+
108+
function updateItemHighlight() {
109+
if (!resultPanel) return;
110+
let items = resultPanel.children;
111+
Array.from(items).forEach((item, index) => {
112+
item.classList.toggle('highlighted_search_selection', index === highlightedIndex);
113+
});
114+
if (highlightedIndex >= 0 && highlightedIndex < items.length) {
115+
items[highlightedIndex].scrollIntoView({ block: 'nearest' });
116+
}
117+
}
118+
119+
function searchResultItemOnClick(event) {
120+
let target = event.target;
121+
while (target && target.tagName !== 'A') {
122+
target = target.parentElement;
123+
}
124+
if (target && target.tagName === 'A') {
125+
const link = target.getAttribute('href');
126+
if (link) {
127+
window.location.href = link;
128+
}
129+
}
130+
}
131+
132+
function isPerfectMatch(searchText, title) {
133+
if (!searchText || !title) return false;
134+
searchText = searchText.toLowerCase();
135+
title = title.toLowerCase();
136+
137+
// Split title into words and check if search text matches any word exactly
138+
const words = title.split(/\s+/);
139+
return words.some(word => word === searchText);
140+
}
141+
142+
function txtSearchChange(event) {
143+
var searchText = txtSearch.value.trim();
144+
if (!resultPanel || !txtSearch) return;
145+
146+
resultPanel.innerHTML = "";
147+
var searchTokens = searchText.toLowerCase().split(/[\\.:,\s]+/).filter(t => t.length > 0);
148+
149+
if (searchText.length === 0 || searchTokens.length === 0) {
150+
resultPanel.style.display = "none";
151+
highlightedIndex = -1;
152+
return;
153+
}
154+
155+
var matchedResults = [];
156+
let hasPerfectMatch = false;
157+
158+
// -------- PHASE 1: TOC-based hierarchical search (existing logic) --------
159+
const allTocLinks = document.querySelectorAll('.sidebar-tree a.reference.internal');
160+
allTocLinks.forEach(innermostATag => {
161+
let currentTokenIndex = searchTokens.length - 1;
162+
let currentMatchCandidateLI = innermostATag.parentElement;
163+
let successfullyMatchedTocTokens = 0;
164+
let tempSearchTokens = [...searchTokens];
165+
166+
while (currentMatchCandidateLI && currentTokenIndex >= 0) {
167+
if (currentMatchCandidateLI.tagName === 'LI' && currentMatchCandidateLI.className.includes('toctree-l')) {
168+
let liTextElement = currentMatchCandidateLI.querySelector('a.reference.internal');
169+
let textToMatch = (currentMatchCandidateLI === innermostATag.parentElement) ?
170+
innermostATag.textContent.trim().toLowerCase() :
171+
(liTextElement ? liTextElement.textContent.trim().toLowerCase() : "");
172+
173+
if (textToMatch.includes(tempSearchTokens[currentTokenIndex])) {
174+
successfullyMatchedTocTokens++;
175+
currentTokenIndex--;
176+
if (currentTokenIndex >=0) {
177+
let parentUL = currentMatchCandidateLI.parentElement;
178+
if (parentUL && parentUL.tagName === 'UL') {
179+
currentMatchCandidateLI = parentUL.parentElement;
180+
if(currentMatchCandidateLI && currentMatchCandidateLI.tagName !== 'LI') {
181+
currentMatchCandidateLI = null;
182+
}
183+
} else {
184+
currentMatchCandidateLI = null;
185+
}
186+
}
187+
} else {
188+
break;
189+
}
190+
} else {
191+
currentMatchCandidateLI = currentMatchCandidateLI.parentElement;
192+
}
193+
}
194+
195+
if (successfullyMatchedTocTokens === tempSearchTokens.length) {
196+
let displayString = getFullText(innermostATag);
197+
let score = 2000 - displayString.length;
198+
let displayStringLower = displayString.toLowerCase();
199+
let allQueryTokensInDisplayString = true;
200+
for(let token of tempSearchTokens){
201+
if(!displayStringLower.includes(token)){
202+
allQueryTokensInDisplayString = false;
203+
break;
204+
}
205+
}
206+
if(allQueryTokensInDisplayString){
207+
const existing = matchedResults.find(r => r.display === displayString && r.href === innermostATag.getAttribute("href"));
208+
if (!existing) {
209+
const isPerfect = isPerfectMatch(searchText, displayString);
210+
if (isPerfect) hasPerfectMatch = true;
211+
matchedResults.push({
212+
display: displayString,
213+
href: innermostATag.getAttribute("href"),
214+
score: score + (isPerfect ? 1000 : 0),
215+
type: 'toc'
216+
});
217+
}
218+
}
219+
}
220+
});
221+
222+
// -------- PHASE 2: Content search using Search._index --------
223+
if (typeof Search !== 'undefined' && Search._index && typeof DOCUMENTATION_OPTIONS !== 'undefined') {
224+
const searchIndex = Search._index;
225+
let docResults = {};
226+
const stemmer = (typeof Stemmer !== 'undefined' && Stemmer.stemWord) ? Stemmer.stemWord : (word => word.toLowerCase());
227+
const queryOriginalTokens = searchText.toLowerCase().split(/[\\.:,\s]+/).filter(t => t.length > 0);
228+
229+
let stemmedSearchTokens = queryOriginalTokens.map(stemmer);
230+
231+
stemmedSearchTokens.forEach((term, i) => {
232+
let originalTerm = queryOriginalTokens[i];
233+
let termDocs = searchIndex.terms[term];
234+
if(!termDocs && searchIndex.terms[originalTerm]) {
235+
termDocs = searchIndex.terms[originalTerm];
236+
}
237+
238+
if (termDocs) {
239+
termDocs.forEach(docInfo => {
240+
let docIndex = Array.isArray(docInfo) ? docInfo[0] : docInfo;
241+
if (!docResults[docIndex]) {
242+
docResults[docIndex] = {
243+
title: searchIndex.titles[docIndex],
244+
filename: searchIndex.filenames[docIndex],
245+
docname: searchIndex.docnames[docIndex],
246+
matchCount: 0,
247+
score: 0
248+
};
249+
}
250+
docResults[docIndex].matchCount++;
251+
docResults[docIndex].score += (searchIndex.terms[term] && searchIndex.terms[term].length || 0) > 100 ? 1 : 10;
252+
if (searchIndex.titles[docIndex] && searchIndex.titles[docIndex].toLowerCase().includes(originalTerm)) {
253+
docResults[docIndex].score += 20;
254+
}
255+
});
256+
}
257+
});
258+
259+
for (const docIndex in docResults) {
260+
const result = docResults[docIndex];
261+
if (result.matchCount === stemmedSearchTokens.length) {
262+
if (result.docname && !result.docname.startsWith('external/core-module-reference')) {
263+
const pageTitle = result.title || "Untitled Page";
264+
const urlRoot = DOCUMENTATION_OPTIONS.URL_ROOT || '';
265+
let pageUrl = result.filename;
266+
if (urlRoot.endsWith('/') && pageUrl.startsWith('/')) {
267+
pageUrl = urlRoot + pageUrl.substring(1);
268+
} else if (!urlRoot.endsWith('/') && !pageUrl.startsWith('/') && urlRoot !== '') {
269+
pageUrl = urlRoot + '/' + pageUrl;
270+
} else {
271+
pageUrl = urlRoot + pageUrl;
272+
}
273+
274+
const existing = matchedResults.find(r => r.href === pageUrl);
275+
if (!existing) {
276+
const isPerfect = isPerfectMatch(searchText, pageTitle);
277+
if (isPerfect) hasPerfectMatch = true;
278+
matchedResults.push({
279+
display: pageTitle,
280+
href: pageUrl,
281+
score: result.score + 500 + (isPerfect ? 1000 : 0),
282+
type: 'content'
283+
});
284+
}
285+
}
286+
}
287+
}
288+
}
289+
290+
matchedResults.sort((a, b) => b.score - a.score);
291+
292+
// Add the "Search for..." item at the top
293+
resultPanel.innerHTML = `<div class='search_result_item' data-type='search'><a href="search.html?q=${encodeURIComponent(searchText)}"><span>Search Documentation for "${escapeHTML(searchText)}"</span></a></div>`;
294+
295+
// Add the rest of the results
296+
resultPanel.innerHTML += matchedResults.map(r =>
297+
`<div class='search_result_item' data-type='${r.type}'><a href='${r.href}'><span>${escapeHTML(r.display)}</span></a></div>`
298+
).join("");
299+
300+
Array.from(resultPanel.children).forEach(child => {
301+
child.addEventListener("click", searchResultItemOnClick);
302+
});
303+
304+
if (matchedResults.length > 0 || searchText.length > 0) {
305+
// If we have a perfect match, highlight the first matching result
306+
// Otherwise, highlight the "Search for..." item
307+
highlightedIndex = hasPerfectMatch ? 1 : 0;
308+
openPanel();
309+
} else {
310+
highlightedIndex = 0;
311+
closePanel();
312+
}
313+
updateItemHighlight();
314+
}
315+
316+
const input = txtSearch;
317+
318+
if (input) {
319+
input.addEventListener('keydown', (e) => {
320+
if (!resultPanel) return;
321+
const items = resultPanel.children;
322+
if (items.length === 0 && e.key !== 'Escape') return;
323+
324+
if (e.key === 'ArrowDown') {
325+
highlightedIndex++;
326+
if (highlightedIndex >= items.length) highlightedIndex = 0;
327+
e.preventDefault();
328+
} else if (e.key === 'ArrowUp') {
329+
highlightedIndex--;
330+
if (highlightedIndex < 0) highlightedIndex = items.length - 1;
331+
e.preventDefault();
332+
} else if (e.key === 'Enter') {
333+
if (highlightedIndex > -1 && items[highlightedIndex]) {
334+
let selectedATag = items[highlightedIndex].querySelector('a');
335+
if (selectedATag && selectedATag.href) {
336+
window.location.href = selectedATag.href;
337+
}
338+
e.preventDefault();
339+
}
340+
} else if (e.key === 'Escape') {
341+
closePanel();
342+
}
343+
updateItemHighlight();
344+
});
345+
346+
input.addEventListener("blur", (e) => {
347+
setTimeout(() => {
348+
if (searchPanel && !searchPanel.contains(document.activeElement)) {
349+
txtSearchLostFocus(e);
350+
if(document.activeElement && !document.activeElement.closest('.search_result_item')){
351+
closePanel();
352+
}
353+
}
354+
}, 150);
355+
});
356+
input.addEventListener("focus", txtSearchFocus);
357+
input.addEventListener("input", txtSearchChange);
358+
}
359+
360+
function positDropdown() {
361+
if (searchPanel && searchPanelOutline && resultPanel) {
362+
resultPanel.style.top = `${searchPanel.offsetHeight}px`;
363+
resultPanel.style.width = `${searchPanelOutline.offsetWidth}px`;
364+
}
365+
}
366+
367+
window.addEventListener('load', positDropdown);
368+
window.addEventListener('resize', positDropdown);
369+
370+
document.addEventListener('click', function(event) {
371+
if (resultPanel && searchPanel && !resultPanel.contains(event.target) && !searchPanel.contains(event.target) && event.target !== txtSearch) {
372+
closePanel();
373+
}
374+
});
375+
376+
document.addEventListener('keydown', function(event) {
377+
if (event.code === 'Backquote') {
378+
event.preventDefault();
379+
if (txtSearch) txtSearch.focus();
380+
}
381+
});
382+
383+
if (txtSearch && txtSearch.offsetParent !== null) {
384+
positDropdown();
385+
}

0 commit comments

Comments
 (0)