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.
5 cr.define('options', function() {
6 /** @const */ var Page = cr.ui.pageManager.Page;
7 /** @const */ var PageManager = cr.ui.pageManager.PageManager;
10 * Encapsulated handling of a search bubble.
13 function SearchBubble(text) {
14 var el = cr.doc.createElement('div');
15 SearchBubble.decorate(el);
20 SearchBubble.decorate = function(el) {
21 el.__proto__ = SearchBubble.prototype;
25 SearchBubble.prototype = {
26 __proto__: HTMLDivElement.prototype,
28 decorate: function() {
29 this.className = 'search-bubble';
31 this.innards_ = cr.doc.createElement('div');
32 this.innards_.className = 'search-bubble-innards';
33 this.appendChild(this.innards_);
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);
43 * Sets the text message in the bubble.
44 * @param {string} text The text the bubble will show.
47 this.innards_.textContent = text;
51 * Attach the bubble to the element.
53 attachTo: function(element) {
54 var parent = element.parentElement;
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);
67 parent.insertBefore(this, element);
72 * Clear the interval timer and remove the element from the page.
75 clearInterval(this.intervalId);
77 var child = this.wrapper || this;
78 var parent = child.parentNode;
80 parent.removeChild(child);
84 * Update the position of the bubble. Called at creation time and then
85 * periodically while the bubble remains visible.
87 updatePosition: function() {
88 // This bubble is 'owned' by the next sibling.
89 var owner = (this.wrapper || this).nextSibling;
91 // If there isn't an offset parent, we have nothing to do.
92 if (!owner.offsetParent)
95 // Position the bubble below the location of the owner.
96 var left = owner.offsetLeft + owner.offsetWidth / 2 -
98 var top = owner.offsetTop + owner.offsetHeight;
100 // Update the position in the CSS. Cache the last values for
102 if (left != this.lastLeft) {
103 this.style.left = left + 'px';
104 this.lastLeft = left;
106 if (top != this.lastTop) {
107 this.style.top = top + 'px';
114 * Encapsulated handling of the search page.
117 function SearchPage() {
118 Page.call(this, 'search',
119 loadTimeData.getString('searchPageTabTitle'),
123 cr.addSingletonGetter(SearchPage);
125 SearchPage.prototype = {
126 // Inherit SearchPage from Page.
127 __proto__: Page.prototype,
130 * A boolean to prevent recursion. Used by setSearchText_().
134 insideSetSearchText_: false,
137 initializePage: function() {
138 Page.prototype.initializePage.call(this);
140 this.searchField = $('search-field');
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);
148 // Install handler for key presses.
149 document.addEventListener('keydown',
150 this.keyDownEventHandler_.bind(this));
159 * Called after this page has shown.
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);
169 * Called before this page will be hidden.
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
176 this.setSearchActive_(false);
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.
184 setSearchActive_: function(active) {
185 // It's fine to exit if search wasn't active and we're not going to
187 if (!this.searchActive_ && !active)
190 // Guest users should never have active search.
191 if (loadTimeData.getBoolean('profileIsGuest'))
194 this.searchActive_ = active;
197 var hash = location.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();
208 // Move 'advanced' sections into the main settings page to allow
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);
218 var pagesToSearch = this.getSearchablePages_();
219 for (var key in pagesToSearch) {
220 var page = pagesToSearch[key];
223 page.visible = false;
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++) {
230 if (childDiv.tagName != 'SECTION')
231 childDiv.classList.add('search-hidden');
233 childDiv.classList.remove('search-hidden');
238 // When search is active, remove the 'hidden' tag. This tag may have
239 // been added by the PageManager.
240 page.pageDiv.hidden = false;
245 this.setSearchText_(this.searchField.value);
246 this.searchField.focus();
248 // After hiding all page content, remove any search results.
249 this.unhighlightMatches_();
250 this.removeSearchBubbles_();
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;
262 * Set the current search criteria.
263 * @param {string} text Search text.
266 setSearchText_: function(text) {
267 // Guest users should never have search text.
268 if (loadTimeData.getBoolean('profileIsGuest'))
271 // Prevent recursive execution of this method.
272 if (this.insideSetSearchText_) return;
273 this.insideSetSearchText_ = true;
275 // Cleanup the search query string.
276 text = SearchPage.canonicalizeQuery(text);
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);
285 // Toggle the search page if necessary.
287 if (!this.searchActive_)
288 PageManager.showPageByName(this.name, false);
290 if (this.searchActive_)
291 PageManager.showDefaultPage(false);
293 this.insideSetSearchText_ = false;
297 var foundMatches = false;
299 // Remove any prior search results.
300 this.unhighlightMatches_();
301 this.removeSearchBubbles_();
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');
312 var bubbleControls = [];
314 // Generate search text by applying lowercase and escaping any characters
315 // that would be problematic for regular expressions.
317 text.toLowerCase().replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&');
318 // Generate a regular expression for hilighting search terms.
319 var regExp = new RegExp('(' + searchText + ')', 'ig');
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];
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');
336 // Search all sub-pages, generating an array of top-level sections that
337 // we need to make visible.
338 var subPagesToSearch = this.getSearchableSubPages_();
340 for (var key in subPagesToSearch) {
341 var page = subPagesToSearch[key];
342 if (this.highlightMatches_(regExp, page.pageDiv)) {
343 this.revealAssociatedSections_(page);
346 bubbleControls.concat(this.getAssociatedControls_(page));
353 // Configure elements on the search results page based on search results.
354 $('searchPageNoMatches').hidden = foundMatches;
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);
361 // Cleanup the recursion-prevention variable.
362 this.insideSetSearchText_ = false;
366 * Reveal the associated section for |subpage|, as well as the one for its
367 * |parentPage|, and its |parentPage|'s |parentPage|, etc.
370 revealAssociatedSections_: function(subpage) {
371 for (var page = subpage; page; page = page.parentPage) {
372 var section = page.associatedSection;
374 section.classList.remove('search-hidden');
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.
384 getAssociatedControls_: function(subpage) {
386 for (var page = subpage; page; page = page.parentPage) {
387 if (page.associatedControls)
388 controls = controls.concat(page.associatedControls);
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
398 * @return {boolean} true if the element was changed.
401 highlightMatches_: function(regExp, element) {
405 // Walk the tree, searching each TEXT node.
406 var walker = document.createTreeWalker(element,
407 NodeFilter.SHOW_TEXT,
410 var node = walker.nextNode();
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) {
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) {
423 parentNode.insertBefore(document.createTextNode(split[i]), node);
425 var span = document.createElement('span');
426 span.className = 'search-highlighted';
427 span.textContent = split[i];
428 parentNode.insertBefore(span, node);
432 parentNode.removeChild(node);
435 node = walker.nextNode();
443 * Removes all search highlight tags from the document.
446 unhighlightMatches_: function() {
447 // Find all search highlight elements.
448 var elements = document.querySelectorAll('.search-highlighted');
450 // For each element, remove the highlighting.
452 for (var i = 0, node; node = elements[i]; i++) {
453 parent = node.parentNode;
455 // Replace the highlight element with the first child (the text node).
456 parent.replaceChild(node.firstChild, node);
458 // Normalize the parent so that multiple text nodes will be combined.
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.
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')))
476 var parent = element.parentElement;
478 var bubble = new SearchBubble(text);
479 bubble.attachTo(element);
480 bubble.updatePosition();
485 * Removes all search match bubbles.
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();
496 * Builds a list of top-level pages to search. Omits the search page and
498 * @return {Array} An array of pages to search.
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)
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.
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) {
529 for (name in PageManager.registeredOverlayPages) {
530 page = PageManager.registeredOverlayPages[name];
531 if (page.associatedSection &&
532 !page.associatedSection.hidden &&
533 page.pageDiv != undefined) {
541 * A function to handle key press events.
542 * @return {Event} a keydown event.
545 keyDownEventHandler_: function(event) {
546 /** @const */ var ESCAPE_KEY_CODE = 27;
547 /** @const */ var FORWARD_SLASH_KEY_CODE = 191;
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();
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();
571 * Standardizes a user-entered text query by removing extra whitespace.
572 * @param {string} The user-entered text.
573 * @return {string} The trimmed query.
575 SearchPage.canonicalizeQuery = function(text) {
576 // Trim beginning and ending whitespace.
577 return text.replace(/^\s+|\s+$/g, '');
582 SearchPage: SearchPage