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 5goog.provide('cvox.ChromeVoxEditableContentEditable'); 6goog.provide('cvox.ChromeVoxEditableHTMLInput'); 7goog.provide('cvox.ChromeVoxEditableTextArea'); 8goog.provide('cvox.ChromeVoxEditableTextBase'); 9goog.provide('cvox.TextChangeEvent'); 10goog.provide('cvox.TextHandlerInterface'); 11goog.provide('cvox.TypingEcho'); 12 13 14goog.require('cvox.BrailleTextHandler'); 15goog.require('cvox.ContentEditableExtractor'); 16goog.require('cvox.DomUtil'); 17goog.require('cvox.EditableTextAreaShadow'); 18goog.require('cvox.TtsInterface'); 19goog.require('goog.i18n.MessageFormat'); 20 21/** 22 * @fileoverview Gives the user spoken feedback as they type, select text, 23 * and move the cursor in editable text controls, including multiline 24 * controls. 25 * 26 * The majority of the code is in ChromeVoxEditableTextBase, a generalized 27 * class that takes the current state in the form of a text string, a 28 * cursor start location and a cursor end location, and calls a speak 29 * method with the resulting text to be spoken. If the control is multiline, 30 * information about line breaks (including automatic ones) is also needed. 31 * 32 * Two subclasses, ChromeVoxEditableHTMLInput and 33 * ChromeVoxEditableTextArea, take a HTML input (type=text) or HTML 34 * textarea node (respectively) in the constructor, and automatically 35 * handle retrieving the current state of the control, including 36 * computing line break information for a textarea using an offscreen 37 * shadow object. It is still the responsibility of the user of this 38 * class to trap key and focus events and call this class's update 39 * method. 40 * 41 */ 42 43 44/** 45 * A class containing the information needed to speak 46 * a text change event to the user. 47 * 48 * @constructor 49 * @param {string} newValue The new string value of the editable text control. 50 * @param {number} newStart The new 0-based start cursor/selection index. 51 * @param {number} newEnd The new 0-based end cursor/selection index. 52 * @param {boolean} triggeredByUser . 53 */ 54cvox.TextChangeEvent = function(newValue, newStart, newEnd, triggeredByUser) { 55 this.value = newValue; 56 this.start = newStart; 57 this.end = newEnd; 58 this.triggeredByUser = triggeredByUser; 59 60 // Adjust offsets to be in left to right order. 61 if (this.start > this.end) { 62 var tempOffset = this.end; 63 this.end = this.start; 64 this.start = tempOffset; 65 } 66}; 67 68 69/** 70 * A list of typing echo options. 71 * This defines the way typed characters get spoken. 72 * CHARACTER: echoes typed characters. 73 * WORD: echoes a word once a breaking character is typed (i.e. spacebar). 74 * CHARACTER_AND_WORD: combines CHARACTER and WORD behavior. 75 * NONE: speaks nothing when typing. 76 * COUNT: The number of possible echo levels. 77 * @enum 78 */ 79cvox.TypingEcho = { 80 CHARACTER: 0, 81 WORD: 1, 82 CHARACTER_AND_WORD: 2, 83 NONE: 3, 84 COUNT: 4 85}; 86 87 88/** 89 * @param {number} cur Current typing echo. 90 * @return {number} Next typing echo. 91 */ 92cvox.TypingEcho.cycle = function(cur) { 93 return (cur + 1) % cvox.TypingEcho.COUNT; 94}; 95 96 97/** 98 * Return if characters should be spoken given the typing echo option. 99 * @param {number} typingEcho Typing echo option. 100 * @return {boolean} Whether the character should be spoken. 101 */ 102cvox.TypingEcho.shouldSpeakChar = function(typingEcho) { 103 return typingEcho == cvox.TypingEcho.CHARACTER_AND_WORD || 104 typingEcho == cvox.TypingEcho.CHARACTER; 105}; 106 107 108/** 109 * An interface for being notified when the text changes. 110 * @interface 111 */ 112cvox.TextHandlerInterface = function() {}; 113 114 115/** 116 * Called when text changes. 117 * @param {cvox.TextChangeEvent} evt The text change event. 118 */ 119cvox.TextHandlerInterface.prototype.changed = function(evt) {}; 120 121 122/** 123 * A class representing an abstracted editable text control. 124 * @param {string} value The string value of the editable text control. 125 * @param {number} start The 0-based start cursor/selection index. 126 * @param {number} end The 0-based end cursor/selection index. 127 * @param {boolean} isPassword Whether the text control if a password field. 128 * @param {cvox.TtsInterface} tts A TTS object. 129 * @constructor 130 */ 131cvox.ChromeVoxEditableTextBase = function(value, start, end, isPassword, tts) { 132 /** 133 * Current value of the text field. 134 * @type {string} 135 * @protected 136 */ 137 this.value = value; 138 139 /** 140 * 0-based selection start index. 141 * @type {number} 142 * @protected 143 */ 144 this.start = start; 145 146 /** 147 * 0-based selection end index. 148 * @type {number} 149 * @protected 150 */ 151 this.end = end; 152 153 /** 154 * True if this is a password field. 155 * @type {boolean} 156 * @protected 157 */ 158 this.isPassword = isPassword; 159 160 /** 161 * Text-to-speech object implementing speak() and stop() methods. 162 * @type {cvox.TtsInterface} 163 * @protected 164 */ 165 this.tts = tts; 166 167 /** 168 * Whether or not the text field is multiline. 169 * @type {boolean} 170 * @protected 171 */ 172 this.multiline = false; 173 174 /** 175 * An optional handler for braille output. 176 * @type {cvox.BrailleTextHandler|undefined} 177 * @private 178 */ 179 this.brailleHandler_ = cvox.ChromeVox.braille ? 180 new cvox.BrailleTextHandler(cvox.ChromeVox.braille) : undefined; 181}; 182 183 184/** 185 * Performs setup for this element. 186 */ 187cvox.ChromeVoxEditableTextBase.prototype.setup = function() {}; 188 189 190/** 191 * Performs teardown for this element. 192 */ 193cvox.ChromeVoxEditableTextBase.prototype.teardown = function() {}; 194 195 196/** 197 * Whether or not moving the cursor from one character to another considers 198 * the cursor to be a block (false) or an i-beam (true). 199 * 200 * If the cursor is a block, then the value of the character to the right 201 * of the cursor index is always read when the cursor moves, no matter what 202 * the previous cursor location was - this is how PC screenreaders work. 203 * 204 * If the cursor is an i-beam, moving the cursor by one character reads the 205 * character that was crossed over, which may be the character to the left or 206 * right of the new cursor index depending on the direction. 207 * 208 * If the current platform is a Mac, we will use an i-beam cursor. If not, 209 * then we will use the block cursor. 210 * 211 * @type {boolean} 212 */ 213cvox.ChromeVoxEditableTextBase.useIBeamCursor = cvox.ChromeVox.isMac; 214 215 216/** 217 * Switches on or off typing echo based on events. When set, editable text 218 * updates for single-character insertions are handled in event watcher's key 219 * press handler. 220 * @type {boolean} 221 */ 222cvox.ChromeVoxEditableTextBase.eventTypingEcho = false; 223 224 225/** 226 * The maximum number of characters that are short enough to speak in response 227 * to an event. For example, if the user selects "Hello", we will speak 228 * "Hello, selected", but if the user selects 1000 characters, we will speak 229 * "text selected" instead. 230 * 231 * @type {number} 232 */ 233cvox.ChromeVoxEditableTextBase.prototype.maxShortPhraseLen = 60; 234 235 236/** 237 * Whether or not the text control is a password. 238 * 239 * @type {boolean} 240 */ 241cvox.ChromeVoxEditableTextBase.prototype.isPassword = false; 242 243 244/** 245 * Whether or not the last update to the text and selection was described. 246 * 247 * Some consumers of this flag like |ChromeVoxEventWatcher| depend on and 248 * react to when this flag is false by generating alternative feedback. 249 * @type {boolean} 250 */ 251cvox.ChromeVoxEditableTextBase.prototype.lastChangeDescribed = false; 252 253 254/** 255 * Get the line number corresponding to a particular index. 256 * Default implementation that can be overridden by subclasses. 257 * @param {number} index The 0-based character index. 258 * @return {number} The 0-based line number corresponding to that character. 259 */ 260cvox.ChromeVoxEditableTextBase.prototype.getLineIndex = function(index) { 261 return 0; 262}; 263 264 265/** 266 * Get the start character index of a line. 267 * Default implementation that can be overridden by subclasses. 268 * @param {number} index The 0-based line index. 269 * @return {number} The 0-based index of the first character in this line. 270 */ 271cvox.ChromeVoxEditableTextBase.prototype.getLineStart = function(index) { 272 return 0; 273}; 274 275 276/** 277 * Get the end character index of a line. 278 * Default implementation that can be overridden by subclasses. 279 * @param {number} index The 0-based line index. 280 * @return {number} The 0-based index of the end of this line. 281 */ 282cvox.ChromeVoxEditableTextBase.prototype.getLineEnd = function(index) { 283 return this.value.length; 284}; 285 286 287/** 288 * Get the full text of the current line. 289 * @param {number} index The 0-based line index. 290 * @return {string} The text of the line. 291 */ 292cvox.ChromeVoxEditableTextBase.prototype.getLine = function(index) { 293 var lineStart = this.getLineStart(index); 294 var lineEnd = this.getLineEnd(index); 295 return this.value.substr(lineStart, lineEnd - lineStart); 296}; 297 298 299/** 300 * @param {string} ch The character to test. 301 * @return {boolean} True if a character is whitespace. 302 */ 303cvox.ChromeVoxEditableTextBase.prototype.isWhitespaceChar = function(ch) { 304 return ch == ' ' || ch == '\n' || ch == '\r' || ch == '\t'; 305}; 306 307 308/** 309 * @param {string} ch The character to test. 310 * @return {boolean} True if a character breaks a word, used to determine 311 * if the previous word should be spoken. 312 */ 313cvox.ChromeVoxEditableTextBase.prototype.isWordBreakChar = function(ch) { 314 return !!ch.match(/^\W$/); 315}; 316 317 318/** 319 * @param {cvox.TextChangeEvent} evt The new text changed event to test. 320 * @return {boolean} True if the event, when compared to the previous text, 321 * should trigger description. 322 */ 323cvox.ChromeVoxEditableTextBase.prototype.shouldDescribeChange = function(evt) { 324 if (evt.value == this.value && 325 evt.start == this.start && 326 evt.end == this.end) { 327 return false; 328 } 329 return true; 330}; 331 332 333/** 334 * Speak text, but if it's a single character, describe the character. 335 * @param {string} str The string to speak. 336 * @param {boolean=} opt_triggeredByUser True if the speech was triggered by a 337 * user action. 338 * @param {Object=} opt_personality Personality used to speak text. 339 */ 340cvox.ChromeVoxEditableTextBase.prototype.speak = 341 function(str, opt_triggeredByUser, opt_personality) { 342 // If there is a node associated with the editable text object, 343 // make sure that node has focus before speaking it. 344 if (this.node && (document.activeElement != this.node)) { 345 return; 346 } 347 var queueMode = cvox.AbstractTts.QUEUE_MODE_QUEUE; 348 if (opt_triggeredByUser === true) { 349 queueMode = cvox.AbstractTts.QUEUE_MODE_FLUSH; 350 } 351 this.tts.speak(str, queueMode, opt_personality || {}); 352}; 353 354 355/** 356 * Update the state of the text and selection and describe any changes as 357 * appropriate. 358 * 359 * @param {cvox.TextChangeEvent} evt The text change event. 360 */ 361cvox.ChromeVoxEditableTextBase.prototype.changed = function(evt) { 362 if (!this.shouldDescribeChange(evt)) { 363 this.lastChangeDescribed = false; 364 return; 365 } 366 367 if (evt.value == this.value) { 368 this.describeSelectionChanged(evt); 369 } else { 370 this.describeTextChanged(evt); 371 } 372 this.lastChangeDescribed = true; 373 374 this.value = evt.value; 375 this.start = evt.start; 376 this.end = evt.end; 377 378 if (this.brailleHandler_) { 379 var line = this.getLine(this.getLineIndex(evt.start)); 380 var lineStart = this.getLineStart(this.getLineIndex(evt.start)); 381 var start = evt.start - lineStart; 382 var end = Math.min(evt.end - lineStart, line.length); 383 this.brailleHandler_.changed(line, start, end, this.multiline, this.node, 384 lineStart); 385 } 386}; 387 388 389/** 390 * Describe a change in the selection or cursor position when the text 391 * stays the same. 392 * @param {cvox.TextChangeEvent} evt The text change event. 393 */ 394cvox.ChromeVoxEditableTextBase.prototype.describeSelectionChanged = 395 function(evt) { 396 // TODO(deboer): Factor this into two function: 397 // - one to determine the selection event 398 // - one to speak 399 400 if (this.isPassword) { 401 this.speak((new goog.i18n.MessageFormat(cvox.ChromeVox.msgs.getMsg('dot')) 402 .format({'COUNT': 1})), evt.triggeredByUser); 403 return; 404 } 405 if (evt.start == evt.end) { 406 // It's currently a cursor. 407 if (this.start != this.end) { 408 // It was previously a selection, so just announce 'unselected'. 409 this.speak(cvox.ChromeVox.msgs.getMsg('Unselected'), evt.triggeredByUser); 410 } else if (this.getLineIndex(this.start) != 411 this.getLineIndex(evt.start)) { 412 // Moved to a different line; read it. 413 var lineValue = this.getLine(this.getLineIndex(evt.start)); 414 if (lineValue == '') { 415 lineValue = cvox.ChromeVox.msgs.getMsg('text_box_blank'); 416 } else if (/^\s+$/.test(lineValue)) { 417 lineValue = cvox.ChromeVox.msgs.getMsg('text_box_whitespace'); 418 } 419 this.speak(lineValue, evt.triggeredByUser); 420 } else if (this.start == evt.start + 1 || 421 this.start == evt.start - 1) { 422 // Moved by one character; read it. 423 if (!cvox.ChromeVoxEditableTextBase.useIBeamCursor) { 424 if (evt.start == this.value.length) { 425 if (cvox.ChromeVox.verbosity == cvox.VERBOSITY_VERBOSE) { 426 this.speak(cvox.ChromeVox.msgs.getMsg('end_of_text_verbose'), 427 evt.triggeredByUser); 428 } else { 429 this.speak(cvox.ChromeVox.msgs.getMsg('end_of_text_brief'), 430 evt.triggeredByUser); 431 } 432 } else { 433 this.speak(this.value.substr(evt.start, 1), 434 evt.triggeredByUser, 435 {'phoneticCharacters': evt.triggeredByUser}); 436 } 437 } else { 438 this.speak(this.value.substr(Math.min(this.start, evt.start), 1), 439 evt.triggeredByUser, 440 {'phoneticCharacters': evt.triggeredByUser}); 441 } 442 } else { 443 // Moved by more than one character. Read all characters crossed. 444 this.speak(this.value.substr(Math.min(this.start, evt.start), 445 Math.abs(this.start - evt.start)), evt.triggeredByUser); 446 } 447 } else { 448 // It's currently a selection. 449 if (this.start + 1 == evt.start && 450 this.end == this.value.length && 451 evt.end == this.value.length) { 452 // Autocomplete: the user typed one character of autocompleted text. 453 this.speak(this.value.substr(this.start, 1), evt.triggeredByUser); 454 this.speak(this.value.substr(evt.start)); 455 } else if (this.start == this.end) { 456 // It was previously a cursor. 457 this.speak(this.value.substr(evt.start, evt.end - evt.start), 458 evt.triggeredByUser); 459 this.speak(cvox.ChromeVox.msgs.getMsg('selected')); 460 } else if (this.start == evt.start && this.end < evt.end) { 461 this.speak(this.value.substr(this.end, evt.end - this.end), 462 evt.triggeredByUser); 463 this.speak(cvox.ChromeVox.msgs.getMsg('added_to_selection')); 464 } else if (this.start == evt.start && this.end > evt.end) { 465 this.speak(this.value.substr(evt.end, this.end - evt.end), 466 evt.triggeredByUser); 467 this.speak(cvox.ChromeVox.msgs.getMsg('removed_from_selection')); 468 } else if (this.end == evt.end && this.start > evt.start) { 469 this.speak(this.value.substr(evt.start, this.start - evt.start), 470 evt.triggeredByUser); 471 this.speak(cvox.ChromeVox.msgs.getMsg('added_to_selection')); 472 } else if (this.end == evt.end && this.start < evt.start) { 473 this.speak(this.value.substr(this.start, evt.start - this.start), 474 evt.triggeredByUser); 475 this.speak(cvox.ChromeVox.msgs.getMsg('removed_from_selection')); 476 } else { 477 // The selection changed but it wasn't an obvious extension of 478 // a previous selection. Just read the new selection. 479 this.speak(this.value.substr(evt.start, evt.end - evt.start), 480 evt.triggeredByUser); 481 this.speak(cvox.ChromeVox.msgs.getMsg('selected')); 482 } 483 } 484}; 485 486 487/** 488 * Describe a change where the text changes. 489 * @param {cvox.TextChangeEvent} evt The text change event. 490 */ 491cvox.ChromeVoxEditableTextBase.prototype.describeTextChanged = function(evt) { 492 var personality = {}; 493 if (evt.value.length < this.value.length) { 494 personality = cvox.AbstractTts.PERSONALITY_DELETED; 495 } 496 if (this.isPassword) { 497 this.speak((new goog.i18n.MessageFormat(cvox.ChromeVox.msgs.getMsg('dot')) 498 .format({'COUNT': 1})), evt.triggeredByUser, personality); 499 return; 500 } 501 502 var value = this.value; 503 var len = value.length; 504 var newLen = evt.value.length; 505 var autocompleteSuffix = ''; 506 // Make a copy of evtValue and evtEnd to avoid changing anything in 507 // the event itself. 508 var evtValue = evt.value; 509 var evtEnd = evt.end; 510 511 // First, see if there's a selection at the end that might have been 512 // added by autocomplete. If so, strip it off into a separate variable. 513 if (evt.start < evtEnd && evtEnd == newLen) { 514 autocompleteSuffix = evtValue.substr(evt.start); 515 evtValue = evtValue.substr(0, evt.start); 516 evtEnd = evt.start; 517 } 518 519 // Now see if the previous selection (if any) was deleted 520 // and any new text was inserted at that character position. 521 // This would handle pasting and entering text by typing, both from 522 // a cursor and from a selection. 523 var prefixLen = this.start; 524 var suffixLen = len - this.end; 525 if (newLen >= prefixLen + suffixLen + (evtEnd - evt.start) && 526 evtValue.substr(0, prefixLen) == value.substr(0, prefixLen) && 527 evtValue.substr(newLen - suffixLen) == value.substr(this.end)) { 528 // However, in a dynamic content editable, defer to authoritative events 529 // (clipboard, key press) to reduce guess work when observing insertions. 530 // Only use this logic when observing deletions (and insertion of word 531 // breakers). 532 // TODO(dtseng): Think about a more reliable way to do this. 533 if (!(this instanceof cvox.ChromeVoxEditableContentEditable) || 534 newLen < len || 535 this.isWordBreakChar(evt.value[newLen - 1] || '')) { 536 this.describeTextChangedHelper( 537 evt, prefixLen, suffixLen, autocompleteSuffix, personality); 538 } 539 return; 540 } 541 542 // Next, see if one or more characters were deleted from the previous 543 // cursor position and the new cursor is in the expected place. This 544 // handles backspace, forward-delete, and similar shortcuts that delete 545 // a word or line. 546 prefixLen = evt.start; 547 suffixLen = newLen - evtEnd; 548 if (this.start == this.end && 549 evt.start == evtEnd && 550 evtValue.substr(0, prefixLen) == value.substr(0, prefixLen) && 551 evtValue.substr(newLen - suffixLen) == 552 value.substr(len - suffixLen)) { 553 this.describeTextChangedHelper( 554 evt, prefixLen, suffixLen, autocompleteSuffix, personality); 555 return; 556 } 557 558 // If all else fails, we assume the change was not the result of a normal 559 // user editing operation, so we'll have to speak feedback based only 560 // on the changes to the text, not the cursor position / selection. 561 // First, restore the autocomplete text if any. 562 evtValue += autocompleteSuffix; 563 564 // Try to do a diff between the new and the old text. If it is a one character 565 // insertion/deletion at the start or at the end, just speak that character. 566 if ((evtValue.length == (value.length + 1)) || 567 ((evtValue.length + 1) == value.length)) { 568 // The user added text either to the beginning or the end. 569 if (evtValue.length > value.length) { 570 if (evtValue.indexOf(value) == 0) { 571 this.speak(evtValue[evtValue.length - 1], evt.triggeredByUser, 572 personality); 573 return; 574 } else if (evtValue.indexOf(value) == 1) { 575 this.speak(evtValue[0], evt.triggeredByUser, personality); 576 return; 577 } 578 } 579 // The user deleted text either from the beginning or the end. 580 if (evtValue.length < value.length) { 581 if (value.indexOf(evtValue) == 0) { 582 this.speak(value[value.length - 1], evt.triggeredByUser, personality); 583 return; 584 } else if (value.indexOf(evtValue) == 1) { 585 this.speak(value[0], evt.triggeredByUser, personality); 586 return; 587 } 588 } 589 } 590 591 if (this.multiline) { 592 // Fall back to announce deleted but omit the text that was deleted. 593 if (evt.value.length < this.value.length) { 594 this.speak(cvox.ChromeVox.msgs.getMsg('text_deleted'), 595 evt.triggeredByUser, personality); 596 } 597 // The below is a somewhat loose way to deal with non-standard 598 // insertions/deletions. Intentionally skip for multiline since deletion 599 // announcements are covered above and insertions are non-standard (possibly 600 // due to auto complete). Since content editable's often refresh content by 601 // removing and inserting entire chunks of text, this type of logic often 602 // results in unintended consequences such as reading all text when only one 603 // character has been entered. 604 return; 605 } 606 607 // If the text is short, just speak the whole thing. 608 if (newLen <= this.maxShortPhraseLen) { 609 this.describeTextChangedHelper(evt, 0, 0, '', personality); 610 return; 611 } 612 613 // Otherwise, look for the common prefix and suffix, but back up so 614 // that we can speak complete words, to be minimally confusing. 615 prefixLen = 0; 616 while (prefixLen < len && 617 prefixLen < newLen && 618 value[prefixLen] == evtValue[prefixLen]) { 619 prefixLen++; 620 } 621 while (prefixLen > 0 && !this.isWordBreakChar(value[prefixLen - 1])) { 622 prefixLen--; 623 } 624 625 suffixLen = 0; 626 while (suffixLen < (len - prefixLen) && 627 suffixLen < (newLen - prefixLen) && 628 value[len - suffixLen - 1] == evtValue[newLen - suffixLen - 1]) { 629 suffixLen++; 630 } 631 while (suffixLen > 0 && !this.isWordBreakChar(value[len - suffixLen])) { 632 suffixLen--; 633 } 634 635 this.describeTextChangedHelper(evt, prefixLen, suffixLen, '', personality); 636}; 637 638 639/** 640 * The function called by describeTextChanged after it's figured out 641 * what text was deleted, what text was inserted, and what additional 642 * autocomplete text was added. 643 * @param {cvox.TextChangeEvent} evt The text change event. 644 * @param {number} prefixLen The number of characters in the common prefix 645 * of this.value and newValue. 646 * @param {number} suffixLen The number of characters in the common suffix 647 * of this.value and newValue. 648 * @param {string} autocompleteSuffix The autocomplete string that was added 649 * to the end, if any. It should be spoken at the end of the utterance 650 * describing the change. 651 * @param {Object=} opt_personality Personality to speak the text. 652 */ 653cvox.ChromeVoxEditableTextBase.prototype.describeTextChangedHelper = function( 654 evt, prefixLen, suffixLen, autocompleteSuffix, opt_personality) { 655 var len = this.value.length; 656 var newLen = evt.value.length; 657 var deletedLen = len - prefixLen - suffixLen; 658 var deleted = this.value.substr(prefixLen, deletedLen); 659 var insertedLen = newLen - prefixLen - suffixLen; 660 var inserted = evt.value.substr(prefixLen, insertedLen); 661 var utterance = ''; 662 var triggeredByUser = evt.triggeredByUser; 663 664 if (insertedLen > 1) { 665 utterance = inserted; 666 } else if (insertedLen == 1) { 667 if ((cvox.ChromeVox.typingEcho == cvox.TypingEcho.WORD || 668 cvox.ChromeVox.typingEcho == cvox.TypingEcho.CHARACTER_AND_WORD) && 669 this.isWordBreakChar(inserted) && 670 prefixLen > 0 && 671 !this.isWordBreakChar(evt.value.substr(prefixLen - 1, 1))) { 672 // Speak previous word. 673 var index = prefixLen; 674 while (index > 0 && !this.isWordBreakChar(evt.value[index - 1])) { 675 index--; 676 } 677 if (index < prefixLen) { 678 utterance = evt.value.substr(index, prefixLen + 1 - index); 679 } else { 680 utterance = inserted; 681 triggeredByUser = false; // Implies QUEUE_MODE_QUEUE. 682 } 683 } else if (cvox.ChromeVox.typingEcho == cvox.TypingEcho.CHARACTER || 684 cvox.ChromeVox.typingEcho == cvox.TypingEcho.CHARACTER_AND_WORD) { 685 // This particular case is handled in event watcher. See the key press 686 // handler for more details. 687 utterance = cvox.ChromeVoxEditableTextBase.eventTypingEcho ? '' : 688 inserted; 689 } 690 } else if (deletedLen > 1 && !autocompleteSuffix) { 691 utterance = deleted + ', deleted'; 692 } else if (deletedLen == 1) { 693 utterance = deleted; 694 } 695 696 if (autocompleteSuffix && utterance) { 697 utterance += ', ' + autocompleteSuffix; 698 } else if (autocompleteSuffix) { 699 utterance = autocompleteSuffix; 700 } 701 702 if (utterance) { 703 this.speak(utterance, triggeredByUser, opt_personality); 704 } 705}; 706 707 708/** 709 * Moves the cursor forward by one character. 710 * @return {boolean} True if the action was handled. 711 */ 712cvox.ChromeVoxEditableTextBase.prototype.moveCursorToNextCharacter = 713 function() { return false; }; 714 715 716/** 717 * Moves the cursor backward by one character. 718 * @return {boolean} True if the action was handled. 719 */ 720cvox.ChromeVoxEditableTextBase.prototype.moveCursorToPreviousCharacter = 721 function() { return false; }; 722 723 724/** 725 * Moves the cursor forward by one word. 726 * @return {boolean} True if the action was handled. 727 */ 728cvox.ChromeVoxEditableTextBase.prototype.moveCursorToNextWord = 729 function() { return false; }; 730 731 732/** 733 * Moves the cursor backward by one word. 734 * @return {boolean} True if the action was handled. 735 */ 736cvox.ChromeVoxEditableTextBase.prototype.moveCursorToPreviousWord = 737 function() { return false; }; 738 739 740/** 741 * Moves the cursor forward by one line. 742 * @return {boolean} True if the action was handled. 743 */ 744cvox.ChromeVoxEditableTextBase.prototype.moveCursorToNextLine = 745 function() { return false; }; 746 747 748/** 749 * Moves the cursor backward by one line. 750 * @return {boolean} True if the action was handled. 751 */ 752cvox.ChromeVoxEditableTextBase.prototype.moveCursorToPreviousLine = 753 function() { return false; }; 754 755 756/** 757 * Moves the cursor forward by one paragraph. 758 * @return {boolean} True if the action was handled. 759 */ 760cvox.ChromeVoxEditableTextBase.prototype.moveCursorToNextParagraph = 761 function() { return false; }; 762 763 764/** 765 * Moves the cursor backward by one paragraph. 766 * @return {boolean} True if the action was handled. 767 */ 768cvox.ChromeVoxEditableTextBase.prototype.moveCursorToPreviousParagraph = 769 function() { return false; }; 770 771 772/******************************************/ 773 774 775/** 776 * A subclass of ChromeVoxEditableTextBase a text element that's part of 777 * the webpage DOM. Contains common code shared by both EditableHTMLInput 778 * and EditableTextArea, but that might not apply to a non-DOM text box. 779 * @param {Element} node A DOM node which allows text input. 780 * @param {string} value The string value of the editable text control. 781 * @param {number} start The 0-based start cursor/selection index. 782 * @param {number} end The 0-based end cursor/selection index. 783 * @param {boolean} isPassword Whether the text control if a password field. 784 * @param {cvox.TtsInterface} tts A TTS object. 785 * @extends {cvox.ChromeVoxEditableTextBase} 786 * @constructor 787 */ 788cvox.ChromeVoxEditableElement = function(node, value, start, end, isPassword, 789 tts) { 790 goog.base(this, value, start, end, isPassword, tts); 791 792 /** 793 * The DOM node which allows text input. 794 * @type {Element} 795 * @protected 796 */ 797 this.node = node; 798 799 /** 800 * True if the description was just spoken. 801 * @type {boolean} 802 * @private 803 */ 804 this.justSpokeDescription_ = false; 805}; 806goog.inherits(cvox.ChromeVoxEditableElement, 807 cvox.ChromeVoxEditableTextBase); 808 809 810/** 811 * Update the state of the text and selection and describe any changes as 812 * appropriate. 813 * 814 * @param {cvox.TextChangeEvent} evt The text change event. 815 */ 816cvox.ChromeVoxEditableElement.prototype.changed = function(evt) { 817 // Ignore changes to the cursor and selection if they happen immediately 818 // after the description was just spoken. This avoid double-speaking when, 819 // for example, a text field is focused and then a moment later the 820 // contents are selected. If the value changes, though, this change will 821 // not be ignored. 822 if (this.justSpokeDescription_ && this.value == evt.value) { 823 this.value = evt.value; 824 this.start = evt.start; 825 this.end = evt.end; 826 this.justSpokeDescription_ = false; 827 } 828 goog.base(this, 'changed', evt); 829}; 830 831 832/** @override */ 833cvox.ChromeVoxEditableElement.prototype.moveCursorToNextCharacter = function() { 834 var node = this.node; 835 node.selectionEnd++; 836 node.selectionStart = node.selectionEnd; 837 cvox.ChromeVoxEventWatcher.handleTextChanged(true); 838 return true; 839}; 840 841 842/** @override */ 843cvox.ChromeVoxEditableElement.prototype.moveCursorToPreviousCharacter = 844 function() { 845 var node = this.node; 846 node.selectionStart--; 847 node.selectionEnd = node.selectionStart; 848 cvox.ChromeVoxEventWatcher.handleTextChanged(true); 849 return true; 850}; 851 852 853/** @override */ 854cvox.ChromeVoxEditableElement.prototype.moveCursorToNextWord = function() { 855 var node = this.node; 856 var length = node.value.length; 857 var re = /\W+/gm; 858 var substring = node.value.substring(node.selectionEnd); 859 var match = re.exec(substring); 860 if (match !== null && match.index == 0) { 861 // Ignore word-breaking sequences right next to the cursor. 862 match = re.exec(substring); 863 } 864 var index = (match === null) ? length : match.index + node.selectionEnd; 865 node.selectionStart = node.selectionEnd = index; 866 cvox.ChromeVoxEventWatcher.handleTextChanged(true); 867 return true; 868}; 869 870 871/** @override */ 872cvox.ChromeVoxEditableElement.prototype.moveCursorToPreviousWord = function() { 873 var node = this.node; 874 var length = node.value.length; 875 var re = /\W+/gm; 876 var substring = node.value.substring(0, node.selectionStart); 877 var index = 0; 878 while (re.exec(substring) !== null) { 879 if (re.lastIndex < node.selectionStart) { 880 index = re.lastIndex; 881 } 882 } 883 node.selectionStart = node.selectionEnd = index; 884 cvox.ChromeVoxEventWatcher.handleTextChanged(true); 885 return true; 886}; 887 888 889/** @override */ 890cvox.ChromeVoxEditableElement.prototype.moveCursorToNextParagraph = 891 function() { 892 var node = this.node; 893 var length = node.value.length; 894 var index = node.selectionEnd >= length ? length : 895 node.value.indexOf('\n', node.selectionEnd); 896 if (index < 0) { 897 index = length; 898 } 899 node.selectionStart = node.selectionEnd = index + 1; 900 cvox.ChromeVoxEventWatcher.handleTextChanged(true); 901 return true; 902}; 903 904 905/** @override */ 906cvox.ChromeVoxEditableElement.prototype.moveCursorToPreviousParagraph = 907 function() { 908 var node = this.node; 909 var index = node.selectionStart <= 0 ? 0 : 910 node.value.lastIndexOf('\n', node.selectionStart - 2) + 1; 911 if (index < 0) { 912 index = 0; 913 } 914 node.selectionStart = node.selectionEnd = index; 915 cvox.ChromeVoxEventWatcher.handleTextChanged(true); 916 return true; 917}; 918 919 920/******************************************/ 921 922 923/** 924 * A subclass of ChromeVoxEditableElement for an HTMLInputElement. 925 * @param {HTMLInputElement} node The HTMLInputElement node. 926 * @param {cvox.TtsInterface} tts A TTS object. 927 * @extends {cvox.ChromeVoxEditableElement} 928 * @implements {cvox.TextHandlerInterface} 929 * @constructor 930 */ 931cvox.ChromeVoxEditableHTMLInput = function(node, tts) { 932 this.node = node; 933 this.setup(); 934 goog.base(this, 935 node, 936 node.value, 937 node.selectionStart, 938 node.selectionEnd, 939 node.type === 'password', 940 tts); 941}; 942goog.inherits(cvox.ChromeVoxEditableHTMLInput, 943 cvox.ChromeVoxEditableElement); 944 945 946/** 947 * Performs setup for this input node. 948 * This accounts for exception-throwing behavior introduced by crbug.com/324360. 949 * @override 950 */ 951cvox.ChromeVoxEditableHTMLInput.prototype.setup = function() { 952 if (!this.node) { 953 return; 954 } 955 if (!cvox.DomUtil.doesInputSupportSelection(this.node)) { 956 this.originalType = this.node.type; 957 this.node.type = 'text'; 958 } 959}; 960 961 962/** 963 * Performs teardown for this input node. 964 * This accounts for exception-throwing behavior introduced by crbug.com/324360. 965 * @override 966 */ 967cvox.ChromeVoxEditableHTMLInput.prototype.teardown = function() { 968 if (this.node && this.originalType) { 969 this.node.type = this.originalType; 970 } 971}; 972 973 974/** 975 * Update the state of the text and selection and describe any changes as 976 * appropriate. 977 * 978 * @param {boolean} triggeredByUser True if this was triggered by a user action. 979 */ 980cvox.ChromeVoxEditableHTMLInput.prototype.update = function(triggeredByUser) { 981 var newValue = this.node.value; 982 var textChangeEvent = new cvox.TextChangeEvent(newValue, 983 this.node.selectionStart, 984 this.node.selectionEnd, 985 triggeredByUser); 986 this.changed(textChangeEvent); 987}; 988 989 990/******************************************/ 991 992 993/** 994 * A subclass of ChromeVoxEditableElement for an HTMLTextAreaElement. 995 * @param {HTMLTextAreaElement} node The HTMLTextAreaElement node. 996 * @param {cvox.TtsInterface} tts A TTS object. 997 * @extends {cvox.ChromeVoxEditableElement} 998 * @implements {cvox.TextHandlerInterface} 999 * @constructor 1000 */ 1001cvox.ChromeVoxEditableTextArea = function(node, tts) { 1002 goog.base(this, node, node.value, node.selectionStart, node.selectionEnd, 1003 false /* isPassword */, tts); 1004 this.multiline = true; 1005 1006 /** 1007 * True if the shadow is up-to-date with the current value of this text area. 1008 * @type {boolean} 1009 * @private 1010 */ 1011 this.shadowIsCurrent_ = false; 1012}; 1013goog.inherits(cvox.ChromeVoxEditableTextArea, 1014 cvox.ChromeVoxEditableElement); 1015 1016 1017/** 1018 * An offscreen div used to compute the line numbers. A single div is 1019 * shared by all instances of the class. 1020 * @type {!cvox.EditableTextAreaShadow|undefined} 1021 * @private 1022 */ 1023cvox.ChromeVoxEditableTextArea.shadow_; 1024 1025 1026/** 1027 * Update the state of the text and selection and describe any changes as 1028 * appropriate. 1029 * 1030 * @param {boolean} triggeredByUser True if this was triggered by a user action. 1031 */ 1032cvox.ChromeVoxEditableTextArea.prototype.update = function(triggeredByUser) { 1033 if (this.node.value != this.value) { 1034 this.shadowIsCurrent_ = false; 1035 } 1036 var textChangeEvent = new cvox.TextChangeEvent(this.node.value, 1037 this.node.selectionStart, this.node.selectionEnd, triggeredByUser); 1038 this.changed(textChangeEvent); 1039}; 1040 1041 1042/** 1043 * Get the line number corresponding to a particular index. 1044 * @param {number} index The 0-based character index. 1045 * @return {number} The 0-based line number corresponding to that character. 1046 */ 1047cvox.ChromeVoxEditableTextArea.prototype.getLineIndex = function(index) { 1048 return this.getShadow().getLineIndex(index); 1049}; 1050 1051 1052/** 1053 * Get the start character index of a line. 1054 * @param {number} index The 0-based line index. 1055 * @return {number} The 0-based index of the first character in this line. 1056 */ 1057cvox.ChromeVoxEditableTextArea.prototype.getLineStart = function(index) { 1058 return this.getShadow().getLineStart(index); 1059}; 1060 1061 1062/** 1063 * Get the end character index of a line. 1064 * @param {number} index The 0-based line index. 1065 * @return {number} The 0-based index of the end of this line. 1066 */ 1067cvox.ChromeVoxEditableTextArea.prototype.getLineEnd = function(index) { 1068 return this.getShadow().getLineEnd(index); 1069}; 1070 1071 1072/** 1073 * Update the shadow object, an offscreen div used to compute line numbers. 1074 * @return {!cvox.EditableTextAreaShadow} The shadow object. 1075 */ 1076cvox.ChromeVoxEditableTextArea.prototype.getShadow = function() { 1077 var shadow = cvox.ChromeVoxEditableTextArea.shadow_; 1078 if (!shadow) { 1079 shadow = cvox.ChromeVoxEditableTextArea.shadow_ = 1080 new cvox.EditableTextAreaShadow(); 1081 } 1082 if (!this.shadowIsCurrent_) { 1083 shadow.update(this.node); 1084 this.shadowIsCurrent_ = true; 1085 } 1086 return shadow; 1087}; 1088 1089 1090/** @override */ 1091cvox.ChromeVoxEditableTextArea.prototype.moveCursorToNextLine = function() { 1092 var node = this.node; 1093 var length = node.value.length; 1094 if (node.selectionEnd >= length) { 1095 return false; 1096 } 1097 var shadow = this.getShadow(); 1098 var lineIndex = shadow.getLineIndex(node.selectionEnd); 1099 var lineStart = shadow.getLineStart(lineIndex); 1100 var offset = node.selectionEnd - lineStart; 1101 var lastLine = (length == 0) ? 0 : shadow.getLineIndex(length - 1); 1102 var newCursorPosition = (lineIndex >= lastLine) ? length : 1103 Math.min(shadow.getLineStart(lineIndex + 1) + offset, 1104 shadow.getLineEnd(lineIndex + 1)); 1105 node.selectionStart = node.selectionEnd = newCursorPosition; 1106 cvox.ChromeVoxEventWatcher.handleTextChanged(true); 1107 return true; 1108}; 1109 1110 1111/** @override */ 1112cvox.ChromeVoxEditableTextArea.prototype.moveCursorToPreviousLine = function() { 1113 var node = this.node; 1114 if (node.selectionStart <= 0) { 1115 return false; 1116 } 1117 var shadow = this.getShadow(); 1118 var lineIndex = shadow.getLineIndex(node.selectionStart); 1119 var lineStart = shadow.getLineStart(lineIndex); 1120 var offset = node.selectionStart - lineStart; 1121 var newCursorPosition = (lineIndex <= 0) ? 0 : 1122 Math.min(shadow.getLineStart(lineIndex - 1) + offset, 1123 shadow.getLineEnd(lineIndex - 1)); 1124 node.selectionStart = node.selectionEnd = newCursorPosition; 1125 cvox.ChromeVoxEventWatcher.handleTextChanged(true); 1126 return true; 1127}; 1128 1129 1130/******************************************/ 1131 1132 1133/** 1134 * A subclass of ChromeVoxEditableElement for elements that are contentEditable. 1135 * This is also used for a region of HTML with the ARIA role of "textbox", 1136 * so that an author can create a pure-JavaScript editable text object - this 1137 * will work the same as contentEditable as long as the DOM selection is 1138 * updated properly within the textbox when it has focus. 1139 * @param {Element} node The root contentEditable node. 1140 * @param {cvox.TtsInterface} tts A TTS object. 1141 * @extends {cvox.ChromeVoxEditableElement} 1142 * @implements {cvox.TextHandlerInterface} 1143 * @constructor 1144 */ 1145cvox.ChromeVoxEditableContentEditable = function(node, tts) { 1146 goog.base(this, node, '', 0, 0, false /* isPassword */, tts); 1147 1148 1149 /** 1150 * True if the ContentEditableExtractor is current with this field's data. 1151 * @type {boolean} 1152 * @private 1153 */ 1154 this.extractorIsCurrent_ = false; 1155 1156 var extractor = this.getExtractor(); 1157 this.value = extractor.getText(); 1158 this.start = extractor.getStartIndex(); 1159 this.end = extractor.getEndIndex(); 1160 this.multiline = true; 1161}; 1162goog.inherits(cvox.ChromeVoxEditableContentEditable, 1163 cvox.ChromeVoxEditableElement); 1164 1165/** 1166 * A helper used to compute the line numbers. A single object is 1167 * shared by all instances of the class. 1168 * @type {!cvox.ContentEditableExtractor|undefined} 1169 * @private 1170 */ 1171cvox.ChromeVoxEditableContentEditable.extractor_; 1172 1173 1174/** 1175 * Update the state of the text and selection and describe any changes as 1176 * appropriate. 1177 * 1178 * @param {boolean} triggeredByUser True if this was triggered by a user action. 1179 */ 1180cvox.ChromeVoxEditableContentEditable.prototype.update = 1181 function(triggeredByUser) { 1182 this.extractorIsCurrent_ = false; 1183 var textChangeEvent = new cvox.TextChangeEvent( 1184 this.getExtractor().getText(), 1185 this.getExtractor().getStartIndex(), 1186 this.getExtractor().getEndIndex(), 1187 triggeredByUser); 1188 this.changed(textChangeEvent); 1189}; 1190 1191 1192/** 1193 * Get the line number corresponding to a particular index. 1194 * @param {number} index The 0-based character index. 1195 * @return {number} The 0-based line number corresponding to that character. 1196 */ 1197cvox.ChromeVoxEditableContentEditable.prototype.getLineIndex = function(index) { 1198 return this.getExtractor().getLineIndex(index); 1199}; 1200 1201 1202/** 1203 * Get the start character index of a line. 1204 * @param {number} index The 0-based line index. 1205 * @return {number} The 0-based index of the first character in this line. 1206 */ 1207cvox.ChromeVoxEditableContentEditable.prototype.getLineStart = function(index) { 1208 return this.getExtractor().getLineStart(index); 1209}; 1210 1211 1212/** 1213 * Get the end character index of a line. 1214 * @param {number} index The 0-based line index. 1215 * @return {number} The 0-based index of the end of this line. 1216 */ 1217cvox.ChromeVoxEditableContentEditable.prototype.getLineEnd = function(index) { 1218 return this.getExtractor().getLineEnd(index); 1219}; 1220 1221 1222/** 1223 * Update the extractor object, an offscreen div used to compute line numbers. 1224 * @return {!cvox.ContentEditableExtractor} The extractor object. 1225 */ 1226cvox.ChromeVoxEditableContentEditable.prototype.getExtractor = function() { 1227 var extractor = cvox.ChromeVoxEditableContentEditable.extractor_; 1228 if (!extractor) { 1229 extractor = cvox.ChromeVoxEditableContentEditable.extractor_ = 1230 new cvox.ContentEditableExtractor(); 1231 } 1232 if (!this.extractorIsCurrent_) { 1233 extractor.update(this.node); 1234 this.extractorIsCurrent_ = true; 1235 } 1236 return extractor; 1237}; 1238 1239 1240/** 1241 * @override 1242 */ 1243cvox.ChromeVoxEditableContentEditable.prototype.changed = 1244 function(evt) { 1245 if (!evt.triggeredByUser) { 1246 return; 1247 } 1248 // Take over here if we can't describe a change; assume it's a blank line. 1249 if (!this.shouldDescribeChange(evt)) { 1250 this.speak(cvox.ChromeVox.msgs.getMsg('text_box_blank'), true); 1251 } else { 1252 goog.base(this, 'changed', evt); 1253 } 1254}; 1255 1256 1257/** @override */ 1258cvox.ChromeVoxEditableContentEditable.prototype.moveCursorToNextCharacter = 1259 function() { 1260 window.getSelection().modify('move', 'forward', 'character'); 1261 cvox.ChromeVoxEventWatcher.handleTextChanged(true); 1262 return true; 1263}; 1264 1265 1266/** @override */ 1267cvox.ChromeVoxEditableContentEditable.prototype.moveCursorToPreviousCharacter = 1268 function() { 1269 window.getSelection().modify('move', 'backward', 'character'); 1270 cvox.ChromeVoxEventWatcher.handleTextChanged(true); 1271 return true; 1272}; 1273 1274 1275/** @override */ 1276cvox.ChromeVoxEditableContentEditable.prototype.moveCursorToNextParagraph = 1277 function() { 1278 window.getSelection().modify('move', 'forward', 'paragraph'); 1279 cvox.ChromeVoxEventWatcher.handleTextChanged(true); 1280 return true; 1281}; 1282 1283/** @override */ 1284cvox.ChromeVoxEditableContentEditable.prototype.moveCursorToPreviousParagraph = 1285 function() { 1286 window.getSelection().modify('move', 'backward', 'paragraph'); 1287 cvox.ChromeVoxEventWatcher.handleTextChanged(true); 1288 return true; 1289}; 1290 1291 1292/** 1293 * @override 1294 */ 1295cvox.ChromeVoxEditableContentEditable.prototype.shouldDescribeChange = 1296 function(evt) { 1297 var sel = window.getSelection(); 1298 var cursor = new cvox.Cursor(sel.baseNode, sel.baseOffset, ''); 1299 1300 // This is a very specific work around because of our buggy content editable 1301 // support. Blank new lines are not captured in the line indexing data 1302 // structures. 1303 // Scenario: given a piece of text like: 1304 // 1305 // Some Title 1306 // 1307 // Description 1308 // Footer 1309 // 1310 // The new lines after Title are not traversed to by TraverseUtil. A root fix 1311 // would make changes there. However, considering the fickle nature of that 1312 // code, we specifically detect for new lines here. 1313 if (Math.abs(this.start - evt.start) != 1 && 1314 this.start == this.end && 1315 evt.start == evt.end && 1316 sel.baseNode == sel.extentNode && 1317 sel.baseOffset == sel.extentOffset && 1318 sel.baseNode.nodeType == Node.ELEMENT_NODE && 1319 sel.baseNode.querySelector('BR') && 1320 cvox.TraverseUtil.forwardsChar(cursor, [], [])) { 1321 // This case detects if the range selection surrounds a new line, 1322 // but there is still content after the new line (like the example 1323 // above after "Title"). In these cases, we "pretend" we're the 1324 // last character so we speak "blank". 1325 return false; 1326 } 1327 1328 // Otherwise, we should never speak "blank" no matter what (even if 1329 // we're at the end of a content editable). 1330 return true; 1331}; 1332