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