Disable view source for Developer Tools.
[chromium-blink-merge.git] / chrome / browser / resources / options / search_page.js
blob234399d7af23e184a62325c2061c3bc9420257e8
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.
5 cr.define('options', function() {
6   /** @const */ var OptionsPage = options.OptionsPage;
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   }
19   SearchBubble.decorate = function(el) {
20     el.__proto__ = SearchBubble.prototype;
21     el.decorate();
22   };
24   SearchBubble.prototype = {
25     __proto__: HTMLDivElement.prototype,
27     decorate: function() {
28       this.className = 'search-bubble';
30       this.innards_ = cr.doc.createElement('div');
31       this.innards_.className = 'search-bubble-innards';
32       this.appendChild(this.innards_);
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     },
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     },
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     },
70     /**
71      * Clear the interval timer and remove the element from the page.
72      */
73     dispose: function() {
74       clearInterval(this.intervalId);
76       var child = this.wrapper || this;
77       var parent = child.parentNode;
78       if (parent)
79         parent.removeChild(child);
80     },
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;
90       // If there isn't an offset parent, we have nothing to do.
91       if (!owner.offsetParent)
92         return;
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;
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   };
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   }
122   cr.addSingletonGetter(SearchPage);
124   SearchPage.prototype = {
125     // Inherit SearchPage from OptionsPage.
126     __proto__: OptionsPage.prototype,
128     /**
129      * A boolean to prevent recursion. Used by setSearchText_().
130      * @type {boolean}
131      * @private
132      */
133     insideSetSearchText_: false,
135     /**
136      * Initialize the page.
137      */
138     initializePage: function() {
139       // Call base class implementation to start preference initialization.
140       OptionsPage.prototype.initializePage.call(this);
142       this.searchField = $('search-field');
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);
150       // Install handler for key presses.
151       document.addEventListener('keydown',
152                                 this.keyDownEventHandler_.bind(this));
153     },
155     /** @override */
156     get sticky() {
157       return true;
158     },
160     /**
161      * Called after this page has shown.
162      */
163     didShowPage: function() {
164       // This method is called by the Options page after all pages have
165       // had their visibilty attribute set.  At this point we can perform the
166       // search specific DOM manipulation.
167       this.setSearchActive_(true);
168     },
170     /**
171      * Called before this page will be hidden.
172      */
173     willHidePage: function() {
174       // This method is called by the Options page before all pages have
175       // their visibilty attribute set.  Before that happens, we need to
176       // undo the search specific DOM manipulation that was performed in
177       // didShowPage.
178       this.setSearchActive_(false);
179     },
181     /**
182      * Update the UI to reflect whether we are in a search state.
183      * @param {boolean} active True if we are on the search page.
184      * @private
185      */
186     setSearchActive_: function(active) {
187       // It's fine to exit if search wasn't active and we're not going to
188       // activate it now.
189       if (!this.searchActive_ && !active)
190         return;
192       this.searchActive_ = active;
194       if (active) {
195         var hash = location.hash;
196         if (hash) {
197           this.searchField.value =
198               decodeURIComponent(hash.slice(1).replace(/\+/g, ' '));
199         } else if (!this.searchField.value) {
200           // This should only happen if the user goes directly to
201           // chrome://settings-frame/search
202           OptionsPage.showDefaultPage();
203           return;
204         }
206         // Move 'advanced' sections into the main settings page to allow
207         // searching.
208         if (!this.advancedSections_) {
209           this.advancedSections_ =
210               $('advanced-settings-container').querySelectorAll('section');
211           for (var i = 0, section; section = this.advancedSections_[i]; i++)
212             $('settings').appendChild(section);
213         }
214       }
216       var pagesToSearch = this.getSearchablePages_();
217       for (var key in pagesToSearch) {
218         var page = pagesToSearch[key];
220         if (!active)
221           page.visible = false;
223         // Update the visible state of all top-level elements that are not
224         // sections (ie titles, button strips).  We do this before changing
225         // the page visibility to avoid excessive re-draw.
226         for (var i = 0, childDiv; childDiv = page.pageDiv.children[i]; i++) {
227           if (active) {
228             if (childDiv.tagName != 'SECTION')
229               childDiv.classList.add('search-hidden');
230           } else {
231             childDiv.classList.remove('search-hidden');
232           }
233         }
235         if (active) {
236           // When search is active, remove the 'hidden' tag.  This tag may have
237           // been added by the OptionsPage.
238           page.pageDiv.hidden = false;
239         }
240       }
242       if (active) {
243         this.setSearchText_(this.searchField.value);
244         this.searchField.focus();
245       } else {
246         // After hiding all page content, remove any search results.
247         this.unhighlightMatches_();
248         this.removeSearchBubbles_();
250         // Move 'advanced' sections back into their original container.
251         if (this.advancedSections_) {
252           for (var i = 0, section; section = this.advancedSections_[i]; i++)
253             $('advanced-settings-container').appendChild(section);
254           this.advancedSections_ = null;
255         }
256       }
257     },
259     /**
260      * Set the current search criteria.
261      * @param {string} text Search text.
262      * @private
263      */
264     setSearchText_: function(text) {
265       // Prevent recursive execution of this method.
266       if (this.insideSetSearchText_) return;
267       this.insideSetSearchText_ = true;
269       // Cleanup the search query string.
270       text = SearchPage.canonicalizeQuery(text);
272       // Set the hash on the current page, and the enclosing uber page
273       var hash = text ? '#' + encodeURIComponent(text) : '';
274       var path = text ? this.name : '';
275       window.location.hash = hash;
276       uber.invokeMethodOnParent('setPath', {path: path + hash});
278       // Toggle the search page if necessary.
279       if (text) {
280         if (!this.searchActive_)
281           OptionsPage.showPageByName(this.name, false);
282       } else {
283         if (this.searchActive_)
284           OptionsPage.showPageByName(OptionsPage.getDefaultPage().name, false);
286         this.insideSetSearchText_ = false;
287         return;
288       }
290       var foundMatches = false;
292       // Remove any prior search results.
293       this.unhighlightMatches_();
294       this.removeSearchBubbles_();
296       var pagesToSearch = this.getSearchablePages_();
297       for (var key in pagesToSearch) {
298         var page = pagesToSearch[key];
299         var elements = page.pageDiv.querySelectorAll('section');
300         for (var i = 0, node; node = elements[i]; i++) {
301           node.classList.add('search-hidden');
302         }
303       }
305       var bubbleControls = [];
307       // Generate search text by applying lowercase and escaping any characters
308       // that would be problematic for regular expressions.
309       var searchText =
310           text.toLowerCase().replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&');
311       // Generate a regular expression for hilighting search terms.
312       var regExp = new RegExp('(' + searchText + ')', 'ig');
314       if (searchText.length) {
315         // Search all top-level sections for anchored string matches.
316         for (var key in pagesToSearch) {
317           var page = pagesToSearch[key];
318           var elements =
319               page.pageDiv.querySelectorAll('section');
320           for (var i = 0, node; node = elements[i]; i++) {
321             if (this.highlightMatches_(regExp, node)) {
322               node.classList.remove('search-hidden');
323               if (!node.hidden)
324                 foundMatches = true;
325             }
326           }
327         }
329         // Search all sub-pages, generating an array of top-level sections that
330         // we need to make visible.
331         var subPagesToSearch = this.getSearchableSubPages_();
332         var control, node;
333         for (var key in subPagesToSearch) {
334           var page = subPagesToSearch[key];
335           if (this.highlightMatches_(regExp, page.pageDiv)) {
336             this.revealAssociatedSections_(page);
338             bubbleControls =
339                 bubbleControls.concat(this.getAssociatedControls_(page));
341             foundMatches = true;
342           }
343         }
344       }
346       // Configure elements on the search results page based on search results.
347       $('searchPageNoMatches').hidden = foundMatches;
349       // Create search balloons for sub-page results.
350       length = bubbleControls.length;
351       for (var i = 0; i < length; i++)
352         this.createSearchBubble_(bubbleControls[i], text);
354       // Cleanup the recursion-prevention variable.
355       this.insideSetSearchText_ = false;
356     },
358     /**
359      * Reveal the associated section for |subpage|, as well as the one for its
360      * |parentPage|, and its |parentPage|'s |parentPage|, etc.
361      * @private
362      */
363     revealAssociatedSections_: function(subpage) {
364       for (var page = subpage; page; page = page.parentPage) {
365         var section = page.associatedSection;
366         if (section)
367           section.classList.remove('search-hidden');
368       }
369     },
371     /**
372      * @return {!Array.<HTMLElement>} all the associated controls for |subpage|,
373      * including |subpage.associatedControls| as well as any controls on parent
374      * pages that are indirectly necessary to get to the subpage.
375      * @private
376      */
377     getAssociatedControls_: function(subpage) {
378       var controls = [];
379       for (var page = subpage; page; page = page.parentPage) {
380         if (page.associatedControls)
381           controls = controls.concat(page.associatedControls);
382       }
383       return controls;
384     },
386     /**
387      * Wraps matches in spans.
388      * @param {RegExp} regExp The search query (in regexp form).
389      * @param {Element} element An HTML container element to recursively search
390      *     within.
391      * @return {boolean} true if the element was changed.
392      * @private
393      */
394     highlightMatches_: function(regExp, element) {
395       var found = false;
396       var div, child, tmp;
398       // Walk the tree, searching each TEXT node.
399       var walker = document.createTreeWalker(element,
400                                              NodeFilter.SHOW_TEXT,
401                                              null,
402                                              false);
403       var node = walker.nextNode();
404       while (node) {
405         var textContent = node.nodeValue;
406         // Perform a search and replace on the text node value.
407         var split = textContent.split(regExp);
408         if (split.length > 1) {
409           found = true;
410           var nextNode = walker.nextNode();
411           var parentNode = node.parentNode;
412           // Use existing node as placeholder to determine where to insert the
413           // replacement content.
414           for (var i = 0; i < split.length; ++i) {
415             if (i % 2 == 0) {
416               parentNode.insertBefore(document.createTextNode(split[i]), node);
417             } else {
418               var span = document.createElement('span');
419               span.className = 'search-highlighted';
420               span.textContent = split[i];
421               parentNode.insertBefore(span, node);
422             }
423           }
424           // Remove old node.
425           parentNode.removeChild(node);
426           node = nextNode;
427         } else {
428           node = walker.nextNode();
429         }
430       }
432       return found;
433     },
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');
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;
448         // Replace the highlight element with the first child (the text node).
449         parent.replaceChild(node.firstChild, node);
451         // Normalize the parent so that multiple text nodes will be combined.
452         parent.normalize();
453       }
454     },
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;
469       var parent = element.parentElement;
470       if (parent) {
471         var bubble = new SearchBubble(text);
472         bubble.attachTo(element);
473         bubble.updatePosition();
474       }
475     },
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     },
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     },
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     },
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;
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   };
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   };
567   // Export
568   return {
569     SearchPage: SearchPage
570   };