search_page.js revision 72a454cd3513ac24fbdd0e0cb9ad70b86a99b801
1// Copyright (c) 2011 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 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.textContent = 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      // We create a timer to periodically update the position of the bubbles.
31      // While this isn't all that desirable, it's the only sure-fire way of
32      // making sure the bubbles stay in the correct location as sections
33      // may dynamically change size at any time.
34      var self = this;
35      this.intervalId = setInterval(this.updatePosition.bind(this), 250);
36    },
37
38    /**
39     * Clear the interval timer and remove the element from the page.
40     */
41    dispose: function() {
42      clearInterval(this.intervalId);
43
44      var parent = this.parentNode;
45      if (parent)
46        parent.removeChild(this);
47    },
48
49    /**
50     * Update the position of the bubble.  Called at creation time and then
51     * periodically while the bubble remains visible.
52     */
53    updatePosition: function() {
54      // This bubble is 'owned' by the next sibling.
55      var owner = this.nextSibling;
56
57      // If there isn't an offset parent, we have nothing to do.
58      if (!owner.offsetParent)
59        return;
60
61      // Position the bubble below the location of the owner.
62      var left = owner.offsetLeft + owner.offsetWidth / 2 -
63          this.offsetWidth / 2;
64      var top = owner.offsetTop + owner.offsetHeight;
65
66      // Update the position in the CSS.  Cache the last values for
67      // best performance.
68      if (left != this.lastLeft) {
69        this.style.left = left + 'px';
70        this.lastLeft = left;
71      }
72      if (top != this.lastTop) {
73        this.style.top = top + 'px';
74        this.lastTop = top;
75      }
76    }
77  }
78
79  /**
80   * Encapsulated handling of the search page.
81   * @constructor
82   */
83  function SearchPage() {
84    OptionsPage.call(this, 'search', templateData.searchPage, 'searchPage');
85    this.searchActive = false;
86  }
87
88  cr.addSingletonGetter(SearchPage);
89
90  SearchPage.prototype = {
91    // Inherit SearchPage from OptionsPage.
92    __proto__: OptionsPage.prototype,
93
94    /**
95     * Initialize the page.
96     */
97    initializePage: function() {
98      // Call base class implementation to start preference initialization.
99      OptionsPage.prototype.initializePage.call(this);
100
101      var self = this;
102
103      // Create a search field element.
104      var searchField = document.createElement('input');
105      searchField.id = 'search-field';
106      searchField.type = 'search';
107      searchField.setAttribute('autosave', 'org.chromium.options.search');
108      searchField.setAttribute('results', '10');
109      searchField.setAttribute('incremental', 'true');
110      this.searchField = searchField;
111
112      // Replace the contents of the navigation tab with the search field.
113      self.tab.textContent = '';
114      self.tab.appendChild(searchField);
115      self.tab.onclick = self.tab.onkeypress = undefined;
116
117      // Handle search events. (No need to throttle, WebKit's search field
118      // will do that automatically.)
119      searchField.onsearch = function(e) {
120        self.setSearchText_(SearchPage.canonicalizeQuery(this.value));
121      };
122
123      // We update the history stack every time the search field blurs. This way
124      // we get a history entry for each search, roughly, but not each letter
125      // typed.
126      searchField.onblur = function(e) {
127        var query = SearchPage.canonicalizeQuery(searchField.value);
128        if (!query)
129          return;
130
131        // Don't push the same page onto the history stack more than once (if
132        // the user clicks in the search field and away several times).
133        var currentHash = location.hash;
134        var newHash = '#' + escape(query);
135        if (currentHash == newHash)
136          return;
137
138        // If there is no hash on the current URL, the history entry has no
139        // search query. Replace the history entry with no search with an entry
140        // that does have a search. Otherwise, add it onto the history stack.
141        var historyFunction = currentHash ? window.history.pushState :
142                                            window.history.replaceState;
143        historyFunction.call(
144            window.history,
145            {pageName: self.name},
146            self.title,
147            '/' + self.name + newHash);
148      };
149
150      // Install handler for key presses.
151      document.addEventListener('keydown',
152                                this.keyDownEventHandler_.bind(this));
153
154      // Focus the search field by default.
155      searchField.focus();
156    },
157
158    /**
159     * @inheritDoc
160     */
161    get sticky() {
162      return true;
163    },
164
165    /**
166     * Called after this page has shown.
167     */
168    didShowPage: function() {
169      // This method is called by the Options page after all pages have
170      // had their visibilty attribute set.  At this point we can perform the
171      // search specific DOM manipulation.
172      this.setSearchActive_(true);
173    },
174
175    /**
176     * Called before this page will be hidden.
177     */
178    willHidePage: function() {
179      // This method is called by the Options page before all pages have
180      // their visibilty attribute set.  Before that happens, we need to
181      // undo the search specific DOM manipulation that was performed in
182      // didShowPage.
183      this.setSearchActive_(false);
184    },
185
186    /**
187     * Update the UI to reflect whether we are in a search state.
188     * @param {boolean} active True if we are on the search page.
189     * @private
190     */
191    setSearchActive_: function(active) {
192      // It's fine to exit if search wasn't active and we're not going to
193      // activate it now.
194      if (!this.searchActive_ && !active)
195        return;
196
197      this.searchActive_ = active;
198
199      if (active) {
200        var hash = location.hash;
201        if (hash)
202          this.searchField.value = unescape(hash.slice(1));
203      } else {
204          // Just wipe out any active search text since it's no longer relevant.
205        this.searchField.value = '';
206      }
207
208      var pagesToSearch = this.getSearchablePages_();
209      for (var key in pagesToSearch) {
210        var page = pagesToSearch[key];
211
212        if (!active)
213          page.visible = false;
214
215        // Update the visible state of all top-level elements that are not
216        // sections (ie titles, button strips).  We do this before changing
217        // the page visibility to avoid excessive re-draw.
218        for (var i = 0, childDiv; childDiv = page.pageDiv.children[i]; i++) {
219          if (active) {
220            if (childDiv.tagName != 'SECTION')
221              childDiv.classList.add('search-hidden');
222          } else {
223            childDiv.classList.remove('search-hidden');
224          }
225        }
226
227        if (active) {
228          // When search is active, remove the 'hidden' tag.  This tag may have
229          // been added by the OptionsPage.
230          page.pageDiv.classList.remove('hidden');
231        }
232      }
233
234      if (active) {
235        this.setSearchText_(this.searchField.value);
236      } else {
237        // After hiding all page content, remove any search results.
238        this.unhighlightMatches_();
239        this.removeSearchBubbles_();
240      }
241    },
242
243    /**
244     * Set the current search criteria.
245     * @param {string} text Search text.
246     * @private
247     */
248    setSearchText_: function(text) {
249      // Toggle the search page if necessary.
250      if (text.length) {
251        if (!this.searchActive_)
252          OptionsPage.navigateToPage(this.name);
253      } else {
254        if (this.searchActive_)
255          OptionsPage.showDefaultPage();
256        return;
257      }
258
259      var foundMatches = false;
260      var bubbleControls = [];
261
262      // Remove any prior search results.
263      this.unhighlightMatches_();
264      this.removeSearchBubbles_();
265
266      // Generate search text by applying lowercase and escaping any characters
267      // that would be problematic for regular expressions.
268      var searchText =
269          text.toLowerCase().replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&');
270
271      // Generate a regular expression and replace string for hilighting
272      // search terms.
273      var regEx = new RegExp('(' + searchText + ')', 'ig');
274      var replaceString = '<span class="search-highlighted">$1</span>';
275
276      // Initialize all sections.  If the search string matches a title page,
277      // show sections for that page.
278      var page, pageMatch, childDiv, length;
279      var pagesToSearch = this.getSearchablePages_();
280      for (var key in pagesToSearch) {
281        page = pagesToSearch[key];
282        pageMatch = false;
283        if (searchText.length) {
284          pageMatch = this.performReplace_(regEx, replaceString, page.tab);
285        }
286        if (pageMatch)
287          foundMatches = true;
288        for (var i = 0, childDiv; childDiv = page.pageDiv.children[i]; i++) {
289          if (childDiv.tagName == 'SECTION') {
290            if (pageMatch) {
291              childDiv.classList.remove('search-hidden');
292            } else {
293              childDiv.classList.add('search-hidden');
294            }
295          }
296        }
297      }
298
299      if (searchText.length) {
300        // Search all top-level sections for anchored string matches.
301        for (var key in pagesToSearch) {
302          page = pagesToSearch[key];
303          for (var i = 0, childDiv; childDiv = page.pageDiv.children[i]; i++) {
304            if (childDiv.tagName == 'SECTION' &&
305                this.performReplace_(regEx, replaceString, childDiv)) {
306              childDiv.classList.remove('search-hidden');
307              foundMatches = true;
308            }
309          }
310        }
311
312        // Search all sub-pages, generating an array of top-level sections that
313        // we need to make visible.
314        var subPagesToSearch = this.getSearchableSubPages_();
315        var control, node;
316        for (var key in subPagesToSearch) {
317          page = subPagesToSearch[key];
318          if (this.performReplace_(regEx, replaceString, page.pageDiv)) {
319            // Reveal the section for this search result.
320            section = page.associatedSection;
321            if (section)
322              section.classList.remove('search-hidden');
323
324            // Identify any controls that should have bubbles.
325            var controls = page.associatedControls;
326            if (controls) {
327              length = controls.length;
328              for (var i = 0; i < length; i++)
329                bubbleControls.push(controls[i]);
330            }
331
332            foundMatches = true;
333          }
334        }
335      }
336
337      // Configure elements on the search results page based on search results.
338      if (foundMatches)
339        $('searchPageNoMatches').classList.add('search-hidden');
340      else
341        $('searchPageNoMatches').classList.remove('search-hidden');
342
343      // Create search balloons for sub-page results.
344      length = bubbleControls.length;
345      for (var i = 0; i < length; i++)
346        this.createSearchBubble_(bubbleControls[i], text);
347    },
348
349    /**
350     * Performs a string replacement based on a regex and replace string.
351     * @param {RegEx} regex A regular expression for finding search matches.
352     * @param {String} replace A string to apply the replace operation.
353     * @param {Element} element An HTML container element.
354     * @returns {Boolean} true if the element was changed.
355     * @private
356     */
357    performReplace_: function(regex, replace, element) {
358      var found = false;
359      var div, child, tmp;
360
361      // Walk the tree, searching each TEXT node.
362      var walker = document.createTreeWalker(element,
363                                             NodeFilter.SHOW_TEXT,
364                                             null,
365                                             false);
366      var node = walker.nextNode();
367      while (node) {
368        // Perform a search and replace on the text node value.
369        var newValue = node.nodeValue.replace(regex, replace);
370        if (newValue != node.nodeValue) {
371          // The text node has changed so that means we found at least one
372          // match.
373          found = true;
374
375          // Create a temporary div element and set the innerHTML to the new
376          // value.
377          div = document.createElement('div');
378          div.innerHTML = newValue;
379
380          // Insert all the child nodes of the temporary div element into the
381          // document, before the original node.
382          child = div.firstChild;
383          while (child = div.firstChild) {
384            node.parentNode.insertBefore(child, node);
385          };
386
387          // Delete the old text node and advance the walker to the next
388          // node.
389          tmp = node;
390          node = walker.nextNode();
391          tmp.parentNode.removeChild(tmp);
392        } else {
393          node = walker.nextNode();
394        }
395      }
396
397      return found;
398    },
399
400    /**
401     * Removes all search highlight tags from the document.
402     * @private
403     */
404    unhighlightMatches_: function() {
405      // Find all search highlight elements.
406      var elements = document.querySelectorAll('.search-highlighted');
407
408      // For each element, remove the highlighting.
409      var parent, i;
410      for (var i = 0, node; node = elements[i]; i++) {
411        parent = node.parentNode;
412
413        // Replace the highlight element with the first child (the text node).
414        parent.replaceChild(node.firstChild, node);
415
416        // Normalize the parent so that multiple text nodes will be combined.
417        parent.normalize();
418      }
419    },
420
421    /**
422     * Creates a search result bubble attached to an element.
423     * @param {Element} element An HTML element, usually a button.
424     * @param {string} text A string to show in the bubble.
425     * @private
426     */
427    createSearchBubble_: function(element, text) {
428      // avoid appending multiple ballons to a button.
429      var sibling = element.previousElementSibling;
430      if (sibling && sibling.classList.contains('search-bubble'))
431        return;
432
433      var parent = element.parentElement;
434      if (parent) {
435        var bubble = new SearchBubble(text);
436        parent.insertBefore(bubble, element);
437        bubble.updatePosition();
438      }
439    },
440
441    /**
442     * Removes all search match bubbles.
443     * @private
444     */
445    removeSearchBubbles_: function() {
446      var elements = document.querySelectorAll('.search-bubble');
447      var length = elements.length;
448      for (var i = 0; i < length; i++)
449        elements[i].dispose();
450    },
451
452    /**
453     * Builds a list of top-level pages to search.  Omits the search page and
454     * all sub-pages.
455     * @returns {Array} An array of pages to search.
456     * @private
457     */
458    getSearchablePages_: function() {
459      var name, page, pages = [];
460      for (name in OptionsPage.registeredPages) {
461        if (name != this.name) {
462          page = OptionsPage.registeredPages[name];
463          if (!page.parentPage)
464            pages.push(page);
465        }
466      }
467      return pages;
468    },
469
470    /**
471     * Builds a list of sub-pages (and overlay pages) to search.  Ignore pages
472     * that have no associated controls.
473     * @returns {Array} An array of pages to search.
474     * @private
475     */
476    getSearchableSubPages_: function() {
477      var name, pageInfo, page, pages = [];
478      for (name in OptionsPage.registeredPages) {
479        page = OptionsPage.registeredPages[name];
480        if (page.parentPage && page.associatedSection)
481          pages.push(page);
482      }
483      for (name in OptionsPage.registeredOverlayPages) {
484        page = OptionsPage.registeredOverlayPages[name];
485        if (page.associatedSection && page.pageDiv != undefined)
486          pages.push(page);
487      }
488      return pages;
489    },
490
491    /**
492     * A function to handle key press events.
493     * @return {Event} a keydown event.
494     * @private
495     */
496    keyDownEventHandler_: function(event) {
497      // Focus the search field on an unused forward-slash.
498      if (event.keyCode == 191 &&
499          !/INPUT|SELECT|BUTTON|TEXTAREA/.test(event.target.tagName)) {
500        this.searchField.focus();
501        event.stopPropagation();
502        event.preventDefault();
503      }
504    },
505  };
506
507  /**
508   * Standardizes a user-entered text query by removing extra whitespace.
509   * @param {string} The user-entered text.
510   * @return {string} The trimmed query.
511   */
512  SearchPage.canonicalizeQuery = function(text) {
513    // Trim beginning and ending whitespace.
514    return text.replace(/^\s+|\s+$/g, '');
515  };
516
517  // Export
518  return {
519    SearchPage: SearchPage
520  };
521
522});
523