Upstream version 10.39.225.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    * @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 });