OLD | NEW |
| (Empty) |
1 // Copyright (c) 2012 The Chromium Authors. All rights reserved. | |
2 // Use of this source code is governed by a BSD-style license that can be | |
3 // found in the LICENSE file. | |
4 | |
5 cr.define('options', function() { | |
6 /** @const */ var OptionsPage = options.OptionsPage; | |
7 | |
8 /** | |
9 * Encapsulated handling of a search bubble. | |
10 * @constructor | |
11 */ | |
12 function SearchBubble(text) { | |
13 var el = cr.doc.createElement('div'); | |
14 SearchBubble.decorate(el); | |
15 el.content = text; | |
16 return el; | |
17 } | |
18 | |
19 SearchBubble.decorate = function(el) { | |
20 el.__proto__ = SearchBubble.prototype; | |
21 el.decorate(); | |
22 }; | |
23 | |
24 SearchBubble.prototype = { | |
25 __proto__: HTMLDivElement.prototype, | |
26 | |
27 decorate: function() { | |
28 this.className = 'search-bubble'; | |
29 | |
30 this.innards_ = cr.doc.createElement('div'); | |
31 this.innards_.className = 'search-bubble-innards'; | |
32 this.appendChild(this.innards_); | |
33 | |
34 // We create a timer to periodically update the position of the bubbles. | |
35 // While this isn't all that desirable, it's the only sure-fire way of | |
36 // making sure the bubbles stay in the correct location as sections | |
37 // may dynamically change size at any time. | |
38 this.intervalId = setInterval(this.updatePosition.bind(this), 250); | |
39 }, | |
40 | |
41 /** | |
42 * Sets the text message in the bubble. | |
43 * @param {string} text The text the bubble will show. | |
44 */ | |
45 set content(text) { | |
46 this.innards_.textContent = text; | |
47 }, | |
48 | |
49 /** | |
50 * Attach the bubble to the element. | |
51 */ | |
52 attachTo: function(element) { | |
53 var parent = element.parentElement; | |
54 if (!parent) | |
55 return; | |
56 if (parent.tagName == 'TD') { | |
57 // To make absolute positioning work inside a table cell we need | |
58 // to wrap the bubble div into another div with position:relative. | |
59 // This only works properly if the element is the first child of the | |
60 // table cell which is true for all options pages. | |
61 this.wrapper = cr.doc.createElement('div'); | |
62 this.wrapper.className = 'search-bubble-wrapper'; | |
63 this.wrapper.appendChild(this); | |
64 parent.insertBefore(this.wrapper, element); | |
65 } else { | |
66 parent.insertBefore(this, element); | |
67 } | |
68 }, | |
69 | |
70 /** | |
71 * Clear the interval timer and remove the element from the page. | |
72 */ | |
73 dispose: function() { | |
74 clearInterval(this.intervalId); | |
75 | |
76 var child = this.wrapper || this; | |
77 var parent = child.parentNode; | |
78 if (parent) | |
79 parent.removeChild(child); | |
80 }, | |
81 | |
82 /** | |
83 * Update the position of the bubble. Called at creation time and then | |
84 * periodically while the bubble remains visible. | |
85 */ | |
86 updatePosition: function() { | |
87 // This bubble is 'owned' by the next sibling. | |
88 var owner = (this.wrapper || this).nextSibling; | |
89 | |
90 // If there isn't an offset parent, we have nothing to do. | |
91 if (!owner.offsetParent) | |
92 return; | |
93 | |
94 // Position the bubble below the location of the owner. | |
95 var left = owner.offsetLeft + owner.offsetWidth / 2 - | |
96 this.offsetWidth / 2; | |
97 var top = owner.offsetTop + owner.offsetHeight; | |
98 | |
99 // Update the position in the CSS. Cache the last values for | |
100 // best performance. | |
101 if (left != this.lastLeft) { | |
102 this.style.left = left + 'px'; | |
103 this.lastLeft = left; | |
104 } | |
105 if (top != this.lastTop) { | |
106 this.style.top = top + 'px'; | |
107 this.lastTop = top; | |
108 } | |
109 }, | |
110 }; | |
111 | |
112 /** | |
113 * Encapsulated handling of the search page. | |
114 * @constructor | |
115 */ | |
116 function SearchPage() { | |
117 OptionsPage.call(this, 'search', | |
118 loadTimeData.getString('searchPageTabTitle'), | |
119 'searchPage'); | |
120 } | |
121 | |
122 cr.addSingletonGetter(SearchPage); | |
123 | |
124 SearchPage.prototype = { | |
125 // Inherit SearchPage from OptionsPage. | |
126 __proto__: OptionsPage.prototype, | |
127 | |
128 /** | |
129 * A boolean to prevent recursion. Used by setSearchText_(). | |
130 * @type {Boolean} | |
131 * @private | |
132 */ | |
133 insideSetSearchText_: false, | |
134 | |
135 /** | |
136 * Initialize the page. | |
137 */ | |
138 initializePage: function() { | |
139 // Call base class implementation to start preference initialization. | |
140 OptionsPage.prototype.initializePage.call(this); | |
141 | |
142 this.searchField = $('search-field'); | |
143 | |
144 // Handle search events. (No need to throttle, WebKit's search field | |
145 // will do that automatically.) | |
146 this.searchField.onsearch = function(e) { | |
147 this.setSearchText_(e.currentTarget.value); | |
148 }.bind(this); | |
149 | |
150 // Install handler for key presses. | |
151 document.addEventListener('keydown', | |
152 this.keyDownEventHandler_.bind(this)); | |
153 }, | |
154 | |
155 /** | |
156 * @inheritDoc | |
157 */ | |
158 get sticky() { | |
159 return true; | |
160 }, | |
161 | |
162 /** | |
163 * Called after this page has shown. | |
164 */ | |
165 didShowPage: function() { | |
166 // This method is called by the Options page after all pages have | |
167 // had their visibilty attribute set. At this point we can perform the | |
168 // search specific DOM manipulation. | |
169 this.setSearchActive_(true); | |
170 }, | |
171 | |
172 /** | |
173 * Called before this page will be hidden. | |
174 */ | |
175 willHidePage: function() { | |
176 // This method is called by the Options page before all pages have | |
177 // their visibilty attribute set. Before that happens, we need to | |
178 // undo the search specific DOM manipulation that was performed in | |
179 // didShowPage. | |
180 this.setSearchActive_(false); | |
181 }, | |
182 | |
183 /** | |
184 * Update the UI to reflect whether we are in a search state. | |
185 * @param {boolean} active True if we are on the search page. | |
186 * @private | |
187 */ | |
188 setSearchActive_: function(active) { | |
189 // It's fine to exit if search wasn't active and we're not going to | |
190 // activate it now. | |
191 if (!this.searchActive_ && !active) | |
192 return; | |
193 | |
194 this.searchActive_ = active; | |
195 | |
196 if (active) { | |
197 var hash = location.hash; | |
198 if (hash) { | |
199 this.searchField.value = | |
200 decodeURIComponent(hash.slice(1).replace(/\+/g, ' ')); | |
201 } else if (!this.searchField.value) { | |
202 // This should only happen if the user goes directly to | |
203 // chrome://settings-frame/search | |
204 OptionsPage.showDefaultPage(); | |
205 return; | |
206 } | |
207 | |
208 // Move 'advanced' sections into the main settings page to allow | |
209 // searching. | |
210 if (!this.advancedSections_) { | |
211 this.advancedSections_ = | |
212 $('advanced-settings-container').querySelectorAll('section'); | |
213 for (var i = 0, section; section = this.advancedSections_[i]; i++) | |
214 $('settings').appendChild(section); | |
215 } | |
216 } | |
217 | |
218 var pagesToSearch = this.getSearchablePages_(); | |
219 for (var key in pagesToSearch) { | |
220 var page = pagesToSearch[key]; | |
221 | |
222 if (!active) | |
223 page.visible = false; | |
224 | |
225 // Update the visible state of all top-level elements that are not | |
226 // sections (ie titles, button strips). We do this before changing | |
227 // the page visibility to avoid excessive re-draw. | |
228 for (var i = 0, childDiv; childDiv = page.pageDiv.children[i]; i++) { | |
229 if (active) { | |
230 if (childDiv.tagName != 'SECTION') | |
231 childDiv.classList.add('search-hidden'); | |
232 } else { | |
233 childDiv.classList.remove('search-hidden'); | |
234 } | |
235 } | |
236 | |
237 if (active) { | |
238 // When search is active, remove the 'hidden' tag. This tag may have | |
239 // been added by the OptionsPage. | |
240 page.pageDiv.hidden = false; | |
241 } | |
242 } | |
243 | |
244 if (active) { | |
245 this.setSearchText_(this.searchField.value); | |
246 this.searchField.focus(); | |
247 } else { | |
248 // After hiding all page content, remove any search results. | |
249 this.unhighlightMatches_(); | |
250 this.removeSearchBubbles_(); | |
251 | |
252 // Move 'advanced' sections back into their original container. | |
253 if (this.advancedSections_) { | |
254 for (var i = 0, section; section = this.advancedSections_[i]; i++) | |
255 $('advanced-settings-container').appendChild(section); | |
256 this.advancedSections_ = null; | |
257 } | |
258 } | |
259 }, | |
260 | |
261 /** | |
262 * Set the current search criteria. | |
263 * @param {string} text Search text. | |
264 * @private | |
265 */ | |
266 setSearchText_: function(text) { | |
267 // Prevent recursive execution of this method. | |
268 if (this.insideSetSearchText_) return; | |
269 this.insideSetSearchText_ = true; | |
270 | |
271 // Cleanup the search query string. | |
272 text = SearchPage.canonicalizeQuery(text); | |
273 | |
274 // Set the hash on the current page, and the enclosing uber page | |
275 var hash = text ? '#' + encodeURIComponent(text) : ''; | |
276 var path = text ? this.name : ''; | |
277 window.location.hash = hash; | |
278 uber.invokeMethodOnParent('setPath', {path: path + hash}); | |
279 | |
280 // Toggle the search page if necessary. | |
281 if (text) { | |
282 if (!this.searchActive_) | |
283 OptionsPage.showPageByName(this.name, false); | |
284 } else { | |
285 if (this.searchActive_) | |
286 OptionsPage.showPageByName(OptionsPage.getDefaultPage().name, false); | |
287 | |
288 this.insideSetSearchText_ = false; | |
289 return; | |
290 } | |
291 | |
292 var foundMatches = false; | |
293 | |
294 // Remove any prior search results. | |
295 this.unhighlightMatches_(); | |
296 this.removeSearchBubbles_(); | |
297 | |
298 var pagesToSearch = this.getSearchablePages_(); | |
299 for (var key in pagesToSearch) { | |
300 var page = pagesToSearch[key]; | |
301 var elements = page.pageDiv.querySelectorAll('section'); | |
302 for (var i = 0, node; node = elements[i]; i++) { | |
303 node.classList.add('search-hidden'); | |
304 } | |
305 } | |
306 | |
307 var bubbleControls = []; | |
308 | |
309 // Generate search text by applying lowercase and escaping any characters | |
310 // that would be problematic for regular expressions. | |
311 var searchText = | |
312 text.toLowerCase().replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); | |
313 // Generate a regular expression for hilighting search terms. | |
314 var regExp = new RegExp('(' + searchText + ')', 'ig'); | |
315 | |
316 if (searchText.length) { | |
317 // Search all top-level sections for anchored string matches. | |
318 for (var key in pagesToSearch) { | |
319 var page = pagesToSearch[key]; | |
320 var elements = | |
321 page.pageDiv.querySelectorAll('section'); | |
322 for (var i = 0, node; node = elements[i]; i++) { | |
323 if (this.highlightMatches_(regExp, node)) { | |
324 node.classList.remove('search-hidden'); | |
325 if (!node.hidden) | |
326 foundMatches = true; | |
327 } | |
328 } | |
329 } | |
330 | |
331 // Search all sub-pages, generating an array of top-level sections that | |
332 // we need to make visible. | |
333 var subPagesToSearch = this.getSearchableSubPages_(); | |
334 var control, node; | |
335 for (var key in subPagesToSearch) { | |
336 var page = subPagesToSearch[key]; | |
337 if (this.highlightMatches_(regExp, page.pageDiv)) { | |
338 this.revealAssociatedSections_(page); | |
339 | |
340 bubbleControls = | |
341 bubbleControls.concat(this.getAssociatedControls_(page)); | |
342 | |
343 foundMatches = true; | |
344 } | |
345 } | |
346 } | |
347 | |
348 // Configure elements on the search results page based on search results. | |
349 $('searchPageNoMatches').hidden = foundMatches; | |
350 | |
351 // Create search balloons for sub-page results. | |
352 length = bubbleControls.length; | |
353 for (var i = 0; i < length; i++) | |
354 this.createSearchBubble_(bubbleControls[i], text); | |
355 | |
356 // Cleanup the recursion-prevention variable. | |
357 this.insideSetSearchText_ = false; | |
358 }, | |
359 | |
360 /** | |
361 * Reveal the associated section for |subpage|, as well as the one for its | |
362 * |parentPage|, and its |parentPage|'s |parentPage|, etc. | |
363 * @private | |
364 */ | |
365 revealAssociatedSections_: function(subpage) { | |
366 for (var page = subpage; page; page = page.parentPage) { | |
367 var section = page.associatedSection; | |
368 if (section) | |
369 section.classList.remove('search-hidden'); | |
370 } | |
371 }, | |
372 | |
373 /** | |
374 * @return {!Array.<HTMLElement>} all the associated controls for |subpage|, | |
375 * including |subpage.associatedControls| as well as any controls on parent | |
376 * pages that are indirectly necessary to get to the subpage. | |
377 * @private | |
378 */ | |
379 getAssociatedControls_: function(subpage) { | |
380 var controls = []; | |
381 for (var page = subpage; page; page = page.parentPage) { | |
382 if (page.associatedControls) | |
383 controls = controls.concat(page.associatedControls); | |
384 } | |
385 return controls; | |
386 }, | |
387 | |
388 /** | |
389 * Wraps matches in spans. | |
390 * @param {RegExp} regExp The search query (in regexp form). | |
391 * @param {Element} element An HTML container element to recursively search | |
392 * within. | |
393 * @return {boolean} true if the element was changed. | |
394 * @private | |
395 */ | |
396 highlightMatches_: function(regExp, element) { | |
397 var found = false; | |
398 var div, child, tmp; | |
399 | |
400 // Walk the tree, searching each TEXT node. | |
401 var walker = document.createTreeWalker(element, | |
402 NodeFilter.SHOW_TEXT, | |
403 null, | |
404 false); | |
405 var node = walker.nextNode(); | |
406 while (node) { | |
407 var textContent = node.nodeValue; | |
408 // Perform a search and replace on the text node value. | |
409 var split = textContent.split(regExp); | |
410 if (split.length > 1) { | |
411 found = true; | |
412 var nextNode = walker.nextNode(); | |
413 var parentNode = node.parentNode; | |
414 parentNode.removeChild(node); | |
415 node = nextNode; | |
416 | |
417 for (var i = 0; i < split.length; ++i) { | |
418 if (i % 2 == 0) { | |
419 parentNode.appendChild(document.createTextNode(split[i])); | |
420 } else { | |
421 var span = document.createElement('span'); | |
422 span.className = 'search-highlighted'; | |
423 span.textContent = split[i]; | |
424 parentNode.appendChild(span); | |
425 } | |
426 } | |
427 } else { | |
428 node = walker.nextNode(); | |
429 } | |
430 } | |
431 | |
432 return found; | |
433 }, | |
434 | |
435 /** | |
436 * Removes all search highlight tags from the document. | |
437 * @private | |
438 */ | |
439 unhighlightMatches_: function() { | |
440 // Find all search highlight elements. | |
441 var elements = document.querySelectorAll('.search-highlighted'); | |
442 | |
443 // For each element, remove the highlighting. | |
444 var parent, i; | |
445 for (var i = 0, node; node = elements[i]; i++) { | |
446 parent = node.parentNode; | |
447 | |
448 // Replace the highlight element with the first child (the text node). | |
449 parent.replaceChild(node.firstChild, node); | |
450 | |
451 // Normalize the parent so that multiple text nodes will be combined. | |
452 parent.normalize(); | |
453 } | |
454 }, | |
455 | |
456 /** | |
457 * Creates a search result bubble attached to an element. | |
458 * @param {Element} element An HTML element, usually a button. | |
459 * @param {string} text A string to show in the bubble. | |
460 * @private | |
461 */ | |
462 createSearchBubble_: function(element, text) { | |
463 // avoid appending multiple bubbles to a button. | |
464 var sibling = element.previousElementSibling; | |
465 if (sibling && (sibling.classList.contains('search-bubble') || | |
466 sibling.classList.contains('search-bubble-wrapper'))) | |
467 return; | |
468 | |
469 var parent = element.parentElement; | |
470 if (parent) { | |
471 var bubble = new SearchBubble(text); | |
472 bubble.attachTo(element); | |
473 bubble.updatePosition(); | |
474 } | |
475 }, | |
476 | |
477 /** | |
478 * Removes all search match bubbles. | |
479 * @private | |
480 */ | |
481 removeSearchBubbles_: function() { | |
482 var elements = document.querySelectorAll('.search-bubble'); | |
483 var length = elements.length; | |
484 for (var i = 0; i < length; i++) | |
485 elements[i].dispose(); | |
486 }, | |
487 | |
488 /** | |
489 * Builds a list of top-level pages to search. Omits the search page and | |
490 * all sub-pages. | |
491 * @return {Array} An array of pages to search. | |
492 * @private | |
493 */ | |
494 getSearchablePages_: function() { | |
495 var name, page, pages = []; | |
496 for (name in OptionsPage.registeredPages) { | |
497 if (name != this.name) { | |
498 page = OptionsPage.registeredPages[name]; | |
499 if (!page.parentPage) | |
500 pages.push(page); | |
501 } | |
502 } | |
503 return pages; | |
504 }, | |
505 | |
506 /** | |
507 * Builds a list of sub-pages (and overlay pages) to search. Ignore pages | |
508 * that have no associated controls. | |
509 * @return {Array} An array of pages to search. | |
510 * @private | |
511 */ | |
512 getSearchableSubPages_: function() { | |
513 var name, pageInfo, page, pages = []; | |
514 for (name in OptionsPage.registeredPages) { | |
515 page = OptionsPage.registeredPages[name]; | |
516 if (page.parentPage && page.associatedSection) | |
517 pages.push(page); | |
518 } | |
519 for (name in OptionsPage.registeredOverlayPages) { | |
520 page = OptionsPage.registeredOverlayPages[name]; | |
521 if (page.associatedSection && page.pageDiv != undefined) | |
522 pages.push(page); | |
523 } | |
524 return pages; | |
525 }, | |
526 | |
527 /** | |
528 * A function to handle key press events. | |
529 * @return {Event} a keydown event. | |
530 * @private | |
531 */ | |
532 keyDownEventHandler_: function(event) { | |
533 /** @const */ var ESCAPE_KEY_CODE = 27; | |
534 /** @const */ var FORWARD_SLASH_KEY_CODE = 191; | |
535 | |
536 switch (event.keyCode) { | |
537 case ESCAPE_KEY_CODE: | |
538 if (event.target == this.searchField) { | |
539 this.setSearchText_(''); | |
540 this.searchField.blur(); | |
541 event.stopPropagation(); | |
542 event.preventDefault(); | |
543 } | |
544 break; | |
545 case FORWARD_SLASH_KEY_CODE: | |
546 if (!/INPUT|SELECT|BUTTON|TEXTAREA/.test(event.target.tagName) && | |
547 !event.ctrlKey && !event.altKey) { | |
548 this.searchField.focus(); | |
549 event.stopPropagation(); | |
550 event.preventDefault(); | |
551 } | |
552 break; | |
553 } | |
554 }, | |
555 }; | |
556 | |
557 /** | |
558 * Standardizes a user-entered text query by removing extra whitespace. | |
559 * @param {string} The user-entered text. | |
560 * @return {string} The trimmed query. | |
561 */ | |
562 SearchPage.canonicalizeQuery = function(text) { | |
563 // Trim beginning and ending whitespace. | |
564 return text.replace(/^\s+|\s+$/g, ''); | |
565 }; | |
566 | |
567 // Export | |
568 return { | |
569 SearchPage: SearchPage | |
570 }; | |
571 | |
572 }); | |
OLD | NEW |