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