Bug 1511529 - Add substring matching/highlighting to UrlbarMatch and support it in UrlbarView. r=mak

Differential Revision: https://phabricator.services.mozilla.com/D13717

--HG--
extra : moz-landing-system : lando
This commit is contained in:
Drew Willcoxon 2018-12-11 19:37:19 +00:00
parent 69c1294a3a
commit f91504311e
6 changed files with 216 additions and 54 deletions

View File

@ -30,8 +30,13 @@ class UrlbarMatch {
* @param {object} payload data for this match. A payload should always
* contain a way to extract a final url to visit. The url getter
* should have a case for each of the types.
* @param {object} [payloadHighlights] payload highlights, if any. Each
* property in the payload may have a corresponding property in this
* object. The value of each property should be an array of [index,
* length] tuples. Each tuple indicates a substring in the correspoding
* payload property.
*/
constructor(matchType, matchSource, payload) {
constructor(matchType, matchSource, payload, payloadHighlights = {}) {
// Type describes the payload and visualization that should be used for
// this match.
if (!Object.values(UrlbarUtils.MATCH_TYPE).includes(matchType)) {
@ -52,6 +57,20 @@ class UrlbarMatch {
throw new Error("Invalid match payload");
}
this.payload = payload;
if (!payloadHighlights || (typeof payloadHighlights != "object")) {
throw new Error("Invalid match payload highlights");
}
this.payloadHighlights = payloadHighlights;
// Make sure every property in the payload has an array of highlights. If a
// payload property does not have a highlights array, then give it one now.
// That way the consumer doesn't need to check whether it exists.
for (let name in payload) {
if (!(name in this.payloadHighlights)) {
this.payloadHighlights[name] = [];
}
}
}
/**
@ -59,16 +78,34 @@ class UrlbarMatch {
* @returns {string} The label to show in a simplified title / url view.
*/
get title() {
return this._titleAndHighlights[0];
}
/**
* Returns an array of highlights for the title.
* @returns {array} The array of highlights.
*/
get titleHighlights() {
return this._titleAndHighlights[1];
}
/**
* Returns an array [title, highlights].
* @returns {array} The title and array of highlights.
*/
get _titleAndHighlights() {
switch (this.type) {
case UrlbarUtils.MATCH_TYPE.TAB_SWITCH:
case UrlbarUtils.MATCH_TYPE.URL:
case UrlbarUtils.MATCH_TYPE.OMNIBOX:
case UrlbarUtils.MATCH_TYPE.REMOTE_TAB:
return this.payload.title || "";
return this.payload.title ?
[this.payload.title, this.payloadHighlights.title] :
["", []];
case UrlbarUtils.MATCH_TYPE.SEARCH:
return this.payload.engine;
return [this.payload.engine, this.payloadHighlights.engine];
default:
return "";
return ["", []];
}
}
@ -79,4 +116,40 @@ class UrlbarMatch {
get icon() {
return this.payload.icon;
}
/**
* A convenience function that takes a payload annotated with should-highlight
* bools and returns the payload and the payload's highlights. Use this
* function when the highlighting required by your payload is based on simple
* substring matching, as done by UrlbarUtils.getTokenMatches(). Pass the
* return values as the `payload` and `payloadHighlights` params of the
* UrlbarMatch constructor.
*
* @param {array} tokens The tokens that should be highlighted in each of the
* payload properties.
* @param {object} payloadInfo An object that looks like this:
* {
* payloadPropertyName: [payloadPropertyValue, shouldHighlight],
* ...
* }
* @returns {array} An array [payload, payloadHighlights].
*/
static payloadAndSimpleHighlights(tokens, payloadInfo) {
let entries = Object.entries(payloadInfo);
return [
entries.reduce((payload, [name, [val, _]]) => {
payload[name] = val;
return payload;
}, {}),
entries.reduce((highlights, [name, [val, shouldHighlight]]) => {
if (shouldHighlight) {
highlights[name] =
!Array.isArray(val) ?
UrlbarUtils.getTokenMatches(tokens, val || "") :
val.map(subval => UrlbarUtils.getTokenMatches(tokens, subval));
}
return highlights;
}, {}),
];
}
}

View File

@ -160,11 +160,17 @@ class ProviderOpenTabs {
cancel();
return;
}
addCallback(this, new UrlbarMatch(UrlbarUtils.MATCH_TYPE.TAB_SWITCH,
UrlbarUtils.MATCH_SOURCE.TABS, {
url: row.getResultByName("url"),
userContextId: row.getResultByName("userContextId"),
}));
addCallback(
this,
new UrlbarMatch(
UrlbarUtils.MATCH_TYPE.TAB_SWITCH,
UrlbarUtils.MATCH_SOURCE.TABS,
...UrlbarMatch.payloadAndSimpleHighlights(queryContext.tokens, {
url: [row.getResultByName("url"), true],
userContextId: [row.getResultByName("userContextId"), false],
})
)
);
});
// We are done.
this.queries.delete(queryContext);

View File

@ -167,7 +167,7 @@ function convertResultToMatches(context, result, urls) {
urls.add(url);
// Not used yet: result.getValueAt(i), result.getLabelAt(i)
let style = result.getStyleAt(i);
let match = makeUrlbarMatch({
let match = makeUrlbarMatch(context.tokens, {
url,
icon: result.getImageAt(i),
style,
@ -194,10 +194,11 @@ function convertResultToMatches(context, result, urls) {
/**
* Creates a new UrlbarMatch from the provided data.
* @param {array} tokens the search tokens.
* @param {object} info includes properties from the legacy match.
* @returns {object} an UrlbarMatch
*/
function makeUrlbarMatch(info) {
function makeUrlbarMatch(tokens, info) {
let action = PlacesUtils.parseActionUrl(info.url);
if (action) {
switch (action.type) {
@ -205,56 +206,56 @@ function makeUrlbarMatch(info) {
return new UrlbarMatch(
UrlbarUtils.MATCH_TYPE.SEARCH,
UrlbarUtils.MATCH_SOURCE.SEARCH,
{
engine: action.params.engineName,
suggestion: action.params.searchSuggestion,
keyword: action.params.alias,
query: action.params.searchQuery,
icon: info.icon,
}
...UrlbarMatch.payloadAndSimpleHighlights(tokens, {
engine: [action.params.engineName, true],
suggestion: [action.params.searchSuggestion, true],
keyword: [action.params.alias, true],
query: [action.params.searchQuery, true],
icon: [info.icon, false],
})
);
case "keyword":
return new UrlbarMatch(
UrlbarUtils.MATCH_TYPE.KEYWORD,
UrlbarUtils.MATCH_SOURCE.BOOKMARKS,
{
url: action.params.url,
keyword: info.firstToken,
postData: action.params.postData,
icon: info.icon,
}
...UrlbarMatch.payloadAndSimpleHighlights(tokens, {
url: [action.params.url, true],
keyword: [info.firstToken, true],
postData: [action.params.postData, false],
icon: [info.icon, false],
})
);
case "extension":
return new UrlbarMatch(
UrlbarUtils.MATCH_TYPE.OMNIBOX,
UrlbarUtils.MATCH_SOURCE.OTHER_NETWORK,
{
title: info.comment,
content: action.params.content,
keyword: action.params.keyword,
icon: info.icon,
}
...UrlbarMatch.payloadAndSimpleHighlights(tokens, {
title: [info.comment, true],
content: [action.params.content, true],
keyword: [action.params.keyword, true],
icon: [info.icon, false],
})
);
case "remotetab":
return new UrlbarMatch(
UrlbarUtils.MATCH_TYPE.REMOTE_TAB,
UrlbarUtils.MATCH_SOURCE.TABS,
{
url: action.params.url,
title: info.comment,
device: action.params.deviceName,
icon: info.icon,
}
...UrlbarMatch.payloadAndSimpleHighlights(tokens, {
url: [action.params.url, true],
title: [info.comment, true],
device: [action.params.deviceName, true],
icon: [info.icon, false],
})
);
case "visiturl":
return new UrlbarMatch(
UrlbarUtils.MATCH_TYPE.URL,
UrlbarUtils.MATCH_SOURCE.OTHER_LOCAL,
{
title: info.comment,
url: action.params.url,
icon: info.icon,
}
...UrlbarMatch.payloadAndSimpleHighlights(tokens, {
title: [info.comment, true],
url: [action.params.url, true],
icon: [info.icon, false],
})
);
default:
Cu.reportError("Unexpected action type");
@ -266,10 +267,10 @@ function makeUrlbarMatch(info) {
return new UrlbarMatch(
UrlbarUtils.MATCH_TYPE.SEARCH,
UrlbarUtils.MATCH_SOURCE.SEARCH,
{
engine: info.comment,
icon: info.icon,
}
...UrlbarMatch.payloadAndSimpleHighlights(tokens, {
engine: [info.comment, true],
icon: [info.icon, false],
})
);
}
@ -291,11 +292,11 @@ function makeUrlbarMatch(info) {
return new UrlbarMatch(
UrlbarUtils.MATCH_TYPE.URL,
source,
{
url: info.url,
icon: info.icon,
title: comment,
tags,
}
...UrlbarMatch.payloadAndSimpleHighlights(tokens, {
url: [info.url, true],
icon: [info.icon, false],
title: [comment, true],
tags: [tags, true],
})
);
}

View File

@ -14,6 +14,7 @@ var EXPORTED_SYMBOLS = ["UrlbarUtils"];
ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
XPCOMUtils.defineLazyModuleGetters(this, {
BinarySearch: "resource://gre/modules/BinarySearch.jsm",
BrowserUtils: "resource://gre/modules/BrowserUtils.jsm",
PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm",
PlacesUIUtils: "resource:///modules/PlacesUIUtils.jsm",
@ -190,4 +191,37 @@ var UrlbarUtils = {
mimeStream.setData(dataStream);
return mimeStream.QueryInterface(Ci.nsIInputStream);
},
/**
* Returns a list of all the token substring matches in a string. Each match
* in the list is a tuple: [matchIndex, matchLength]. matchIndex is the index
* in the string of the match, and matchLength is the length of the match.
*
* @param {array} tokens The tokens to search for.
* @param {string} str The string to match against.
* @returns {array} An array: [
* [matchIndex_0, matchLength_0],
* [matchIndex_1, matchLength_1],
* ...
* [matchIndex_n, matchLength_n]
* ].
* The array is sorted by match indexes ascending.
*/
getTokenMatches(tokens, str) {
return tokens.reduce((matches, token) => {
let index = 0;
while (index >= 0) {
index = str.indexOf(token.value, index);
if (index >= 0) {
let match = [index, token.value.length];
let matchesIndex = BinarySearch.insertionIndexOf((a, b) => {
return a[0] - b[0];
}, matches, match);
matches.splice(matchesIndex, 0, match);
index += token.value.length;
}
}
return matches;
}, []);
},
};

View File

@ -215,23 +215,66 @@ class UrlbarView {
let title = this._createElement("span");
title.className = "urlbarView-title";
title.textContent = result.title || result.payload.url;
this._addTextContentWithHighlights(
title,
...(result.title ?
[result.title, result.titleHighlights] :
[result.payload.url || "", result.payloadHighlights.url || []])
);
content.appendChild(title);
let secondary = this._createElement("span");
secondary.className = "urlbarView-secondary";
if (result.type == UrlbarUtils.MATCH_TYPE.TAB_SWITCH) {
secondary.classList.add("urlbarView-action");
secondary.textContent = "Switch to Tab";
this._addTextContentWithHighlights(secondary, "Switch to Tab", []);
} else {
secondary.classList.add("urlbarView-url");
secondary.textContent = result.payload.url;
this._addTextContentWithHighlights(secondary, result.payload.url || "",
result.payloadHighlights.url || []);
}
content.appendChild(secondary);
this._rows.appendChild(item);
}
/**
* Adds text content to a node, placing substrings that should be highlighted
* inside <em> nodes.
*
* @param {Node} parentNode
* The text content will be added to this node.
* @param {string} textContent
* The text content to give the node.
* @param {array} highlights
* The matches to highlight in the text.
*/
_addTextContentWithHighlights(parentNode, textContent, highlights) {
if (!textContent) {
return;
}
highlights = (highlights || []).concat([[textContent.length, 0]]);
let index = 0;
for (let [highlightIndex, highlightLength] of highlights) {
if (highlightIndex - index > 0) {
parentNode.appendChild(
this.document.createTextNode(
textContent.substring(index, highlightIndex)
)
);
}
if (highlightLength > 0) {
let strong = this._createElement("strong");
strong.textContent = textContent.substring(
highlightIndex,
highlightIndex + highlightLength
);
parentNode.appendChild(strong);
}
index = highlightIndex + highlightLength;
}
}
/**
* Passes DOM events for the view to the _on_<event type> methods.
* @param {Event} event

View File

@ -109,6 +109,11 @@
color: var(--urlbar-popup-url-color);
}
.urlbarView-title > strong,
.urlbarView-url > strong {
font-weight: 600;
}
.urlbarView-row[selected] > .urlbarView-row-inner > .urlbarView-title::after,
.urlbarView-row[selected] > .urlbarView-row-inner > .urlbarView-secondary {
color: inherit;