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