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 }); 593