Commit c1400ed1 authored by Yuheng Huang's avatar Yuheng Huang Committed by Josip Sokcevic

Implement fuzzy search highlight

Bug: 1099917
Change-Id: Ia2fcf03cae03c05ed9229812f1740488a2441ec9
Reviewed-on: https://chrome-internal-review.googlesource.com/c/chrome/browser/resources/tab_search/+/3269809Reviewed-by: default avatarDemetrios Papadopoulos <dpapad@chromium.org>
Reviewed-by: default avatarTom Lukaszewicz <tluk@chromium.org>
Cr-Commit-Position: refs/heads/master@{#819608}
parent ae2ba3b9
...@@ -7,6 +7,7 @@ import Fuse from './fuse.js'; ...@@ -7,6 +7,7 @@ import Fuse from './fuse.js';
const OPTIONS = { const OPTIONS = {
includeScore: true, includeScore: true,
ignoreLocation: true, ignoreLocation: true,
includeMatches: true,
keys: [ keys: [
{ {
name: 'title', name: 'title',
...@@ -21,12 +22,35 @@ const OPTIONS = { ...@@ -21,12 +22,35 @@ const OPTIONS = {
/** /**
* @param {string} input * @param {string} input
* @param {!Array<tabSearch.mojom.Tab>} records * @param {!Array<!tabSearch.mojom.Tab>} records
* @return {!Array<tabSearch.mojom.Tab>} * @return {!Array<!tabSearch.mojom.Tab>}
*/ */
export function fuzzySearch(input, records) { export function fuzzySearch(input, records) {
if (input.length === 0) { if (input.length === 0) {
return records; return records;
} }
return new Fuse(records, OPTIONS).search(input).map(_ => _.item); return new Fuse(records, OPTIONS).search(input).map(result => {
const titleMatch = result.matches.find(e => e.key === 'title');
const hostnameMatch = result.matches.find(e => e.key === 'hostname');
const item = Object.assign({}, result.item);
if (titleMatch) {
item.titleHighlightRanges = convertToRanges(titleMatch.indices);
}
if (hostnameMatch) {
item.hostnameHighlightRanges = convertToRanges(hostnameMatch.indices);
}
return item;
});
}
/**
* Convert fuse.js matches [start1, end1], [start2, end2] ... to
* ranges {start:start1, length:length1}, {start:start2, length:length2} ...
* to be used by search_highlight_utils.js
* @param {!Array<!Array<number>>} matches
* @return {!Array<!{start: number, length: number}>}
*/
function convertToRanges(matches) {
return matches.map(
([start, end]) => ({start: start, length: end - start + 1}));
} }
...@@ -31,21 +31,21 @@ ...@@ -31,21 +31,21 @@
overflow: hidden; overflow: hidden;
} }
.primary-text, #primaryText,
.secondary-text { #secondaryText {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
user-select: none; user-select: none;
white-space: nowrap; white-space: nowrap;
} }
.primary-text { #primaryText {
color: var(--cr-primary-text-color); color: var(--cr-primary-text-color);
font-size: var(--mwb-primary-text-font-size); font-size: var(--mwb-primary-text-font-size);
margin-bottom: 3px; margin-bottom: 3px;
} }
.secondary-text { #secondaryText {
color: var(--cr-secondary-text-color); color: var(--cr-secondary-text-color);
font-size: var(--mwb-secondary-text-font-size); font-size: var(--mwb-secondary-text-font-size);
} }
...@@ -55,12 +55,18 @@ ...@@ -55,12 +55,18 @@
--cr-icon-button-margin-start: calc(var(--mwb-icon-size) / 4); --cr-icon-button-margin-start: calc(var(--mwb-icon-size) / 4);
--cr-icon-button-size: var(--mwb-icon-size); --cr-icon-button-size: var(--mwb-icon-size);
} }
.search-highlight-hit {
font-weight: bold;
}
</style> </style>
<div class="favicon" style="background-image:[[faviconUrl_(data.isDefaultFavicon, data.url)]]"></div> <div class="favicon"
style="background-image:[[faviconUrl_(data.isDefaultFavicon, data.url)]]">
</div>
<div class="text-container"> <div class="text-container">
<div class="primary-text">[[data.title]]</div> <div id="primaryText"></div>
<div class="secondary-text">[[urlHostname_(data.url)]]</div> <div id="secondaryText"></div>
</div> </div>
<div class="button-container"> <div class="button-container">
<cr-icon-button id="closeButton" aria-label="[[ariaLabel_(data.title)]]" <cr-icon-button id="closeButton" aria-label="[[ariaLabel_(data.title)]]"
......
...@@ -10,6 +10,7 @@ import 'chrome://resources/cr_elements/shared_vars_css.m.js'; ...@@ -10,6 +10,7 @@ import 'chrome://resources/cr_elements/shared_vars_css.m.js';
import {getFaviconForPageURL} from 'chrome://resources/js/icon.m.js'; import {getFaviconForPageURL} from 'chrome://resources/js/icon.m.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.m.js'; import {loadTimeData} from 'chrome://resources/js/load_time_data.m.js';
import {highlight} from 'chrome://resources/js/search_highlight_utils.m.js';
import {html, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js'; import {html, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import './strings.js'; import './strings.js';
...@@ -26,7 +27,10 @@ export class TabSearchItem extends PolymerElement { ...@@ -26,7 +27,10 @@ export class TabSearchItem extends PolymerElement {
static get properties() { static get properties() {
return { return {
/** @type {!tabSearch.mojom.Tab} */ /** @type {!tabSearch.mojom.Tab} */
data: Object, data: {
type: Object,
observer: 'setHighlights_',
},
}; };
} }
...@@ -40,22 +44,49 @@ export class TabSearchItem extends PolymerElement { ...@@ -40,22 +44,49 @@ export class TabSearchItem extends PolymerElement {
} }
/** /**
* @param {boolean} isDefaultFavicon
* @param {string} url * @param {string} url
* @return {string} * @return {string}
* @private * @private
*/ */
urlHostname_(url) { faviconUrl_(isDefaultFavicon, url) {
return new URL(url).hostname; return getFaviconForPageURL(
isDefaultFavicon ? 'chrome://newtab' : url, false);
} }
/** /**
* @param isDefaultFavicon {boolean} * @suppress {checkTypes}
* @param url {string} * Suppress checking types because hostname, titleHighlightRanges and
* @return {string} * hostnameHighlightRanges are added on the flight and not part of the
* original mojom.tabSearch.Tab definition
* @private * @private
*/ */
faviconUrl_(isDefaultFavicon, url) { setHighlights_() {
return getFaviconForPageURL(isDefaultFavicon ? undefined : url, false); this.highlightText_(
/** @type {!HTMLElement} */ (this.$.primaryText), this.data.title,
this.data.titleHighlightRanges);
this.highlightText_(
/** @type {!HTMLElement} */ (this.$.secondaryText), this.data.hostname,
this.data.hostnameHighlightRanges);
}
/**
*
* @param {!HTMLElement} container
* @param {string} text
* @param {?Array<!{start:number, length:number}>} ranges
*/
highlightText_(container, text, ranges) {
container.textContent = '';
const node = document.createTextNode(text);
container.appendChild(node);
if (ranges) {
const result = highlight(node, ranges, true);
// Delete default highlight style.
result.querySelectorAll('.search-highlight-hit').forEach(e => {
e.style = '';
});
}
} }
ariaLabel_(title) { ariaLabel_(title) {
......
...@@ -15,6 +15,7 @@ js_type_check("closure_compile") { ...@@ -15,6 +15,7 @@ js_type_check("closure_compile") {
":fuzzy_search_test", ":fuzzy_search_test",
":tab_search_app_focus_test", ":tab_search_app_focus_test",
":tab_search_app_test", ":tab_search_app_test",
":tab_search_item_test",
] ]
} }
...@@ -51,3 +52,11 @@ js_library("test_tab_search_api_proxy") { ...@@ -51,3 +52,11 @@ js_library("test_tab_search_api_proxy") {
"//chrome/test/data/webui:test_browser_proxy.m", "//chrome/test/data/webui:test_browser_proxy.m",
] ]
} }
js_library("tab_search_item_test") {
deps = [
"../..:chai_assert",
"//chrome/browser/resources/tab_search:tab_search_item",
]
externs_list = [ "$externs_path/mocha-2.5.js" ]
}
...@@ -11,19 +11,31 @@ suite('FuzzySearchTest', () => { ...@@ -11,19 +11,31 @@ suite('FuzzySearchTest', () => {
const records = [ const records = [
{ {
title: 'OpenGL', title: 'OpenGL',
hostname: 'https://www.opengl.org', hostname: 'www.opengl.org',
}, },
{ {
title: 'Google', title: 'Google',
hostname: 'https://www.google.com', hostname: 'www.google.com',
}, },
]; ];
assertDeepEquals(
[ const matchedRecords = [
records[1], {
records[0], title: 'Google',
hostname: 'www.google.com',
titleHighlightRanges: [{start: 0, length: 1}, {start: 3, length: 3}],
hostnameHighlightRanges: [{start: 4, length: 1}, {start: 7, length: 3}]
},
{
title: 'OpenGL',
hostname: 'www.opengl.org',
titleHighlightRanges: [{start: 2, length: 1}, {start: 4, length: 2}],
hostnameHighlightRanges: [
{start: 6, length: 1}, {start: 8, length: 2}, {start: 13, length: 1}
], ],
fuzzySearch('gle', records)); },
]
assertDeepEquals(matchedRecords, fuzzySearch('gle', records));
assertDeepEquals(records, fuzzySearch('', records)); assertDeepEquals(records, fuzzySearch('', records));
assertDeepEquals([], fuzzySearch('z', records)); assertDeepEquals([], fuzzySearch('z', records));
}); });
......
...@@ -55,3 +55,15 @@ var FuzzySearchTest = class extends TabSearchBrowserTest { ...@@ -55,3 +55,15 @@ var FuzzySearchTest = class extends TabSearchBrowserTest {
TEST_F('FuzzySearchTest', 'All', function() { TEST_F('FuzzySearchTest', 'All', function() {
mocha.run(); mocha.run();
}); });
// eslint-disable-next-line no-var
var TabSearchItemTest = class extends TabSearchBrowserTest {
/** @override */
get browsePreload() {
return 'chrome://tab-search/test_loader.html?module=tab_search/test/tab_search_item_test.js';
}
};
TEST_F('TabSearchItemTest', 'All', function() {
mocha.run();
});
\ No newline at end of file
// Copyright 2020 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {TabSearchItem} from 'chrome://tab-search/tab_search_item.js';
import {assertDeepEquals} from '../../chai_assert.js';
import {flushTasks} from '../../test_util.m.js';
suite('TabSearchItemTest', () => {
/**
* @param {string} text
* @param {?Array<{start:number, length:number}>} highlightRanges
* @param {!Array<string>} expected
*/
async function assertTabSearchItemHighlights(
text, highlightRanges, expected) {
const tabSearchItem = /** @type {!TabSearchItem} */ (
document.createElement('tab-search-item'));
const data = {
active: true,
index: 0,
isDefaultFavicon: true,
lastActiveTimeTicks: {internalValue: 0},
pinned: false,
showIcon: true,
tabId: 0,
url: '',
title: text,
titleHighlightRanges: highlightRanges,
hostname: text,
hostnameHighlightRanges: highlightRanges,
};
tabSearchItem.data = data;
document.body.innerHTML = '';
document.body.appendChild(tabSearchItem);
await flushTasks();
assertHighlight(
/** @type {!HTMLElement} */ (tabSearchItem.$['primaryText']), expected);
assertHighlight(
/** @type {!HTMLElement} */ (tabSearchItem.$['secondaryText']),
expected);
}
/**
* @param {!HTMLElement} node
* @param {!Array<string>} expected
*/
function assertHighlight(node, expected) {
assertDeepEquals(
expected,
[].slice.call(node.querySelectorAll('.search-highlight-hit'))
.map(e => e ? e.textContent : ''));
}
test('highlight', async () => {
const text = 'Make work better';
await assertTabSearchItemHighlights(text, null, []);
await assertTabSearchItemHighlights(
text, [{start: 0, length: text.length}], ['Make work better']);
await assertTabSearchItemHighlights(
text, [{start: 0, length: 4}], ['Make']);
await assertTabSearchItemHighlights(
text, [{start: 0, length: 4}, {start: 10, length: 6}],
['Make', 'better']);
await assertTabSearchItemHighlights(
text, [{start: 5, length: 4}], ['work']);
});
});
\ No newline at end of file
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment