Skip to content
This repository was archived by the owner on Nov 17, 2022. It is now read-only.

Commit b6d5805

Browse files
author
Thom Chiovoloni
committed
Tokenize search results and highlight the matches in the sidebar
1 parent e0c87c4 commit b6d5805

File tree

3 files changed

+151
-14
lines changed

3 files changed

+151
-14
lines changed

src/tab.js

Lines changed: 136 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,13 @@ SideTab.prototype = {
6666
const titleWrapper = document.createElement("div");
6767
titleWrapper.className = "tab-title-wrapper";
6868

69-
const title = document.createElement("span");
70-
title.className = "tab-title";
69+
const title = document.createElement("div");
70+
title.className = "tab-title search-highlight-container";
7171
titleWrapper.appendChild(title);
7272
this._titleView = title;
7373

74-
const host = document.createElement("span");
75-
host.className = "tab-host";
74+
const host = document.createElement("div");
75+
host.className = "tab-host search-highlight-container";
7676
titleWrapper.appendChild(host);
7777
this._hostView = host;
7878

@@ -94,13 +94,60 @@ SideTab.prototype = {
9494
tab.appendChild(pin);
9595
tab.appendChild(close);
9696
},
97+
matches(tokens) {
98+
if (tokens.length === 0) {
99+
return true;
100+
}
101+
let title = normalizeStr(this.title);
102+
let url = normalizeStr(this.url);
103+
for (let token of tokens) {
104+
token = normalizeStr(token);
105+
if (title.includes(token)) {
106+
return true;
107+
}
108+
if (url.includes(token)) {
109+
return true;
110+
}
111+
}
112+
return false;
113+
},
114+
_highlightSearchResults(node, text, searchTokens) {
115+
let ranges = findHighlightedRanges(text, searchTokens);
116+
117+
// Clear out the node before we fill it with new stuff.
118+
while (node.firstChild) {
119+
node.removeChild(node.firstChild);
120+
}
121+
122+
for (let {text, highlight} of ranges) {
123+
if (highlight) {
124+
let span = document.createElement("span");
125+
span.className = "search-highlight";
126+
span.textContent = text;
127+
node.appendChild(span);
128+
} else {
129+
node.appendChild(document.createTextNode(text));
130+
}
131+
}
132+
},
133+
highlightMatches(tokens) {
134+
if (!this.visible) {
135+
// Reset these to the 'no matches' state (Not calling
136+
// _highlightSearchResult is just an optimization).
137+
this.updateTitle(this.title);
138+
this.updateURL(this.url);
139+
} else {
140+
this._highlightSearchResults(this._titleView, this.title, tokens);
141+
this._highlightSearchResults(this._hostView, getHost(this.url), tokens);
142+
}
143+
},
97144
updateTitle(title) {
98145
this.title = title;
99146
this._titleView.innerText = title;
100147
this.view.title = title;
101148
},
102149
updateURL(url) {
103-
const host = new URL(url).host || url;
150+
const host = getHost(url);
104151
this.url = url;
105152
this._hostView.innerText = host;
106153
},
@@ -282,4 +329,88 @@ function toggleClass(node, className, boolean) {
282329
boolean ? node.classList.add(className) : node.classList.remove(className);
283330
}
284331

332+
function normalizeStr(str) {
333+
return str ? str.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "") : "";
334+
}
335+
336+
function getHost(url) {
337+
return new URL(url).host || url;
338+
}
339+
340+
// This function takes as input a text string and an array of "search tokens"
341+
// and returns what we should render in an abstract sense. e.g. an array of
342+
// `{text: string, highlighted: bool}`, such that `result.map(r =>
343+
// r.text).join('')` should equal what was provided as the first argument, and
344+
// that the sections with `highlighted: true` correspond to ranges that match
345+
// the members of searchTokens.
346+
//
347+
// (It's complex enough to arguably warrant unit tests, but oh well, it's split
348+
// out so that I could more easily test it manually).
349+
function findHighlightedRanges(text, searchTokens) {
350+
// Trivial case
351+
if (searchTokens.length === 0) {
352+
return [{text, highlighted: false}];
353+
}
354+
// Potentially surprisingly, changing case doesn't preserve length. If we
355+
// can't do this without messing up the indices in the given text, we fail.
356+
// This function is just for highlighting the matching parts in searches in
357+
// the UI, so it's not a big deal if it doesn't highlight something.
358+
let canLowercaseText = text.toLowerCase().length === text.length &&
359+
searchTokens.every(t =>
360+
t.toLowerCase().length === t.length);
361+
let normalize = s => canLowercaseText ? s.toLowerCase() : s;
362+
let normText = normalize(text);
363+
364+
// Build an array of the start/end indices of each result.
365+
let ranges = [];
366+
for (let token of searchTokens) {
367+
token = normalize(token);
368+
if (!token.length) {
369+
continue;
370+
}
371+
for (let index = normText.indexOf(token);
372+
index >= 0;
373+
index = normText.indexOf(token, index + 1)) {
374+
ranges.push({start: index, end: index + token.length});
375+
}
376+
}
377+
if (ranges.length === 0) {
378+
return [{text, highlighted: false}];
379+
}
380+
381+
// Order them in the order they appear in the text (as it is they're ordered
382+
// first by the order of the tokens in searchTokens, and then by the
383+
// position in the text).
384+
ranges.sort((a, b) => a.start - b.start);
385+
386+
let coalesced = [ranges[0]];
387+
for (let i = 1; i < ranges.length; ++i) {
388+
let prev = coalesced[coalesced.length - 1];
389+
let curr = ranges[i];
390+
if (curr.start < prev.end) {
391+
// Overlap, update prev, but don't add curr.
392+
if (curr.end > prev.end) {
393+
prev.end = curr.end;
394+
}
395+
} else {
396+
coalesced.push(curr);
397+
}
398+
}
399+
400+
let result = [];
401+
let pos = 0;
402+
for (let range of coalesced) {
403+
if (pos < range.start) {
404+
result.push({text: text.slice(pos, range.start), highlight: false});
405+
}
406+
result.push({text: text.slice(range.start, range.end), highlight: true});
407+
pos = range.end;
408+
}
409+
if (pos < text.length) {
410+
result.push({text: text.slice(pos), highlight: false});
411+
}
412+
413+
return result;
414+
}
415+
285416
module.exports = SideTab;

