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. Only do
    273       // this if the page is not current. See https://crbug.com/401004.
    274       var hash = text ? '#' + encodeURIComponent(text) : '';
    275       var path = text ? this.name : '';
    276       if (location.hash != hash || location.pathname != '/' + path)
    277         uber.pushState({}, path + hash);
    278 
    279       // Toggle the search page if necessary.
    280       if (text) {
    281         if (!this.searchActive_)
    282           OptionsPage.showPageByName(this.name, false);
    283       } else {
    284         if (this.searchActive_)
    285           OptionsPage.showPageByName(OptionsPage.getDefaultPage().name, false);
    286 
    287         this.insideSetSearchText_ = false;
    288         return;
    289       }
    290 
    291       var foundMatches = false;
    292 
    293       // Remove any prior search results.
    294       this.unhighlightMatches_();
    295       this.removeSearchBubbles_();
    296 
    297       var pagesToSearch = this.getSearchablePages_();
    298       for (var key in pagesToSearch) {
    299         var page = pagesToSearch[key];
    300         var elements = page.pageDiv.querySelectorAll('section');
    301         for (var i = 0, node; node = elements[i]; i++) {
    302           node.classList.add('search-hidden');
    303         }
    304       }
    305 
    306       var bubbleControls = [];
    307 
    308       // Generate search text by applying lowercase and escaping any characters
    309       // that would be problematic for regular expressions.
    310       var searchText =
    311           text.toLowerCase().replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&');
    312       // Generate a regular expression for hilighting search terms.
    313       var regExp = new RegExp('(' + searchText + ')', 'ig');
    314 
    315       if (searchText.length) {
    316         // Search all top-level sections for anchored string matches.
    317         for (var key in pagesToSearch) {
    318           var page = pagesToSearch[key];
    319           var elements =
    320               page.pageDiv.querySelectorAll('section');
    321           for (var i = 0, node; node = elements[i]; i++) {
    322             if (this.highlightMatches_(regExp, node)) {
    323               node.classList.remove('search-hidden');
    324               if (!node.hidden)
    325                 foundMatches = true;
    326             }
    327           }
    328         }
    329 
    330         // Search all sub-pages, generating an array of top-level sections that
    331         // we need to make visible.
    332         var subPagesToSearch = this.getSearchableSubPages_();
    333         var control, node;
    334         for (var key in subPagesToSearch) {
    335           var page = subPagesToSearch[key];
    336           if (this.highlightMatches_(regExp, page.pageDiv)) {
    337             this.revealAssociatedSections_(page);
    338 
    339             bubbleControls =
    340                 bubbleControls.concat(this.getAssociatedControls_(page));
    341 
    342             foundMatches = true;
    343           }
    344         }
    345       }
    346 
    347       // Configure elements on the search results page based on search results.
    348       $('searchPageNoMatches').hidden = foundMatches;
    349 
    350       // Create search balloons for sub-page results.
    351       length = bubbleControls.length;
    352       for (var i = 0; i < length; i++)
    353         this.createSearchBubble_(bubbleControls[i], text);
    354 
    355       // Cleanup the recursion-prevention variable.
    356       this.insideSetSearchText_ = false;
    357     },
    358 
    359     /**
    360      * Reveal the associated section for |subpage|, as well as the one for its
    361      * |parentPage|, and its |parentPage|'s |parentPage|, etc.
    362      * @private
    363      */
    364     revealAssociatedSections_: function(subpage) {
    365       for (var page = subpage; page; page = page.parentPage) {
    366         var section = page.associatedSection;
    367         if (section)
    368           section.classList.remove('search-hidden');
    369       }
    370     },
    371 
    372     /**
    373      * @return {!Array.<HTMLElement>} all the associated controls for |subpage|,
    374      * including |subpage.associatedControls| as well as any controls on parent
    375      * pages that are indirectly necessary to get to the subpage.
    376      * @private
    377      */
    378     getAssociatedControls_: function(subpage) {
    379       var controls = [];
    380       for (var page = subpage; page; page = page.parentPage) {
    381         if (page.associatedControls)
    382           controls = controls.concat(page.associatedControls);
    383       }
    384       return controls;
    385     },
    386 
    387     /**
    388      * Wraps matches in spans.
    389      * @param {RegExp} regExp The search query (in regexp form).
    390      * @param {Element} element An HTML container element to recursively search
    391      *     within.
    392      * @return {boolean} true if the element was changed.
    393      * @private
    394      */
    395     highlightMatches_: function(regExp, element) {
    396       var found = false;
    397       var div, child, tmp;
    398 
    399       // Walk the tree, searching each TEXT node.
    400       var walker = document.createTreeWalker(element,
    401                                              NodeFilter.SHOW_TEXT,
    402                                              null,
    403                                              false);
    404       var node = walker.nextNode();
    405       while (node) {
    406         var textContent = node.nodeValue;
    407         // Perform a search and replace on the text node value.
    408         var split = textContent.split(regExp);
    409         if (split.length > 1) {
    410           found = true;
    411           var nextNode = walker.nextNode();
    412           var parentNode = node.parentNode;
    413           // Use existing node as placeholder to determine where to insert the
    414           // replacement content.
    415           for (var i = 0; i < split.length; ++i) {
    416             if (i % 2 == 0) {
    417               parentNode.insertBefore(document.createTextNode(split[i]), node);
    418             } else {
    419               var span = document.createElement('span');
    420               span.className = 'search-highlighted';
    421               span.textContent = split[i];
    422               parentNode.insertBefore(span, node);
    423             }
    424           }
    425           // Remove old node.
    426           parentNode.removeChild(node);
    427           node = nextNode;
    428         } else {
    429           node = walker.nextNode();
    430         }
    431       }
    432 
    433       return found;
    434     },
    435 
    436     /**
    437      * Removes all search highlight tags from the document.
    438      * @private
    439      */
    440     unhighlightMatches_: function() {
    441       // Find all search highlight elements.
    442       var elements = document.querySelectorAll('.search-highlighted');
    443 
    444       // For each element, remove the highlighting.
    445       var parent, i;
    446       for (var i = 0, node; node = elements[i]; i++) {
    447         parent = node.parentNode;
    448 
    449         // Replace the highlight element with the first child (the text node).
    450         parent.replaceChild(node.firstChild, node);
    451 
    452         // Normalize the parent so that multiple text nodes will be combined.
    453         parent.normalize();
    454       }
    455     },
    456 
    457     /**
    458      * Creates a search result bubble attached to an element.
    459      * @param {Element} element An HTML element, usually a button.
    460      * @param {string} text A string to show in the bubble.
    461      * @private
    462      */
    463     createSearchBubble_: function(element, text) {
    464       // avoid appending multiple bubbles to a button.
    465       var sibling = element.previousElementSibling;
    466       if (sibling && (sibling.classList.contains('search-bubble') ||
    467                       sibling.classList.contains('search-bubble-wrapper')))
    468         return;
    469 
    470       var parent = element.parentElement;
    471       if (parent) {
    472         var bubble = new SearchBubble(text);
    473         bubble.attachTo(element);
    474         bubble.updatePosition();
    475       }
    476     },
    477 
    478     /**
    479      * Removes all search match bubbles.
    480      * @private
    481      */
    482     removeSearchBubbles_: function() {
    483       var elements = document.querySelectorAll('.search-bubble');
    484       var length = elements.length;
    485       for (var i = 0; i < length; i++)
    486         elements[i].dispose();
    487     },
    488 
    489     /**
    490      * Builds a list of top-level pages to search.  Omits the search page and
    491      * all sub-pages.
    492      * @return {Array} An array of pages to search.
    493      * @private
    494      */
    495     getSearchablePages_: function() {
    496       var name, page, pages = [];
    497       for (name in OptionsPage.registeredPages) {
    498         if (name != this.name) {
    499           page = OptionsPage.registeredPages[name];
    500           if (!page.parentPage)
    501             pages.push(page);
    502         }
    503       }
    504       return pages;
    505     },
    506 
    507     /**
    508      * Builds a list of sub-pages (and overlay pages) to search.  Ignore pages
    509      * that have no associated controls, or whose controls are hidden.
    510      * @return {Array} An array of pages to search.
    511      * @private
    512      */
    513     getSearchableSubPages_: function() {
    514       var name, pageInfo, page, pages = [];
    515       for (name in OptionsPage.registeredPages) {
    516         page = OptionsPage.registeredPages[name];
    517         if (page.parentPage &&
    518             page.associatedSection &&
    519             !page.associatedSection.hidden) {
    520           pages.push(page);
    521         }
    522       }
    523       for (name in OptionsPage.registeredOverlayPages) {
    524         page = OptionsPage.registeredOverlayPages[name];
    525         if (page.associatedSection &&
    526             !page.associatedSection.hidden &&
    527             page.pageDiv != undefined) {
    528           pages.push(page);
    529         }
    530       }
    531       return pages;
    532     },
    533 
    534     /**
    535      * A function to handle key press events.
    536      * @return {Event} a keydown event.
    537      * @private
    538      */
    539     keyDownEventHandler_: function(event) {
    540       /** @const */ var ESCAPE_KEY_CODE = 27;
    541       /** @const */ var FORWARD_SLASH_KEY_CODE = 191;
    542 
    543       switch (event.keyCode) {
    544         case ESCAPE_KEY_CODE:
    545           if (event.target == this.searchField) {
    546             this.setSearchText_('');
    547             this.searchField.blur();
    548             event.stopPropagation();
    549             event.preventDefault();
    550           }
    551           break;
    552         case FORWARD_SLASH_KEY_CODE:
    553           if (!/INPUT|SELECT|BUTTON|TEXTAREA/.test(event.target.tagName) &&
    554               !event.ctrlKey && !event.altKey) {
    555             this.searchField.focus();
    556             event.stopPropagation();
    557             event.preventDefault();
    558           }
    559           break;
    560       }
    561     },
    562   };
    563 
    564   /**
    565    * Standardizes a user-entered text query by removing extra whitespace.
    566    * @param {string} The user-entered text.
    567    * @return {string} The trimmed query.
    568    */
    569   SearchPage.canonicalizeQuery = function(text) {
    570     // Trim beginning and ending whitespace.
    571     return text.replace(/^\s+|\s+$/g, '');
    572   };
    573 
    574   // Export
    575   return {
    576     SearchPage: SearchPage
    577   };
    578 
    579 });
    580