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