OLD | NEW |
1 // Copyright 2016 The Chromium Authors. All rights reserved. | 1 // Copyright 2016 The Chromium Authors. All rights reserved. |
2 // Use of this source code is governed by a BSD-style license that can be | 2 // Use of this source code is governed by a BSD-style license that can be |
3 // found in the LICENSE file. | 3 // found in the LICENSE file. |
4 | 4 |
| 5 /** @typedef {{id: number, rawQuery: ?string, regExp: ?RegExp}} */ |
| 6 var SearchContext; |
| 7 |
5 cr.define('settings', function() { | 8 cr.define('settings', function() { |
6 /** @const {string} */ | 9 /** @const {string} */ |
7 var WRAPPER_CSS_CLASS = 'search-highlight-wrapper'; | 10 var WRAPPER_CSS_CLASS = 'search-highlight-wrapper'; |
8 | 11 |
9 /** @const {string} */ | 12 /** @const {string} */ |
10 var HIT_CSS_CLASS = 'search-highlight-hit'; | 13 var HIT_CSS_CLASS = 'search-highlight-hit'; |
11 | 14 |
12 /** @const {!RegExp} */ | |
13 var SANITIZE_REGEX = /[-[\]{}()*+?.,\\^$|#\s]/g; | |
14 | |
15 /** | 15 /** |
16 * List of elements types that should not be searched at all. | 16 * List of elements types that should not be searched at all. |
17 * The only DOM-MODULE node is in <body> which is not searched, therefore | 17 * The only DOM-MODULE node is in <body> which is not searched, therefore |
18 * DOM-MODULE is not needed in this set. | 18 * DOM-MODULE is not needed in this set. |
19 * @const {!Set<string>} | 19 * @const {!Set<string>} |
20 */ | 20 */ |
21 var IGNORED_ELEMENTS = new Set([ | 21 var IGNORED_ELEMENTS = new Set([ |
22 'CONTENT', | 22 'CONTENT', |
23 'CR-EVENTS', | 23 'CR-EVENTS', |
24 'IMG', | 24 'IMG', |
25 'IRON-ICON', | 25 'IRON-ICON', |
26 'IRON-LIST', | 26 'IRON-LIST', |
27 'PAPER-ICON-BUTTON', | 27 'PAPER-ICON-BUTTON', |
28 /* TODO(dpapad): paper-item is used for dynamically populated dropdown | 28 /* TODO(dpapad): paper-item is used for dynamically populated dropdown |
29 * menus. Perhaps a better approach is to mark the entire dropdown menu such | 29 * menus. Perhaps a better approach is to mark the entire dropdown menu such |
30 * that search algorithm can skip it as a whole instead. | 30 * that search algorithm can skip it as a whole instead. |
31 */ | 31 */ |
32 'PAPER-ITEM', | 32 'PAPER-ITEM', |
33 'PAPER-RIPPLE', | 33 'PAPER-RIPPLE', |
34 'PAPER-SLIDER', | 34 'PAPER-SLIDER', |
35 'PAPER-SPINNER', | 35 'PAPER-SPINNER', |
36 'STYLE', | 36 'STYLE', |
37 'TEMPLATE', | 37 'TEMPLATE', |
38 ]); | 38 ]); |
39 | 39 |
40 /** | 40 /** |
41 * Finds all previous highlighted nodes under |node| (both within self and | 41 * Finds all previous highlighted nodes under |node| (both within self and |
42 * children's Shadow DOM) and removes the highlight (yellow rectangle). | 42 * children's Shadow DOM) and removes the highlight (yellow rectangle). |
| 43 * TODO(dpapad): Consider making this a private method of TopLevelSearchTask. |
43 * @param {!Node} node | 44 * @param {!Node} node |
44 * @private | 45 * @private |
45 */ | 46 */ |
46 function findAndRemoveHighlights_(node) { | 47 function findAndRemoveHighlights_(node) { |
47 var wrappers = node.querySelectorAll('* /deep/ .' + WRAPPER_CSS_CLASS); | 48 var wrappers = node.querySelectorAll('* /deep/ .' + WRAPPER_CSS_CLASS); |
48 | 49 |
49 for (var wrapper of wrappers) { | 50 for (var wrapper of wrappers) { |
50 var hitElements = wrapper.querySelectorAll('.' + HIT_CSS_CLASS); | 51 var hitElements = wrapper.querySelectorAll('.' + HIT_CSS_CLASS); |
51 // For each hit element, remove the highlighting. | 52 // For each hit element, remove the highlighting. |
52 for (var hitElement of hitElements) { | 53 for (var hitElement of hitElements) { |
(...skipping 37 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
90 var span = document.createElement('span'); | 91 var span = document.createElement('span'); |
91 span.classList.add(HIT_CSS_CLASS); | 92 span.classList.add(HIT_CSS_CLASS); |
92 span.style.backgroundColor = 'yellow'; | 93 span.style.backgroundColor = 'yellow'; |
93 span.textContent = tokens[i]; | 94 span.textContent = tokens[i]; |
94 wrapper.appendChild(span); | 95 wrapper.appendChild(span); |
95 } | 96 } |
96 } | 97 } |
97 } | 98 } |
98 | 99 |
99 /** | 100 /** |
| 101 * Checks whether the given |node| requires force rendering. |
| 102 * |
| 103 * @param {!SearchContext} context |
| 104 * @param {!Node} node |
| 105 * @return {boolean} Whether a forced rendering task was scheduled. |
| 106 * @private |
| 107 */ |
| 108 function forceRenderNeeded_(context, node) { |
| 109 if (node.nodeName != 'TEMPLATE' || !node.hasAttribute('name') || node.if) |
| 110 return false; |
| 111 |
| 112 // TODO(dpapad): Temporarily ignore site-settings because it throws an |
| 113 // assertion error during force-rendering. |
| 114 return node.getAttribute('name').indexOf('site-') != 0; |
| 115 } |
| 116 |
| 117 /** |
100 * Traverses the entire DOM (including Shadow DOM), finds text nodes that | 118 * Traverses the entire DOM (including Shadow DOM), finds text nodes that |
101 * match the given regular expression and applies the highlight UI. It also | 119 * match the given regular expression and applies the highlight UI. It also |
102 * ensures that <settings-section> instances become visible if any matches | 120 * ensures that <settings-section> instances become visible if any matches |
103 * occurred under their subtree. | 121 * occurred under their subtree. |
104 * | 122 * |
105 * @param {!Element} page The page to be searched, should be either | 123 * @param {!SearchContext} context |
106 * <settings-basic-page> or <settings-advanced-page>. | 124 * @param {!Node} root The root of the sub-tree to be searched |
107 * @param {!RegExp} regExp The regular expression to detect matches. | |
108 * @private | 125 * @private |
109 */ | 126 */ |
110 function findAndHighlightMatches_(page, regExp) { | 127 function findAndHighlightMatches_(context, root) { |
111 function doSearch(node) { | 128 function doSearch(node) { |
112 if (IGNORED_ELEMENTS.has(node.tagName)) | 129 if (forceRenderNeeded_(context, node)) { |
| 130 SearchManager.getInstance().queue_.addRenderTask( |
| 131 new RenderTask(context, node)); |
| 132 return; |
| 133 } |
| 134 |
| 135 if (IGNORED_ELEMENTS.has(node.nodeName)) |
113 return; | 136 return; |
114 | 137 |
115 if (node.nodeType == Node.TEXT_NODE) { | 138 if (node.nodeType == Node.TEXT_NODE) { |
116 var textContent = node.nodeValue.trim(); | 139 var textContent = node.nodeValue.trim(); |
117 if (textContent.length == 0) | 140 if (textContent.length == 0) |
118 return; | 141 return; |
119 | 142 |
120 if (regExp.test(textContent)) { | 143 if (context.regExp.test(textContent)) { |
121 revealParentSection_(node); | 144 revealParentSection_(node); |
122 highlight_(node, textContent.split(regExp)); | 145 highlight_(node, textContent.split(context.regExp)); |
123 } | 146 } |
124 // Returning early since TEXT_NODE nodes never have children. | 147 // Returning early since TEXT_NODE nodes never have children. |
125 return; | 148 return; |
126 } | 149 } |
127 | 150 |
128 var child = node.firstChild; | 151 var child = node.firstChild; |
129 while (child !== null) { | 152 while (child !== null) { |
130 // Getting a reference to the |nextSibling| before calling doSearch() | 153 // Getting a reference to the |nextSibling| before calling doSearch() |
131 // because |child| could be removed from the DOM within doSearch(). | 154 // because |child| could be removed from the DOM within doSearch(). |
132 var nextSibling = child.nextSibling; | 155 var nextSibling = child.nextSibling; |
133 doSearch(child); | 156 doSearch(child); |
134 child = nextSibling; | 157 child = nextSibling; |
135 } | 158 } |
136 | 159 |
137 var shadowRoot = node.shadowRoot; | 160 var shadowRoot = node.shadowRoot; |
138 if (shadowRoot) | 161 if (shadowRoot) |
139 doSearch(shadowRoot); | 162 doSearch(shadowRoot); |
140 } | 163 } |
141 | 164 |
142 doSearch(page); | 165 doSearch(root); |
143 } | 166 } |
144 | 167 |
145 /** | 168 /** |
146 * Finds and makes visible the <settings-section> parent of |node|. | 169 * Finds and makes visible the <settings-section> parent of |node|. |
147 * @param {!Node} node | 170 * @param {!Node} node |
148 */ | 171 */ |
149 function revealParentSection_(node) { | 172 function revealParentSection_(node) { |
150 // Find corresponding SETTINGS-SECTION parent and make it visible. | 173 // Find corresponding SETTINGS-SECTION parent and make it visible. |
151 var parent = node; | 174 var parent = node; |
152 while (parent && parent.tagName !== 'SETTINGS-SECTION') { | 175 while (parent && parent.nodeName !== 'SETTINGS-SECTION') { |
153 parent = parent.nodeType == Node.DOCUMENT_FRAGMENT_NODE ? | 176 parent = parent.nodeType == Node.DOCUMENT_FRAGMENT_NODE ? |
154 parent.host : parent.parentNode; | 177 parent.host : parent.parentNode; |
155 } | 178 } |
156 if (parent) | 179 if (parent) |
157 parent.hidden = false; | 180 parent.hidden = false; |
158 } | 181 } |
159 | 182 |
160 /** | 183 /** |
161 * @param {!Element} page | 184 * @constructor |
162 * @param {boolean} visible | 185 * |
163 * @private | 186 * @param {!SearchContext} context |
164 */ | 187 * @param {!Node} node |
165 function setSectionsVisibility_(page, visible) { | 188 */ |
166 var sections = Polymer.dom(page.root).querySelectorAll('settings-section'); | 189 function Task(context, node) { |
167 for (var i = 0; i < sections.length; i++) | 190 /** @protected {!SearchContext} */ |
168 sections[i].hidden = !visible; | 191 this.context = context; |
169 } | 192 |
170 | 193 /** @protected {!Node} */ |
171 /** | 194 this.node = node; |
172 * Performs hierarchical search, starting at the given page element. | 195 } |
| 196 |
| 197 Task.prototype = { |
| 198 /** |
| 199 * @abstract |
| 200 * @return {!Promise} |
| 201 */ |
| 202 exec: function() {}, |
| 203 }; |
| 204 |
| 205 /** |
| 206 * A task that takes a <template is="dom-if">...</template> node corresponding |
| 207 * to a setting subpage and renders it. A SearchAndHighlightTask is posted for |
| 208 * the newly rendered subtree, once rendering is done. |
| 209 * @constructor |
| 210 * @extends {Task} |
| 211 * |
| 212 * @param {!SearchContext} context |
| 213 * @param {!Node} node |
| 214 */ |
| 215 function RenderTask(context, node) { |
| 216 Task.call(this, context, node); |
| 217 } |
| 218 |
| 219 RenderTask.prototype = { |
| 220 /** @override */ |
| 221 exec: function() { |
| 222 var subpageTemplate = |
| 223 this.node['_content'].querySelector('settings-subpage'); |
| 224 subpageTemplate.id = subpageTemplate.id || this.node.getAttribute('name'); |
| 225 assert(!this.node.if); |
| 226 this.node.if = true; |
| 227 |
| 228 return new Promise(function(resolve, reject) { |
| 229 var parent = this.node.parentNode; |
| 230 parent.async(function() { |
| 231 var renderedNode = parent.querySelector('#' + subpageTemplate.id); |
| 232 // Register a SearchAndHighlightTask for the part of the DOM that was |
| 233 // just rendered. |
| 234 SearchManager.getInstance().queue_.addSearchAndHighlightTask( |
| 235 new SearchAndHighlightTask(this.context, assert(renderedNode))); |
| 236 resolve(); |
| 237 }.bind(this)); |
| 238 }.bind(this)); |
| 239 }, |
| 240 }; |
| 241 |
| 242 /** |
| 243 * @constructor |
| 244 * @extends {Task} |
| 245 * |
| 246 * @param {!SearchContext} context |
| 247 * @param {!Node} node |
| 248 */ |
| 249 function SearchAndHighlightTask(context, node) { |
| 250 Task.call(this, context, node); |
| 251 } |
| 252 |
| 253 SearchAndHighlightTask.prototype = { |
| 254 /** @override */ |
| 255 exec: function() { |
| 256 findAndHighlightMatches_(this.context, this.node); |
| 257 return Promise.resolve(); |
| 258 }, |
| 259 }; |
| 260 |
| 261 /** |
| 262 * @constructor |
| 263 * @extends {Task} |
| 264 * |
| 265 * @param {!SearchContext} context |
| 266 * @param {!Node} page |
| 267 */ |
| 268 function TopLevelSearchTask(context, page) { |
| 269 Task.call(this, context, page); |
| 270 } |
| 271 |
| 272 TopLevelSearchTask.prototype = { |
| 273 /** @override */ |
| 274 exec: function() { |
| 275 findAndRemoveHighlights_(this.node); |
| 276 |
| 277 var shouldSearch = this.context.regExp !== null; |
| 278 this.setSectionsVisibility_(!shouldSearch); |
| 279 if (shouldSearch) |
| 280 findAndHighlightMatches_(this.context, this.node); |
| 281 |
| 282 return Promise.resolve(); |
| 283 }, |
| 284 |
| 285 /** |
| 286 * @param {boolean} visible |
| 287 * @private |
| 288 */ |
| 289 setSectionsVisibility_: function(visible) { |
| 290 var sections = Polymer.dom( |
| 291 this.node.root).querySelectorAll('settings-section'); |
| 292 for (var i = 0; i < sections.length; i++) |
| 293 sections[i].hidden = !visible; |
| 294 }, |
| 295 }; |
| 296 |
| 297 /** |
| 298 * @constructor |
| 299 */ |
| 300 function TaskQueue() { |
| 301 /** |
| 302 * @private {{ |
| 303 * high: !Array<!Task>, |
| 304 * middle: !Array<!Task>, |
| 305 * low: !Array<!Task> |
| 306 * }} |
| 307 */ |
| 308 this.queues_; |
| 309 this.reset(); |
| 310 |
| 311 /** |
| 312 * Whether a task is currently running. |
| 313 * @private {boolean} |
| 314 */ |
| 315 this.running_ = false; |
| 316 } |
| 317 |
| 318 TaskQueue.prototype = { |
| 319 /** Drops all tasks. */ |
| 320 reset: function() { |
| 321 this.queues_ = {high: [], middle: [], low: []}; |
| 322 }, |
| 323 |
| 324 /** @param {!TopLevelSearchTask} task */ |
| 325 addTopLevelSearchTask: function(task) { |
| 326 this.queues_.high.push(task); |
| 327 this.consumePending_(); |
| 328 }, |
| 329 |
| 330 /** @param {!SearchAndHighlightTask} task */ |
| 331 addSearchAndHighlightTask: function(task) { |
| 332 this.queues_.middle.push(task); |
| 333 this.consumePending_(); |
| 334 }, |
| 335 |
| 336 /** @param {!RenderTask} task */ |
| 337 addRenderTask: function(task) { |
| 338 this.queues_.low.push(task); |
| 339 this.consumePending_(); |
| 340 }, |
| 341 |
| 342 /** |
| 343 * @return {!Task|undefined} |
| 344 * @private |
| 345 */ |
| 346 popNextTask_: function() { |
| 347 return this.queues_.high.shift() || |
| 348 this.queues_.middle.shift() || |
| 349 this.queues_.low.shift(); |
| 350 }, |
| 351 |
| 352 /** @private */ |
| 353 consumePending_: function() { |
| 354 if (this.running_) |
| 355 return; |
| 356 |
| 357 while (1) { |
| 358 var task = this.popNextTask_(); |
| 359 if (!task) { |
| 360 this.running_ = false; |
| 361 return; |
| 362 } |
| 363 |
| 364 window.requestIdleCallback(function() { |
| 365 function startNextTask() { |
| 366 this.running_ = false; |
| 367 this.consumePending_(); |
| 368 } |
| 369 if (task.context.id == |
| 370 SearchManager.getInstance().activeContext_.id) { |
| 371 task.exec().then(startNextTask.bind(this)); |
| 372 } else { |
| 373 // Dropping this task without ever executing it, since a new search |
| 374 // has been issued since this task was queued. |
| 375 startNextTask.call(this); |
| 376 } |
| 377 }.bind(this)); |
| 378 return; |
| 379 } |
| 380 }, |
| 381 }; |
| 382 |
| 383 /** |
| 384 * @constructor |
| 385 */ |
| 386 var SearchManager = function() { |
| 387 /** @private {!TaskQueue} */ |
| 388 this.queue_ = new TaskQueue(); |
| 389 |
| 390 /** @private {!SearchContext} */ |
| 391 this.activeContext_ = {id: 0, rawQuery: null, regExp: null}; |
| 392 }; |
| 393 cr.addSingletonGetter(SearchManager); |
| 394 |
| 395 /** @private @const {!RegExp} */ |
| 396 SearchManager.SANITIZE_REGEX_ = /[-[\]{}()*+?.,\\^$|#\s]/g; |
| 397 |
| 398 SearchManager.prototype = { |
| 399 /** |
| 400 * @param {string} text The text to search for. |
| 401 * @param {!Node} page |
| 402 */ |
| 403 search: function(text, page) { |
| 404 if (this.activeContext_.rawQuery != text) { |
| 405 var newId = this.activeContext_.id + 1; |
| 406 |
| 407 var regExp = null; |
| 408 // Generate search text by escaping any characters that would be |
| 409 // problematic for regular expressions. |
| 410 var searchText = text.trim().replace( |
| 411 SearchManager.SANITIZE_REGEX_, '\\$&'); |
| 412 if (searchText.length > 0) |
| 413 regExp = new RegExp('(' + searchText + ')', 'i'); |
| 414 |
| 415 this.activeContext_ = {id: newId, rawQuery: text, regExp: regExp}; |
| 416 |
| 417 // Drop all previously scheduled tasks, since a new search was just |
| 418 // issued. |
| 419 this.queue_.reset(); |
| 420 } |
| 421 |
| 422 this.queue_.addTopLevelSearchTask( |
| 423 new TopLevelSearchTask(this.activeContext_, page)); |
| 424 }, |
| 425 }; |
| 426 |
| 427 /** |
173 * @param {string} text | 428 * @param {string} text |
174 * @param {!Element} page Must be either <settings-basic-page> or | 429 * @param {!Node} page |
175 * <settings-advanced-page>. | |
176 */ | 430 */ |
177 function search(text, page) { | 431 function search(text, page) { |
178 findAndRemoveHighlights_(page); | 432 SearchManager.getInstance().search(text, page); |
179 | |
180 // Generate search text by escaping any characters that would be problematic | |
181 // for regular expressions. | |
182 var searchText = text.trim().replace(SANITIZE_REGEX, '\\$&'); | |
183 if (searchText.length == 0) { | |
184 setSectionsVisibility_(page, true); | |
185 return; | |
186 } | |
187 | |
188 setSectionsVisibility_(page, false); | |
189 findAndHighlightMatches_(page, new RegExp('(' + searchText + ')', 'i')); | |
190 } | 433 } |
191 | 434 |
192 return { | 435 return { |
193 search: search, | 436 search: search, |
194 }; | 437 }; |
195 }); | 438 }); |
OLD | NEW |