blob: 0f1f5824545f027e1a874c6d820251c72419f32c [file] [log] [blame]
// Copyright 2016 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.
// clang-format off
import {assert} from 'chrome://resources/js/assert.m.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.m.js';
import {PromiseResolver} from 'chrome://resources/js/promise_resolver.m.js';
import {createEmptySearchBubble, findAndRemoveHighlights, highlight, removeHighlights, stripDiacritics} from 'chrome://resources/js/search_highlight_utils.js';
import {findAncestor} from 'chrome://resources/js/util.m.js';
import {DomIf} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
// clang-format on
/**
* A data structure used by callers to combine the results of multiple search
* requests.
*
* @typedef {{
* canceled: Boolean,
* didFindMatches: Boolean,
* wasClearSearch: Boolean,
* }}
*/
export let SearchResult;
/**
* A CSS attribute indicating that a node should be ignored during searching.
* @type {string}
*/
const SKIP_SEARCH_CSS_ATTRIBUTE = 'no-search';
/**
* List of elements types that should not be searched at all.
* The only DOM-MODULE node is in <body> which is not searched, therefore
* DOM-MODULE is not needed in this set.
* @type {!Set<string>}
*/
const IGNORED_ELEMENTS = new Set([
'CONTENT',
'CR-ACTION-MENU',
'CR-DIALOG',
'CR-ICON-BUTTON',
'CR-SLIDER',
'DIALOG',
'IMG',
'IRON-ICON',
'IRON-LIST',
'PAPER-RIPPLE',
'PAPER-SPINNER-LITE',
'SLOT',
'STYLE',
'TEMPLATE',
]);
/**
* Traverses the entire DOM (including Shadow DOM), finds text nodes that
* match the given regular expression and applies the highlight UI. It also
* ensures that <settings-section> instances become visible if any matches
* occurred under their subtree.
*
* @param {!SearchRequest} request
* @param {!Node} root The root of the sub-tree to be searched
* @return {boolean} Whether or not matches were found.
* @private
*/
function findAndHighlightMatches_(request, root) {
let foundMatches = false;
const highlights = [];
function doSearch(node) {
// NOTE: For subpage wrappers <template route-path="..."> when |no-search|
// participates in a data binding:
//
// - Always use noSearch Polymer property, for example
// no-search="[[foo]]"
// - *Don't* use a no-search CSS attribute like no-search$="[[foo]]"
//
// The latter throws an error during the automatic Polymer 2 conversion to
// <dom-if><template...></dom-if> syntax.
if (node.nodeName === 'DOM-IF' && node.hasAttribute('route-path') &&
!node.if && !node['noSearch'] &&
!node.hasAttribute(SKIP_SEARCH_CSS_ATTRIBUTE)) {
request.queue_.addRenderTask(new RenderTask(request, node));
return;
}
if (IGNORED_ELEMENTS.has(node.nodeName)) {
return;
}
if (node instanceof HTMLElement) {
const element = /** @type {HTMLElement} */ (node);
if (element.hasAttribute(SKIP_SEARCH_CSS_ATTRIBUTE) ||
element.hasAttribute('hidden') ||
element.style.display === 'none') {
return;
}
}
if (node.nodeType === Node.TEXT_NODE) {
const textContent = node.nodeValue;
if (textContent.trim().length === 0) {
return;
}
const strippedText =
stripDiacritics(textContent);
const ranges = [];
for (let match; match = request.regExp.exec(strippedText);) {
ranges.push({start: match.index, length: match[0].length});
}
if (ranges.length > 0) {
foundMatches = true;
revealParentSection_(
node, /*numResults=*/ ranges.length, request.bubbles);
if (node.parentNode.nodeName === 'OPTION') {
const select = node.parentNode.parentNode;
assert(select.nodeName === 'SELECT');
// TODO(crbug.com/355446): support showing bubbles inside subpages.
// Currently, they're incorrectly positioned and there's no great
// signal at which to know when to reposition them (because every
// page asynchronously loads/renders things differently).
const isSubpage = n => n.nodeName === 'SETTINGS-SUBPAGE';
if (findAncestor(select, isSubpage, true)) {
return;
}
showBubble_(
select, /*numResults=*/ ranges.length, request.bubbles,
/*horizontallyCenter=*/ true);
} else {
request.addTextObserver(node);
highlights.push(highlight(node, ranges));
}
}
// Returning early since TEXT_NODE nodes never have children.
return;
}
let child = node.firstChild;
while (child !== null) {
// Getting a reference to the |nextSibling| before calling doSearch()
// because |child| could be removed from the DOM within doSearch().
const nextSibling = child.nextSibling;
doSearch(child);
child = nextSibling;
}
const shadowRoot = node.shadowRoot;
if (shadowRoot) {
doSearch(shadowRoot);
}
}
doSearch(root);
request.addHighlights(highlights);
return foundMatches;
}
/**
* Finds and makes visible the <settings-section> parent of |node|.
* @param {!Node} node
* @param {number} numResults
* @param {!Map<!Node, number>} bubbles A map of bubbles created so far.
* @private
*/
function revealParentSection_(node, numResults, bubbles) {
let associatedControl = null;
// Find corresponding SETTINGS-SECTION parent and make it visible.
let parent = node;
while (parent.nodeName !== 'SETTINGS-SECTION') {
parent = parent.nodeType === Node.DOCUMENT_FRAGMENT_NODE ?
parent.host :
parent.parentNode;
if (!parent) {
// |node| wasn't inside a SETTINGS-SECTION.
return;
}
if (parent.nodeName === 'SETTINGS-SUBPAGE') {
// TODO(dpapad): Cast to SettingsSubpageElement here.
associatedControl = assert(
parent.associatedControl,
'An associated control was expected for SETTINGS-SUBPAGE ' +
parent.pageTitle + ', but was not found.');
}
}
parent.hiddenBySearch = false;
// Need to add the search bubble after the parent SETTINGS-SECTION has
// become visible, otherwise |offsetWidth| returns zero.
if (associatedControl) {
showBubble_(
associatedControl, numResults, bubbles,
/* horizontallyCenter= */ false);
}
}
/**
* @param {!Node} control
* @param {number} numResults
* @param {!Map<!Node, number>} bubbles
* @param {boolean} horizontallyCenter
*/
function showBubble_(control, numResults, bubbles, horizontallyCenter) {
const bubble = createEmptySearchBubble(
control, horizontallyCenter);
const numHits = numResults + (bubbles.get(bubble) || 0);
bubbles.set(bubble, numHits);
const msgName =
numHits === 1 ? 'searchResultBubbleText' : 'searchResultsBubbleText';
bubble.firstChild.textContent = loadTimeData.getStringF(msgName, numHits);
}
/** @abstract */
class Task {
/**
* @param {!SearchRequest} request
* @param {!Node} node
*/
constructor(request, node) {
/** @protected {!SearchRequest} */
this.request = request;
/** @protected {!Node} */
this.node = node;
}
/**
* @abstract
* @return {!Promise}
*/
exec() {}
}
class RenderTask extends Task {
/**
* A task that takes a <template is="dom-if">...</template> node
* corresponding to a setting subpage and renders it. A
* SearchAndHighlightTask is posted for the newly rendered subtree, once
* rendering is done.
*
* @param {!SearchRequest} request
* @param {!Node} node
*/
constructor(request, node) {
super(request, node);
}
/** @override */
exec() {
const routePath = this.node.getAttribute('route-path');
const content =
/**
@type {!{_contentForTemplate:
function(!HTMLTemplateElement):!HTMLElement}}
*/
(DomIf)
._contentForTemplate(
/** @type {!HTMLTemplateElement} */ (
this.node.firstElementChild));
const subpageTemplate = content.querySelector('settings-subpage');
subpageTemplate.setAttribute('route-path', routePath);
assert(!this.node.if);
this.node.if = true;
return new Promise((resolve, reject) => {
const parent = this.node.parentNode;
parent.async(() => {
const renderedNode =
parent.querySelector('[route-path="' + routePath + '"]');
// Register a SearchAndHighlightTask for the part of the DOM that was
// just rendered.
this.request.queue_.addSearchAndHighlightTask(
new SearchAndHighlightTask(this.request, assert(renderedNode)));
resolve();
});
});
}
}
class SearchAndHighlightTask extends Task {
/**
* @param {!SearchRequest} request
* @param {!Node} node
*/
constructor(request, node) {
super(request, node);
}
/** @override */
exec() {
const foundMatches = findAndHighlightMatches_(this.request, this.node);
this.request.updateMatches(foundMatches);
return Promise.resolve();
}
}
class TopLevelSearchTask extends Task {
/**
* @param {!SearchRequest} request
* @param {!Node} page
*/
constructor(request, page) {
super(request, page);
}
/** @override */
exec() {
const shouldSearch = this.request.regExp !== null;
this.setSectionsVisibility_(!shouldSearch);
if (shouldSearch) {
const foundMatches = findAndHighlightMatches_(this.request, this.node);
this.request.updateMatches(foundMatches);
}
return Promise.resolve();
}
/**
* @param {boolean} visible
* @private
*/
setSectionsVisibility_(visible) {
const sections = this.node.querySelectorAll('settings-section');
for (let i = 0; i < sections.length; i++) {
sections[i].hiddenBySearch = !visible;
}
}
}
class TaskQueue {
/** @param {!SearchRequest} request */
constructor(request) {
/** @private {!SearchRequest} */
this.request_ = request;
/**
* @private {{
* high: !Array<!Task>,
* middle: !Array<!Task>,
* low: !Array<!Task>
* }}
*/
this.queues_;
this.reset();
/** @private {?Function} */
this.onEmptyCallback_ = null;
/**
* Whether a task is currently running.
* @private {boolean}
*/
this.running_ = false;
}
/** Drops all tasks. */
reset() {
this.queues_ = {high: [], middle: [], low: []};
}
/** @param {!TopLevelSearchTask} task */
addTopLevelSearchTask(task) {
this.queues_.high.push(task);
this.consumePending_();
}
/** @param {!SearchAndHighlightTask} task */
addSearchAndHighlightTask(task) {
this.queues_.middle.push(task);
this.consumePending_();
}
/** @param {!RenderTask} task */
addRenderTask(task) {
this.queues_.low.push(task);
this.consumePending_();
}
/**
* Registers a callback to be called every time the queue becomes empty.
* @param {function():void} onEmptyCallback
*/
onEmpty(onEmptyCallback) {
this.onEmptyCallback_ = onEmptyCallback;
}
/**
* @return {!Task|undefined}
* @private
*/
popNextTask_() {
return this.queues_.high.shift() || this.queues_.middle.shift() ||
this.queues_.low.shift();
}
/** @private */
consumePending_() {
if (this.running_) {
return;
}
const task = this.popNextTask_();
if (!task) {
this.running_ = false;
if (this.onEmptyCallback_) {
this.onEmptyCallback_();
}
return;
}
this.running_ = true;
window.requestIdleCallback(() => {
if (!this.request_.canceled) {
task.exec().then(() => {
this.running_ = false;
this.consumePending_();
});
}
// Nothing to do otherwise. Since the request corresponding to this
// queue was canceled, the queue is disposed along with the request.
});
}
}
export class SearchRequest {
/**
* @param {string} rawQuery
* @param {!Element} root
*/
constructor(rawQuery, root) {
/** @private {string} */
this.rawQuery_ = rawQuery;
/** @private {!Element} */
this.root_ = root;
/** @type {?RegExp} */
this.regExp = this.generateRegExp_();
/**
* Whether this request was canceled before completing.
* @type {boolean}
*/
this.canceled = false;
/** @private {boolean} */
this.foundMatches_ = false;
/** @type {!PromiseResolver} */
this.resolver = new PromiseResolver();
/** @private {!TaskQueue} */
this.queue_ = new TaskQueue(this);
this.queue_.onEmpty(() => {
this.resolver.resolve(this);
});
/** @private {!Set<!MutationObserver>} */
this.textObservers_ = new Set();
/** @private {!Array<!Node>} */
this.highlights_ = [];
/** @type {!Map<!Node, number>} */
this.bubbles = new Map;
}
/** @param {!Array<!Node>} highlights The highlight wrappers to add */
addHighlights(highlights) {
this.highlights_.push(...highlights);
}
removeAllTextObservers() {
this.textObservers_.forEach(observer => {
observer.disconnect();
});
this.textObservers_.clear();
}
removeAllHighlightsAndBubbles() {
removeHighlights(this.highlights_);
this.bubbles.forEach((count, bubble) => bubble.remove());
this.highlights_ = [];
this.bubbles.clear();
}
/** @param {!Node} textNode */
addTextObserver(textNode) {
const originalParentNode = /** @type {!Node} */ (textNode.parentNode);
const observer = new MutationObserver(mutations => {
const oldValue = mutations[0].oldValue.trim();
const newValue = textNode.nodeValue.trim();
if (oldValue !== newValue) {
observer.disconnect();
this.textObservers_.delete(observer);
findAndRemoveHighlights(originalParentNode);
}
});
observer.observe(
textNode, {characterData: true, characterDataOldValue: true});
this.textObservers_.add(observer);
}
/**
* Fires this search request.
*/
start() {
this.queue_.addTopLevelSearchTask(
new TopLevelSearchTask(this, this.root_));
}
/**
* @return {?RegExp}
* @private
*/
generateRegExp_() {
let regExp = null;
// Generate search text by escaping any characters that would be
// problematic for regular expressions.
const strippedQuery =
stripDiacritics(this.rawQuery_.trim());
const sanitizedQuery = strippedQuery.replace(SANITIZE_REGEX, '\\$&');
if (sanitizedQuery.length > 0) {
regExp = new RegExp(`(${sanitizedQuery})`, 'ig');
}
return regExp;
}
/**
* @param {string} rawQuery
* @return {boolean} Whether this SearchRequest refers to an identical
* query.
*/
isSame(rawQuery) {
return this.rawQuery_ === rawQuery;
}
/**
* Updates the result for this search request.
* @param {boolean} found
*/
updateMatches(found) {
this.foundMatches_ = this.foundMatches_ || found;
}
/** @return {boolean} Whether any matches were found. */
didFindMatches() {
return this.foundMatches_;
}
}
/** @type {!RegExp} */
const SANITIZE_REGEX = /[-[\]{}()*+?.,\\^$|#\s]/g;
/** @interface */
class SearchManager {
/**
* @param {string} text The text to search for.
* @param {!Element} page
* @return {!Promise<!SearchRequest>} A signal indicating that
* searching finished.
*/
search(text, page) {}
}
/** @implements {SearchManager} */
class SearchManagerImpl {
constructor() {
/** @private {!Set<!SearchRequest>} */
this.activeRequests_ = new Set();
/** @private {!Set<!SearchRequest>} */
this.completedRequests_ = new Set();
/** @private {?string} */
this.lastSearchedText_ = null;
}
/** @override */
search(text, page) {
// Cancel any pending requests if a request with different text is
// submitted.
if (text !== this.lastSearchedText_) {
this.activeRequests_.forEach(function(request) {
request.removeAllTextObservers();
request.removeAllHighlightsAndBubbles();
request.canceled = true;
request.resolver.resolve(request);
});
this.activeRequests_.clear();
this.completedRequests_.forEach(request => {
request.removeAllTextObservers();
request.removeAllHighlightsAndBubbles();
});
this.completedRequests_.clear();
}
this.lastSearchedText_ = text;
const request = new SearchRequest(text, page);
this.activeRequests_.add(request);
request.start();
return request.resolver.promise.then(() => {
this.activeRequests_.delete(request);
this.completedRequests_.add(request);
return request;
});
}
}
/** @type {?SearchManager} */
let instance = null;
/** @return {!SearchManager} */
export function getSearchManager() {
if (instance === null) {
instance = new SearchManagerImpl();
}
return instance;
}
/**
* Sets the SearchManager singleton instance, useful for testing.
* @param {!SearchManager} searchManager
*/
export function setSearchManagerForTesting(searchManager) {
instance = searchManager;
}