• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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
5cr.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