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