src/tabcenter.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,14 @@ body[platform="mac"] #searchbox.focused {
304304
border-radius: 0 var(--border-radius) var(--border-radius) 0;
305305
}
306306

307+
.search-result-container {
308+
display: inline;
309+
}
310+
311+
.search-highlight {
312+
font-weight: bold;
313+
}
314+
307315
body[platform="mac"] #searchbox-input {
308316
font-size: 12px;
309317
}

src/tablist.js

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -424,14 +424,17 @@ SideTabList.prototype = {
424424
this.filter();
425425
},
426426
filter(query = "") {
427-
this._filterActive = query !== "";
428-
query = normalizeStr(query);
427+
// Remove whitespace and split on spaces.
428+
// filter(Boolean) to handle the case where the query is entirely
429+
// whitespace.
430+
let queryTokens = query.trim().split(/\s+/).filter(Boolean);
431+
this._filterActive = queryTokens.length > 0;
429432
let notShown = 0;
430433
for (let tab of this.tabs.values()) {
431-
const show = normalizeStr(tab.url).includes(query) ||
432-
normalizeStr(tab.title).includes(query);
434+
const show = tab.matches(queryTokens);
433435
notShown += !show ? 1 : 0;
434436
tab.updateVisibility(show);
437+
tab.highlightMatches(queryTokens, show);
435438
}
436439
if (notShown > 0) {
437440
// Sadly browser.i18n doesn't support plurals, which is why we
@@ -723,9 +726,4 @@ SideTabList.prototype = {
723726
}
724727
};
725728

726-
// Remove case and accents/diacritics.
727-
function normalizeStr(str) {
728-
return str ? str.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "") : "";
729-
}
730-
731729
module.exports = SideTabList;

0 commit comments

Comments
 (0)