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

Commit

Permalink
Tokenize search results and highlight the matches in the sidebar
Browse files Browse the repository at this point in the history
  • Loading branch information
Thom Chiovoloni committed Feb 11, 2018
1 parent e0c87c4 commit 3e1f44b
Show file tree
Hide file tree
Showing 3 changed files with 152 additions and 14 deletions.
142 changes: 137 additions & 5 deletions src/tab.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,13 @@ SideTab.prototype = {
const titleWrapper = document.createElement("div");
titleWrapper.className = "tab-title-wrapper";

const title = document.createElement("span");
title.className = "tab-title";
const title = document.createElement("div");
title.className = "tab-title search-highlight-container";
titleWrapper.appendChild(title);
this._titleView = title;

const host = document.createElement("span");
host.className = "tab-host";
const host = document.createElement("div");
host.className = "tab-host search-highlight-container";
titleWrapper.appendChild(host);
this._hostView = host;

Expand All @@ -94,13 +94,60 @@ SideTab.prototype = {
tab.appendChild(pin);
tab.appendChild(close);
},
matches(tokens) {
if (tokens.length === 0) {
return true;
}
let title = normalizeStr(this.title);
let url = normalizeStr(this.url);
for (let token of tokens) {
token = normalizeStr(token);
if (title.includes(token)) {
return true;
}
if (url.includes(token)) {
return true;
}
}
return false;
},
_highlightSearchResults(node, text, searchTokens) {
let ranges = findHighlightedRanges(text, searchTokens);

// Clear out the node before we fill it with new stuff.
while (node.firstChild) {
node.removeChild(node.firstChild);
}

for (let {text, highlight} of ranges) {
if (highlight) {
let span = document.createElement("span");
span.className = "search-highlight";
span.textContent = text;
node.appendChild(span);
} else {
node.appendChild(document.createTextNode(text));
}
}
},
highlightMatches(tokens) {
if (!this.visible) {
// Reset these to the 'no matches' state (Not calling
// _highlightSearchResult is just an optimization).
this.updateTitle(this.title);
this.updateURL(this.url);
} else {
this._highlightSearchResults(this._titleView, this.title, tokens);
this._highlightSearchResults(this._hostView, getHost(this.url), tokens);
}
},
updateTitle(title) {
this.title = title;
this._titleView.innerText = title;
this.view.title = title;
},
updateURL(url) {
const host = new URL(url).host || url;
const host = getHost(url);
this.url = url;
this._hostView.innerText = host;
},
Expand Down Expand Up @@ -282,4 +329,89 @@ function toggleClass(node, className, boolean) {
boolean ? node.classList.add(className) : node.classList.remove(className);
}

// Remove case and accents/diacritics.
function normalizeStr(str) {
return str ? str.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "") : "";
}

function getHost(url) {
return new URL(url).host || url;
}

// This function takes as input a text string and an array of "search tokens"
// and returns what we should render in an abstract sense. e.g. an array of
// `{text: string, highlighted: bool}`, such that `result.map(r =>
// r.text).join('')` should equal what was provided as the first argument, and
// that the sections with `highlighted: true` correspond to ranges that match
// the members of searchTokens.
//
// (It's complex enough to arguably warrant unit tests, but oh well, it's split
// out so that I could more easily test it manually).
function findHighlightedRanges(text, searchTokens) {
// Trivial case
if (searchTokens.length === 0) {
return [{text, highlighted: false}];
}
// Potentially surprisingly, changing case doesn't preserve length. If we
// can't do this without messing up the indices in the given text, we fail.
// This function is just for highlighting the matching parts in searches in
// the UI, so it's not a big deal if it doesn't highlight something.
let canLowercaseText = text.toLowerCase().length === text.length &&
searchTokens.every(t =>
t.toLowerCase().length === t.length);
let normalize = s => canLowercaseText ? s.toLowerCase() : s;
let normText = normalize(text);

// Build an array of the start/end indices of each result.
let ranges = [];
for (let token of searchTokens) {
token = normalize(token);
if (!token.length) {
continue;
}
for (let index = normText.indexOf(token);
index >= 0;
index = normText.indexOf(token, index + 1)) {
ranges.push({start: index, end: index + token.length});
}
}
if (ranges.length === 0) {
return [{text, highlighted: false}];
}

// Order them in the order they appear in the text (as it is they're ordered
// first by the order of the tokens in searchTokens, and then by the
// position in the text).
ranges.sort((a, b) => a.start - b.start);

let coalesced = [ranges[0]];
for (let i = 1; i < ranges.length; ++i) {
let prev = coalesced[coalesced.length - 1];
let curr = ranges[i];
if (curr.start < prev.end) {
// Overlap, update prev, but don't add curr.
if (curr.end > prev.end) {
prev.end = curr.end;
}
} else {
coalesced.push(curr);
}
}

let result = [];
let pos = 0;
for (let range of coalesced) {
if (pos < range.start) {
result.push({text: text.slice(pos, range.start), highlight: false});
}
result.push({text: text.slice(range.start, range.end), highlight: true});
pos = range.end;
}
if (pos < text.length) {
result.push({text: text.slice(pos), highlight: false});
}

return result;
}

module.exports = SideTab;
8 changes: 8 additions & 0 deletions src/tabcenter.css
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,14 @@ body[platform="mac"] #searchbox.focused {
border-radius: 0 var(--border-radius) var(--border-radius) 0;
}

.search-result-container {
display: inline;
}

.search-highlight {
font-weight: bold;
}

body[platform="mac"] #searchbox-input {
font-size: 12px;
}
Expand Down
16 changes: 7 additions & 9 deletions src/tablist.js
Original file line number Diff line number Diff line change
Expand Up @@ -424,14 +424,17 @@ SideTabList.prototype = {
this.filter();
},
filter(query = "") {
this._filterActive = query !== "";
query = normalizeStr(query);
// Remove whitespace and split on spaces.
// filter(Boolean) to handle the case where the query is entirely
// whitespace.
let queryTokens = query.trim().split(/\s+/).filter(Boolean);
this._filterActive = queryTokens.length > 0;
let notShown = 0;
for (let tab of this.tabs.values()) {
const show = normalizeStr(tab.url).includes(query) ||
normalizeStr(tab.title).includes(query);
const show = tab.matches(queryTokens);
notShown += !show ? 1 : 0;
tab.updateVisibility(show);
tab.highlightMatches(queryTokens, show);
}
if (notShown > 0) {
// Sadly browser.i18n doesn't support plurals, which is why we
Expand Down Expand Up @@ -723,9 +726,4 @@ SideTabList.prototype = {
}
};

// Remove case and accents/diacritics.
function normalizeStr(str) {
return str ? str.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "") : "";
}

module.exports = SideTabList;

0 comments on commit 3e1f44b

Please sign in to comment.