Skip to content

Commit 20d1912

Browse files
committed
Add search highlighting
Search highlighting implemented using the `mark` tag based upon a query param passed in the URL (`q=`). Existing search UI now will show the active search term and allow you to clear it (using esc or the ‘x’ in the input box). Highlighting will disppear anytime the search textbox value is mutated and will not return until a subsequent page reload occurs.
1 parent 99622af commit 20d1912

File tree

2 files changed

+108
-1
lines changed

2 files changed

+108
-1
lines changed

src/resources/formats/html/bootstrap/_bootstrap-rules.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ h4 {
4242
@include body-secondary;
4343
}
4444

45+
mark {
46+
padding: 0em;
47+
}
48+
4549
caption,
4650
.figure-caption {
4751
@include body-secondary;

src/resources/projects/website/search/quarto-search.js

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,18 @@ window.document.addEventListener("DOMContentLoaded", function (_event) {
9999

100100
callback(fuse.search(query, searchOptions)
101101
.map(result => {
102+
const addParam = (url, name, value) => {
103+
const anchorParts = url.split('#');
104+
const baseUrl = anchorParts[0];
105+
const sep = baseUrl.search("\\?") > 0 ? "&" : "?";
106+
anchorParts[0] = baseUrl + sep + name + "=" + value;
107+
return anchorParts.join("#");
108+
}
109+
102110
return {
103111
title: result.item.title,
104112
section: result.item.section,
105-
href: result.item.href,
113+
href: addParam(result.item.href, "q", query),
106114
text: highlightMatch(query, result.item.text)
107115
}
108116
})
@@ -157,4 +165,99 @@ window.document.addEventListener("DOMContentLoaded", function (_event) {
157165
console.log(error);
158166
});
159167

168+
// If there is a query param indicating that a search was performed, highlight any matching terms
169+
const params = new URLSearchParams(window.location.search);
170+
if (params.has("q")) {
171+
172+
// show the search term in the search box
173+
const searchterm = params.get("q");
174+
searchEl.value = searchterm;
175+
searchEl.focus();
176+
177+
// Search the main section of the document for matching terms to highlight
178+
const mainEl = window.document.querySelector("main");
179+
if (mainEl) {
180+
// highlight matches
181+
highlight(searchterm, mainEl);
182+
183+
// if the input changes, clear the highlighting
184+
let highlighting = true;
185+
searchEl.addEventListener('input', () => {
186+
if (highlighting && searchEl.value !== searchterm) {
187+
clearHighlight(searchterm, mainEl)
188+
highlighting = false;
189+
}
190+
});
191+
192+
// clear the search input if the user presses 'esc'
193+
searchEl.onkeydown = (event) => {
194+
if (event.key === "Escape") {
195+
searchEl.value = "";
196+
}
197+
}
198+
}
199+
}
160200
});
201+
202+
// removes highlighting as implemented by the mark tag
203+
function clearHighlight(searchterm, el) {
204+
const childNodes = el.childNodes;
205+
for (let i = childNodes.length - 1; i >= 0; i--) {
206+
const node = childNodes[i];
207+
if (node.nodeType === Node.ELEMENT_NODE) {
208+
if (node.tagName === 'MARK'&& node.innerText.toLowerCase() === searchterm.toLowerCase()) {
209+
el.replaceChild(document.createTextNode(node.innerText), node);
210+
} else {
211+
clearHighlight(searchterm, node);
212+
}
213+
}
214+
}
215+
}
216+
217+
// highlight matches
218+
function highlight(term, el) {
219+
const termRegex = new RegExp(term, 'ig')
220+
const childNodes = el.childNodes;
221+
222+
// walk back to front avoid mutating elements in front of us
223+
for (let i = childNodes.length - 1; i >= 0; i--) {
224+
const node = childNodes[i];
225+
226+
227+
if (node.nodeType === Node.TEXT_NODE) {
228+
// Search text nodes for text to highlight
229+
const text = node.nodeValue;
230+
231+
let startIndex = 0;
232+
let matchIndex = text.search(termRegex);
233+
if (matchIndex > -1) {
234+
const markFragment = document.createDocumentFragment();
235+
while (matchIndex > -1) {
236+
const prefix = text.slice(startIndex, matchIndex);
237+
markFragment.appendChild(document.createTextNode(prefix));
238+
239+
const mark = document.createElement("mark");
240+
mark.appendChild(document.createTextNode(text.slice(matchIndex, matchIndex + term.length)));
241+
markFragment.appendChild(mark);
242+
243+
244+
startIndex = matchIndex + term.length;
245+
matchIndex = text.slice(startIndex).search(new RegExp(term, 'ig'));
246+
if (matchIndex > -1) {
247+
matchIndex = startIndex + matchIndex;
248+
}
249+
}
250+
if (startIndex < text.length) {
251+
markFragment.appendChild(document.createTextNode(text.slice(startIndex, text.length)));
252+
}
253+
254+
el.replaceChild(markFragment, node);
255+
}
256+
} else if (node.nodeType === Node.ELEMENT_NODE) {
257+
// recurse through elements
258+
highlight(term, node);
259+
}
260+
}
261+
}
262+
263+

0 commit comments

Comments
 (0)