Home | History | Annotate | Download | only in options
      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     /** @override */
    156     get sticky() {
    157       return true;
    158     },
    159 
    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     },
    169 
    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     },
    180 
    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;
    191 
    192       this.searchActive_ = active;
    193 
    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         }
    205 
    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       }
    215 
    216       var pagesToSearch = this.getSearchablePages_();
    217       for (var key in pagesToSearch) {
    218         var page = pagesToSearch[key];
    219 
    220         if (!active)
    221           page.visible = false;
    222 
    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         }
    234 
    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       }
    241 
    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_();
    249 
    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     },
    258 
    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;
    268 
    269       // Cleanup the search query string.
    270       text = SearchPage.canonicalizeQuery(text);
    271 
    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});
    277 
    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);
    285 
    286         this.insideSetSearchText_ = false;
    287         return;
    288       }
    289 
    290       var foundMatches = false;
    291 
    292       // Remove any prior search results.
    293       this.unhighlightMatches_();
    294       this.removeSearchBubbles_();
    295 
    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       }
    304 
    305       var bubbleControls = [];
    306 
    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');
    313 
    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         }
    328 
    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);
    337 
    338             bubbleControls =
    339                 bubbleControls.concat(this.getAssociatedControls_(page));
    340 
    341             foundMatches = true;
    342           }
    343         }
    344       }
    345 
    346       // Configure elements on the search results page based on search results.
    347       $('searchPageNoMatches').hidden = foundMatches;
    348 
    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);
    353 
    354       // Cleanup the recursion-prevention variable.
    355       this.insideSetSearchText_ = false;
    356     },
    357 
    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     },
    370 
    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     },
    385 
    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;
    397 
    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       }
    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 });
    573