1// Copyright 2014 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/** 6 * @fileoverview JavaScript for poppup up a search widget and performing 7 * search within a page. 8 */ 9 10goog.provide('cvox.SearchWidget'); 11 12goog.require('cvox.AbstractEarcons'); 13goog.require('cvox.ApiImplementation'); 14goog.require('cvox.ChromeVox'); 15goog.require('cvox.CursorSelection'); 16goog.require('cvox.NavigationManager'); 17goog.require('cvox.Widget'); 18 19 20/** 21 * Initializes the search widget. 22 * @constructor 23 * @extends {cvox.Widget} 24 */ 25cvox.SearchWidget = function() { 26 /** 27 * @type {Element} 28 * @private 29 */ 30 this.containerNode_ = null; 31 32 /** 33 * @type {Element} 34 * @private 35 */ 36 this.txtNode_ = null; 37 38 /** 39 * @type {string} 40 * @const 41 * @private 42 */ 43 this.PROMPT_ = 'Search:'; 44 45 /** 46 * @type {boolean} 47 * @private 48 */ 49 this.caseSensitive_ = false; 50 51 /** 52 * @type {boolean} 53 * @private 54 */ 55 this.hasMatch_ = false; 56 goog.base(this); 57}; 58goog.inherits(cvox.SearchWidget, cvox.Widget); 59goog.addSingletonGetter(cvox.SearchWidget); 60 61 62/** 63 * @override 64 */ 65cvox.SearchWidget.prototype.show = function() { 66 goog.base(this, 'show'); 67 this.active = true; 68 this.hasMatch_ = false; 69 cvox.ChromeVox.navigationManager.setGranularity( 70 cvox.NavigationShifter.GRANULARITIES.OBJECT, true, false); 71 72 // Always start search forward. 73 cvox.ChromeVox.navigationManager.setReversed(false); 74 75 // During profiling, NavigationHistory was found to have a serious performance 76 // impact on search. 77 this.focusRecovery_ = cvox.ChromeVox.navigationManager.getFocusRecovery(); 78 cvox.ChromeVox.navigationManager.setFocusRecovery(false); 79 80 var containerNode = this.createContainerNode_(); 81 this.containerNode_ = containerNode; 82 83 var overlayNode = this.createOverlayNode_(); 84 containerNode.appendChild(overlayNode); 85 86 var promptNode = document.createElement('span'); 87 promptNode.innerHTML = this.PROMPT_; 88 overlayNode.appendChild(promptNode); 89 90 this.txtNode_ = this.createTextAreaNode_(); 91 92 overlayNode.appendChild(this.txtNode_); 93 94 document.body.appendChild(containerNode); 95 96 this.txtNode_.focus(); 97 98 window.setTimeout(function() { 99 containerNode.style['opacity'] = '1.0'; 100 }, 0); 101}; 102 103 104/** 105 * @override 106 */ 107cvox.SearchWidget.prototype.hide = function(opt_noSync) { 108 if (this.isActive()) { 109 var containerNode = this.containerNode_; 110 containerNode.style.opacity = '0.0'; 111 window.setTimeout(function() { 112 document.body.removeChild(containerNode); 113 }, 1000); 114 this.txtNode_ = null; 115 cvox.SearchWidget.containerNode = null; 116 cvox.ChromeVox.navigationManager.setFocusRecovery(this.focusRecovery_); 117 this.active = false; 118 } 119 120 cvox.$m('choice_widget_exited'). 121 andPause(). 122 andMessage(this.getNameMsg()). 123 speakFlush(); 124 125 if (!this.hasMatch_ || !opt_noSync) { 126 cvox.ChromeVox.navigationManager.updateSelToArbitraryNode( 127 this.initialNode); 128 } 129 cvox.ChromeVoxEventSuspender.withSuspendedEvents(goog.bind( 130 cvox.ChromeVox.navigationManager.syncAll, 131 cvox.ChromeVox.navigationManager))(true); 132 cvox.ChromeVox.navigationManager.speakDescriptionArray( 133 cvox.ChromeVox.navigationManager.getDescription(), 134 cvox.AbstractTts.QUEUE_MODE_QUEUE, 135 null, 136 cvox.AbstractTts.PERSONALITY_ANNOUNCEMENT); 137 138 // Update on Braille too. 139 // TODO: Use line granularity in search so we can simply call 140 // cvox.ChromeVox.navigationManager.getBraille().write() instead. 141 var text = this.textFromCurrentDescription_(); 142 cvox.ChromeVox.braille.write(new cvox.NavBraille({ 143 text: text, 144 startIndex: 0, 145 endIndex: 0 146 })); 147 148 goog.base(this, 'hide', true); 149}; 150 151 152/** 153 * @override 154 */ 155cvox.SearchWidget.prototype.getNameMsg = function() { 156 return ['search_widget_intro']; 157}; 158 159 160/** 161 * @override 162 */ 163cvox.SearchWidget.prototype.getHelpMsg = function() { 164 return 'search_widget_intro_help'; 165}; 166 167 168/** 169 * @override 170 */ 171cvox.SearchWidget.prototype.onKeyDown = function(evt) { 172 if (!this.isActive()) { 173 return false; 174 } 175 var searchStr = this.txtNode_.value; 176 if (evt.keyCode == 8) { // Backspace 177 if (searchStr.length > 0) { 178 searchStr = searchStr.substring(0, searchStr.length - 1); 179 this.txtNode_.value = searchStr; 180 this.beginSearch_(searchStr); 181 } else { 182 cvox.ChromeVox.navigationManager.updateSelToArbitraryNode( 183 this.initialNode); 184 cvox.ChromeVox.navigationManager.syncAll(); 185 } 186 } else if (evt.keyCode == 40) { // Down arrow 187 this.next_(searchStr, false); 188 } else if (evt.keyCode == 38) { // Up arrow 189 this.next_(searchStr, true); 190 } else if (evt.keyCode == 13) { // Enter 191 this.hide(true); 192 } else if (evt.keyCode == 27) { // Escape 193 this.hide(false); 194 } else if (evt.ctrlKey && evt.keyCode == 67) { // ctrl + c 195 this.toggleCaseSensitivity_(); 196 } else { 197 return goog.base(this, 'onKeyDown', evt); 198 } 199 evt.preventDefault(); 200 evt.stopPropagation(); 201 return true; 202}; 203 204 205/** 206 * Adds the letter the user typed to the search string and updates the search. 207 * @override 208 */ 209cvox.SearchWidget.prototype.onKeyPress = function(evt) { 210 if (!this.isActive()) { 211 return false; 212 } 213 214 this.txtNode_.value += String.fromCharCode(evt.charCode); 215 var searchStr = this.txtNode_.value; 216 this.beginSearch_(searchStr); 217 evt.preventDefault(); 218 evt.stopPropagation(); 219 return true; 220}; 221 222 223/** 224 * Called when navigation occurs. 225 * Override this method to react to navigation caused by user input. 226 */ 227cvox.SearchWidget.prototype.onNavigate = function() { 228}; 229 230 231/** 232 * Gets the predicate to apply to every search. 233 * @return {?function(Array.<Node>)} A predicate; if null, no predicate applies. 234 */ 235cvox.SearchWidget.prototype.getPredicate = function() { 236 return null; 237}; 238 239 240/** 241 * Goes to the next or previous result. For use in AndroidVox. 242 * @param {boolean=} opt_reverse Whether to find the next result in reverse. 243 * @return {Array.<cvox.NavDescription>} The next result. 244 */ 245cvox.SearchWidget.prototype.nextResult = function(opt_reverse) { 246 if (!this.isActive()) { 247 return null; 248 } 249 var searchStr = this.txtNode_.value; 250 return this.next_(searchStr, opt_reverse); 251}; 252 253 254/** 255 * Create the container node for the search overlay. 256 * 257 * @return {!Element} The new element, not yet added to the document. 258 * @private 259 */ 260cvox.SearchWidget.prototype.createContainerNode_ = function() { 261 var containerNode = document.createElement('div'); 262 containerNode.id = 'cvox-search'; 263 containerNode.style['position'] = 'fixed'; 264 containerNode.style['top'] = '50%'; 265 containerNode.style['left'] = '50%'; 266 containerNode.style['-webkit-transition'] = 'all 0.3s ease-in'; 267 containerNode.style['opacity'] = '0.0'; 268 containerNode.style['z-index'] = '2147483647'; 269 containerNode.setAttribute('aria-hidden', 'true'); 270 return containerNode; 271}; 272 273 274/** 275 * Create the search overlay. This should be a child of the node 276 * returned from createContainerNode. 277 * 278 * @return {!Element} The new element, not yet added to the document. 279 * @private 280 */ 281cvox.SearchWidget.prototype.createOverlayNode_ = function() { 282 var overlayNode = document.createElement('div'); 283 overlayNode.style['position'] = 'relative'; 284 overlayNode.style['left'] = '-50%'; 285 overlayNode.style['top'] = '-40px'; 286 overlayNode.style['line-height'] = '1.2em'; 287 overlayNode.style['font-size'] = '20px'; 288 overlayNode.style['padding'] = '30px'; 289 overlayNode.style['min-width'] = '150px'; 290 overlayNode.style['color'] = '#fff'; 291 overlayNode.style['background-color'] = 'rgba(0, 0, 0, 0.7)'; 292 overlayNode.style['border-radius'] = '10px'; 293 return overlayNode; 294}; 295 296 297/** 298 * Create the text area node. This should be the child of the node 299 * returned from createOverlayNode. 300 * 301 * @return {!Element} The new element, not yet added to the document. 302 * @private 303 */ 304cvox.SearchWidget.prototype.createTextAreaNode_ = function() { 305 var textNode = document.createElement('textarea'); 306 textNode.setAttribute('aria-hidden', 'true'); 307 textNode.setAttribute('rows', '1'); 308 textNode.style['color'] = '#fff'; 309 textNode.style['background-color'] = 'rgba(0, 0, 0, 0.7)'; 310 textNode.style['vertical-align'] = 'middle'; 311 textNode.addEventListener('textInput', 312 this.handleSearchChanged_, false); 313 return textNode; 314}; 315 316 317/** 318 * Toggles whether or not searches are case sensitive. 319 * @private 320 */ 321cvox.SearchWidget.prototype.toggleCaseSensitivity_ = function() { 322 if (this.caseSensitive_) { 323 cvox.SearchWidget.caseSensitive_ = false; 324 cvox.ChromeVox.tts.speak('Ignoring case.', 0, null); 325 } else { 326 this.caseSensitive_ = true; 327 cvox.ChromeVox.tts.speak('Case sensitive.', 0, null); 328 } 329}; 330 331 332/** 333 * Gets the next result. 334 * 335 * @param {string} searchStr The text to search for. 336 * @return {Array.<cvox.NavDescription>} The next result, in the form of 337 * NavDescriptions. 338 * @private 339 */ 340cvox.SearchWidget.prototype.getNextResult_ = function(searchStr) { 341 var r = cvox.ChromeVox.navigationManager.isReversed(); 342 if (!this.caseSensitive_) { 343 searchStr = searchStr.toLowerCase(); 344 } 345 346 cvox.ChromeVox.navigationManager.setGranularity( 347 cvox.NavigationShifter.GRANULARITIES.OBJECT, true, false); 348 349 do { 350 if (this.getPredicate()) { 351 var retNode = this.getPredicate()(cvox.DomUtil.getAncestors( 352 cvox.ChromeVox.navigationManager.getCurrentNode())); 353 if (!retNode) { 354 continue; 355 } 356 } 357 358 var descriptions = cvox.ChromeVox.navigationManager.getDescription(); 359 for (var i = 0; i < descriptions.length; i++) { 360 var targetStr = this.caseSensitive_ ? descriptions[i].text : 361 descriptions[i].text.toLowerCase(); 362 var targetIndex = targetStr.indexOf(searchStr); 363 364 // Surround search hit with pauses. 365 if (targetIndex != -1 && targetStr.length > searchStr.length) { 366 descriptions[i].text = 367 cvox.DomUtil.collapseWhitespace( 368 targetStr.substring(0, targetIndex)) + 369 ', ' + searchStr + ', ' + 370 targetStr.substring(targetIndex + searchStr.length); 371 descriptions[i].text = 372 cvox.DomUtil.collapseWhitespace(descriptions[i].text); 373 } 374 if (targetIndex != -1) { 375 return descriptions; 376 } 377 } 378 cvox.ChromeVox.navigationManager.setReversed(r); 379 } while (cvox.ChromeVox.navigationManager.navigate(true, 380 cvox.NavigationShifter.GRANULARITIES.OBJECT)); 381}; 382 383 384/** 385 * Performs the search starting from the initial position. 386 * 387 * @param {string} searchStr The text to search for. 388 * @private 389 */ 390cvox.SearchWidget.prototype.beginSearch_ = function(searchStr) { 391 var result = this.getNextResult_(searchStr); 392 this.outputSearchResult_(result, searchStr); 393 this.onNavigate(); 394}; 395 396 397/** 398 * Goes to the next (directed) matching result. 399 * 400 * @param {string} searchStr The text to search for. 401 * @param {boolean=} opt_reversed The direction. 402 * @return {Array.<cvox.NavDescription>} The next result. 403 * @private 404 */ 405cvox.SearchWidget.prototype.next_ = function(searchStr, opt_reversed) { 406 cvox.ChromeVox.navigationManager.setReversed(!!opt_reversed); 407 408 var success = false; 409 if (this.getPredicate()) { 410 success = cvox.ChromeVox.navigationManager.findNext( 411 /** @type {function(Array.<Node>)} */ (this.getPredicate())); 412 // TODO(dtseng): findNext always seems to point direction forward! 413 cvox.ChromeVox.navigationManager.setReversed(!!opt_reversed); 414 if (!success) { 415 cvox.ChromeVox.navigationManager.syncToBeginning(); 416 cvox.ChromeVox.earcons.playEarcon(cvox.AbstractEarcons.WRAP); 417 success = true; 418 } 419 } else { 420 success = cvox.ChromeVox.navigationManager.navigate(true); 421 } 422 var result = success ? this.getNextResult_(searchStr) : null; 423 this.outputSearchResult_(result, searchStr); 424 this.onNavigate(); 425 return result; 426}; 427 428 429/** 430 * Given a range corresponding to a search result, highlight the result, 431 * speak it, focus the node if applicable, and speak some instructions 432 * at the end. 433 * 434 * @param {Array.<cvox.NavDescription>} result The description of the next 435 * result. If null, no more results were found and an error will be presented. 436 * @param {string} searchStr The text to search for. 437 * @private 438 */ 439cvox.SearchWidget.prototype.outputSearchResult_ = function(result, searchStr) { 440 cvox.ChromeVox.tts.stop(); 441 if (!result) { 442 cvox.ChromeVox.earcons.playEarcon(cvox.AbstractEarcons.WRAP); 443 this.hasMatch_ = false; 444 return; 445 } 446 447 this.hasMatch_ = true; 448 449 // Speak the modified description and some instructions. 450 cvox.ChromeVoxEventSuspender.withSuspendedEvents(goog.bind( 451 cvox.ChromeVox.navigationManager.syncAll, 452 cvox.ChromeVox.navigationManager))(true); 453 454 cvox.ChromeVox.navigationManager.speakDescriptionArray( 455 result, 456 cvox.AbstractTts.QUEUE_MODE_FLUSH, 457 null, 458 cvox.AbstractTts.PERSONALITY_ANNOUNCEMENT); 459 460 cvox.ChromeVox.tts.speak(cvox.ChromeVox.msgs.getMsg('search_help_item'), 461 cvox.AbstractTts.QUEUE_MODE_QUEUE, 462 cvox.AbstractTts.PERSONALITY_ANNOTATION); 463 464 // Output to Braille. 465 // TODO: Use line granularity in search so we can simply call 466 // cvox.ChromeVox.navigationManager.getBraille().write() instead. 467 this.outputSearchResultToBraille_(searchStr); 468}; 469 470 471/** 472 * Writes the currently selected search result to Braille, with description 473 * text formatted for Braille display instead of speech. 474 * 475 * @param {string} searchStr The text to search for. 476 * Should be in navigation manager's description. 477 * @private 478 */ 479cvox.SearchWidget.prototype.outputSearchResultToBraille_ = function(searchStr) { 480 // Construct object we can pass to Chromevox.braille to write. 481 // We concatenate the text together and set the "cursor" 482 // position to be at the end of search query string 483 // (consistent with editing text in a field). 484 var text = this.textFromCurrentDescription_(); 485 var targetStr = this.caseSensitive_ ? text : 486 text.toLowerCase(); 487 searchStr = this.caseSensitive_ ? searchStr : searchStr.toLowerCase(); 488 var targetIndex = targetStr.indexOf(searchStr); 489 if (targetIndex == -1) { 490 console.log('Search string not in result when preparing for Braille.'); 491 return; 492 } 493 494 // Mark the string as a search result by adding a prefix 495 // and adjust the targetIndex accordingly. 496 var oldLength = text.length; 497 text = cvox.ChromeVox.msgs.getMsg('mark_as_search_result_brl', [text]); 498 var newLength = text.length; 499 targetIndex += (newLength - oldLength); 500 501 // Write to Braille with cursor at the end of the search hit. 502 cvox.ChromeVox.braille.write(new cvox.NavBraille({ 503 text: text, 504 startIndex: (targetIndex + searchStr.length), 505 endIndex: (targetIndex + searchStr.length) 506 })); 507}; 508 509 510/** 511 * Returns the concatenated text from the current description in the 512 * NavigationManager. 513 * TODO: May not be needed after we just simply use line granularity in search, 514 * since this is mostly used to display the long search result descriptions on 515 * Braille. 516 * @return {string} The concatenated text from the current description. 517 * @private 518 */ 519cvox.SearchWidget.prototype.textFromCurrentDescription_ = function() { 520 var descriptions = cvox.ChromeVox.navigationManager.getDescription(); 521 var text = ''; 522 for (var i = 0; i < descriptions.length; i++) { 523 text += descriptions[i].text + ' '; 524 } 525 return text; 526}; 527 528/** 529 * @param {Object} evt The onInput event that the function is handling. 530 * @private 531 */ 532cvox.SearchWidget.prototype.handleSearchChanged_ = function(evt) { 533 var searchStr = evt.target.value + evt.data; 534 cvox.SearchWidget.prototype.beginSearch_(searchStr); 535}; 536