1 // Copyright 2015 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.
5 goog
.provide('cvox.ChromeVoxEditableTextBase');
6 goog
.provide('cvox.TextChangeEvent');
7 goog
.provide('cvox.TypingEcho');
9 goog
.require('cvox.AbstractTts');
10 goog
.require('cvox.ChromeVox');
11 goog
.require('cvox.TtsInterface');
12 goog
.require('goog.i18n.MessageFormat');
16 * @fileoverview Generalized logic for providing spoken feedback when editing
17 * text fields, both single and multiline fields.
19 * {@code ChromeVoxEditableTextBase} is a generalized class that takes the
20 * current state in the form of a text string, a cursor start location and a
21 * cursor end location, and calls a speak method with the resulting text to
22 * be spoken. This class can be used directly for single line fields or
23 * extended to override methods that extract lines for multiline fields
24 * or to provide other customizations.
29 * A class containing the information needed to speak
30 * a text change event to the user.
33 * @param {string} newValue The new string value of the editable text control.
34 * @param {number} newStart The new 0-based start cursor/selection index.
35 * @param {number} newEnd The new 0-based end cursor/selection index.
36 * @param {boolean} triggeredByUser .
38 cvox
.TextChangeEvent = function(newValue
, newStart
, newEnd
, triggeredByUser
) {
39 this.value
= newValue
;
40 this.start
= newStart
;
42 this.triggeredByUser
= triggeredByUser
;
44 // Adjust offsets to be in left to right order.
45 if (this.start
> this.end
) {
46 var tempOffset
= this.end
;
47 this.end
= this.start
;
48 this.start
= tempOffset
;
54 * A list of typing echo options.
55 * This defines the way typed characters get spoken.
56 * CHARACTER: echoes typed characters.
57 * WORD: echoes a word once a breaking character is typed (i.e. spacebar).
58 * CHARACTER_AND_WORD: combines CHARACTER and WORD behavior.
59 * NONE: speaks nothing when typing.
60 * COUNT: The number of possible echo levels.
66 CHARACTER_AND_WORD
: 2,
73 * @param {number} cur Current typing echo.
74 * @return {number} Next typing echo.
76 cvox
.TypingEcho
.cycle = function(cur
) {
77 return (cur
+ 1) % cvox
.TypingEcho
.COUNT
;
82 * Return if characters should be spoken given the typing echo option.
83 * @param {number} typingEcho Typing echo option.
84 * @return {boolean} Whether the character should be spoken.
86 cvox
.TypingEcho
.shouldSpeakChar = function(typingEcho
) {
87 return typingEcho
== cvox
.TypingEcho
.CHARACTER_AND_WORD
||
88 typingEcho
== cvox
.TypingEcho
.CHARACTER
;
93 * A class representing an abstracted editable text control.
94 * @param {string} value The string value of the editable text control.
95 * @param {number} start The 0-based start cursor/selection index.
96 * @param {number} end The 0-based end cursor/selection index.
97 * @param {boolean} isPassword Whether the text control if a password field.
98 * @param {cvox.TtsInterface} tts A TTS object.
101 cvox
.ChromeVoxEditableTextBase = function(value
, start
, end
, isPassword
, tts
) {
103 * Current value of the text field.
110 * 0-based selection start index.
117 * 0-based selection end index.
124 * True if this is a password field.
128 this.isPassword
= isPassword
;
131 * Text-to-speech object implementing speak() and stop() methods.
132 * @type {cvox.TtsInterface}
138 * Whether or not the text field is multiline.
142 this.multiline
= false;
145 * Whether or not the last update to the text and selection was described.
147 * Some consumers of this flag like |ChromeVoxEventWatcher| depend on and
148 * react to when this flag is false by generating alternative feedback.
151 this.lastChangeDescribed
= false;
157 * Performs setup for this element.
159 cvox
.ChromeVoxEditableTextBase
.prototype.setup = function() {};
163 * Performs teardown for this element.
165 cvox
.ChromeVoxEditableTextBase
.prototype.teardown = function() {};
169 * Whether or not moving the cursor from one character to another considers
170 * the cursor to be a block (false) or an i-beam (true).
172 * If the cursor is a block, then the value of the character to the right
173 * of the cursor index is always read when the cursor moves, no matter what
174 * the previous cursor location was - this is how PC screenreaders work.
176 * If the cursor is an i-beam, moving the cursor by one character reads the
177 * character that was crossed over, which may be the character to the left or
178 * right of the new cursor index depending on the direction.
180 * If the current platform is a Mac, we will use an i-beam cursor. If not,
181 * then we will use the block cursor.
185 cvox
.ChromeVoxEditableTextBase
.useIBeamCursor
= cvox
.ChromeVox
.isMac
;
189 * Switches on or off typing echo based on events. When set, editable text
190 * updates for single-character insertions are handled in event watcher's key
194 cvox
.ChromeVoxEditableTextBase
.eventTypingEcho
= false;
198 * The maximum number of characters that are short enough to speak in response
199 * to an event. For example, if the user selects "Hello", we will speak
200 * "Hello, selected", but if the user selects 1000 characters, we will speak
201 * "text selected" instead.
205 cvox
.ChromeVoxEditableTextBase
.prototype.maxShortPhraseLen
= 60;
209 * Get the line number corresponding to a particular index.
210 * Default implementation that can be overridden by subclasses.
211 * @param {number} index The 0-based character index.
212 * @return {number} The 0-based line number corresponding to that character.
214 cvox
.ChromeVoxEditableTextBase
.prototype.getLineIndex = function(index
) {
220 * Get the start character index of a line.
221 * Default implementation that can be overridden by subclasses.
222 * @param {number} index The 0-based line index.
223 * @return {number} The 0-based index of the first character in this line.
225 cvox
.ChromeVoxEditableTextBase
.prototype.getLineStart = function(index
) {
231 * Get the end character index of a line.
232 * Default implementation that can be overridden by subclasses.
233 * @param {number} index The 0-based line index.
234 * @return {number} The 0-based index of the end of this line.
236 cvox
.ChromeVoxEditableTextBase
.prototype.getLineEnd = function(index
) {
237 return this.value
.length
;
242 * Get the full text of the current line.
243 * @param {number} index The 0-based line index.
244 * @return {string} The text of the line.
246 cvox
.ChromeVoxEditableTextBase
.prototype.getLine = function(index
) {
247 var lineStart
= this.getLineStart(index
);
248 var lineEnd
= this.getLineEnd(index
);
249 return this.value
.substr(lineStart
, lineEnd
- lineStart
);
254 * @param {string} ch The character to test.
255 * @return {boolean} True if a character is whitespace.
257 cvox
.ChromeVoxEditableTextBase
.prototype.isWhitespaceChar = function(ch
) {
258 return ch
== ' ' || ch
== '\n' || ch
== '\r' || ch
== '\t';
263 * @param {string} ch The character to test.
264 * @return {boolean} True if a character breaks a word, used to determine
265 * if the previous word should be spoken.
267 cvox
.ChromeVoxEditableTextBase
.prototype.isWordBreakChar = function(ch
) {
268 return !!ch
.match(/^\W$/);
273 * @param {cvox.TextChangeEvent} evt The new text changed event to test.
274 * @return {boolean} True if the event, when compared to the previous text,
275 * should trigger description.
277 cvox
.ChromeVoxEditableTextBase
.prototype.shouldDescribeChange = function(evt
) {
278 if (evt
.value
== this.value
&&
279 evt
.start
== this.start
&&
280 evt
.end
== this.end
) {
288 * Speak text, but if it's a single character, describe the character.
289 * @param {string} str The string to speak.
290 * @param {boolean=} opt_triggeredByUser True if the speech was triggered by a
292 * @param {Object=} opt_personality Personality used to speak text.
294 cvox
.ChromeVoxEditableTextBase
.prototype.speak
=
295 function(str
, opt_triggeredByUser
, opt_personality
) {
296 var queueMode
= cvox
.QueueMode
.QUEUE
;
297 if (opt_triggeredByUser
=== true) {
298 queueMode
= cvox
.QueueMode
.FLUSH
;
300 this.tts
.speak(str
, queueMode
, opt_personality
|| {});
305 * Update the state of the text and selection and describe any changes as
308 * @param {cvox.TextChangeEvent} evt The text change event.
310 cvox
.ChromeVoxEditableTextBase
.prototype.changed = function(evt
) {
311 if (!this.shouldDescribeChange(evt
)) {
312 this.lastChangeDescribed
= false;
316 if (evt
.value
== this.value
) {
317 this.describeSelectionChanged(evt
);
319 this.describeTextChanged(evt
);
321 this.lastChangeDescribed
= true;
323 this.value
= evt
.value
;
324 this.start
= evt
.start
;
330 * Describe a change in the selection or cursor position when the text
332 * @param {cvox.TextChangeEvent} evt The text change event.
334 cvox
.ChromeVoxEditableTextBase
.prototype.describeSelectionChanged
=
336 // TODO(deboer): Factor this into two function:
337 // - one to determine the selection event
340 if (this.isPassword
) {
341 this.speak((new goog
.i18n
.MessageFormat(cvox
.ChromeVox
.msgs
.getMsg('dot'))
342 .format({'COUNT': 1})), evt
.triggeredByUser
);
345 if (evt
.start
== evt
.end
) {
346 // It's currently a cursor.
347 if (this.start
!= this.end
) {
348 // It was previously a selection, so just announce 'unselected'.
349 this.speak(cvox
.ChromeVox
.msgs
.getMsg('Unselected'), evt
.triggeredByUser
);
350 } else if (this.getLineIndex(this.start
) !=
351 this.getLineIndex(evt
.start
)) {
352 // Moved to a different line; read it.
353 var lineValue
= this.getLine(this.getLineIndex(evt
.start
));
354 if (lineValue
== '') {
355 lineValue
= cvox
.ChromeVox
.msgs
.getMsg('text_box_blank');
356 } else if (/^\s+$/.test(lineValue
)) {
357 lineValue
= cvox
.ChromeVox
.msgs
.getMsg('text_box_whitespace');
359 this.speak(lineValue
, evt
.triggeredByUser
);
360 } else if (this.start
== evt
.start
+ 1 ||
361 this.start
== evt
.start
- 1) {
362 // Moved by one character; read it.
363 if (!cvox
.ChromeVoxEditableTextBase
.useIBeamCursor
) {
364 if (evt
.start
== this.value
.length
) {
365 if (cvox
.ChromeVox
.verbosity
== cvox
.VERBOSITY_VERBOSE
) {
366 this.speak(cvox
.ChromeVox
.msgs
.getMsg('end_of_text_verbose'),
367 evt
.triggeredByUser
);
369 this.speak(cvox
.ChromeVox
.msgs
.getMsg('end_of_text_brief'),
370 evt
.triggeredByUser
);
373 this.speak(this.value
.substr(evt
.start
, 1),
375 {'phoneticCharacters': evt
.triggeredByUser
});
378 this.speak(this.value
.substr(Math
.min(this.start
, evt
.start
), 1),
380 {'phoneticCharacters': evt
.triggeredByUser
});
383 // Moved by more than one character. Read all characters crossed.
384 this.speak(this.value
.substr(Math
.min(this.start
, evt
.start
),
385 Math
.abs(this.start
- evt
.start
)), evt
.triggeredByUser
);
388 // It's currently a selection.
389 if (this.start
+ 1 == evt
.start
&&
390 this.end
== this.value
.length
&&
391 evt
.end
== this.value
.length
) {
392 // Autocomplete: the user typed one character of autocompleted text.
393 this.speak(this.value
.substr(this.start
, 1), evt
.triggeredByUser
);
394 this.speak(this.value
.substr(evt
.start
));
395 } else if (this.start
== this.end
) {
396 // It was previously a cursor.
397 this.speak(this.value
.substr(evt
.start
, evt
.end
- evt
.start
),
398 evt
.triggeredByUser
);
399 this.speak(cvox
.ChromeVox
.msgs
.getMsg('selected'));
400 } else if (this.start
== evt
.start
&& this.end
< evt
.end
) {
401 this.speak(this.value
.substr(this.end
, evt
.end
- this.end
),
402 evt
.triggeredByUser
);
403 this.speak(cvox
.ChromeVox
.msgs
.getMsg('added_to_selection'));
404 } else if (this.start
== evt
.start
&& this.end
> evt
.end
) {
405 this.speak(this.value
.substr(evt
.end
, this.end
- evt
.end
),
406 evt
.triggeredByUser
);
407 this.speak(cvox
.ChromeVox
.msgs
.getMsg('removed_from_selection'));
408 } else if (this.end
== evt
.end
&& this.start
> evt
.start
) {
409 this.speak(this.value
.substr(evt
.start
, this.start
- evt
.start
),
410 evt
.triggeredByUser
);
411 this.speak(cvox
.ChromeVox
.msgs
.getMsg('added_to_selection'));
412 } else if (this.end
== evt
.end
&& this.start
< evt
.start
) {
413 this.speak(this.value
.substr(this.start
, evt
.start
- this.start
),
414 evt
.triggeredByUser
);
415 this.speak(cvox
.ChromeVox
.msgs
.getMsg('removed_from_selection'));
417 // The selection changed but it wasn't an obvious extension of
418 // a previous selection. Just read the new selection.
419 this.speak(this.value
.substr(evt
.start
, evt
.end
- evt
.start
),
420 evt
.triggeredByUser
);
421 this.speak(cvox
.ChromeVox
.msgs
.getMsg('selected'));
428 * Describe a change where the text changes.
429 * @param {cvox.TextChangeEvent} evt The text change event.
431 cvox
.ChromeVoxEditableTextBase
.prototype.describeTextChanged = function(evt
) {
432 var personality
= {};
433 if (evt
.value
.length
< this.value
.length
) {
434 personality
= cvox
.AbstractTts
.PERSONALITY_DELETED
;
436 if (this.isPassword
) {
437 this.speak((new goog
.i18n
.MessageFormat(cvox
.ChromeVox
.msgs
.getMsg('dot'))
438 .format({'COUNT': 1})), evt
.triggeredByUser
, personality
);
442 var value
= this.value
;
443 var len
= value
.length
;
444 var newLen
= evt
.value
.length
;
445 var autocompleteSuffix
= '';
446 // Make a copy of evtValue and evtEnd to avoid changing anything in
448 var evtValue
= evt
.value
;
449 var evtEnd
= evt
.end
;
451 // First, see if there's a selection at the end that might have been
452 // added by autocomplete. If so, strip it off into a separate variable.
453 if (evt
.start
< evtEnd
&& evtEnd
== newLen
) {
454 autocompleteSuffix
= evtValue
.substr(evt
.start
);
455 evtValue
= evtValue
.substr(0, evt
.start
);
459 // Now see if the previous selection (if any) was deleted
460 // and any new text was inserted at that character position.
461 // This would handle pasting and entering text by typing, both from
462 // a cursor and from a selection.
463 var prefixLen
= this.start
;
464 var suffixLen
= len
- this.end
;
465 if (newLen
>= prefixLen
+ suffixLen
+ (evtEnd
- evt
.start
) &&
466 evtValue
.substr(0, prefixLen
) == value
.substr(0, prefixLen
) &&
467 evtValue
.substr(newLen
- suffixLen
) == value
.substr(this.end
)) {
468 // However, in a dynamic content editable, defer to authoritative events
469 // (clipboard, key press) to reduce guess work when observing insertions.
470 // Only use this logic when observing deletions (and insertion of word
472 // TODO(dtseng): Think about a more reliable way to do this.
473 if (!(this instanceof cvox
.ChromeVoxEditableContentEditable
) ||
475 this.isWordBreakChar(evt
.value
[newLen
- 1] || '')) {
476 this.describeTextChangedHelper(
477 evt
, prefixLen
, suffixLen
, autocompleteSuffix
, personality
);
482 // Next, see if one or more characters were deleted from the previous
483 // cursor position and the new cursor is in the expected place. This
484 // handles backspace, forward-delete, and similar shortcuts that delete
486 prefixLen
= evt
.start
;
487 suffixLen
= newLen
- evtEnd
;
488 if (this.start
== this.end
&&
489 evt
.start
== evtEnd
&&
490 evtValue
.substr(0, prefixLen
) == value
.substr(0, prefixLen
) &&
491 evtValue
.substr(newLen
- suffixLen
) ==
492 value
.substr(len
- suffixLen
)) {
493 this.describeTextChangedHelper(
494 evt
, prefixLen
, suffixLen
, autocompleteSuffix
, personality
);
498 // If all else fails, we assume the change was not the result of a normal
499 // user editing operation, so we'll have to speak feedback based only
500 // on the changes to the text, not the cursor position / selection.
501 // First, restore the autocomplete text if any.
502 evtValue
+= autocompleteSuffix
;
504 // Try to do a diff between the new and the old text. If it is a one character
505 // insertion/deletion at the start or at the end, just speak that character.
506 if ((evtValue
.length
== (value
.length
+ 1)) ||
507 ((evtValue
.length
+ 1) == value
.length
)) {
508 // The user added text either to the beginning or the end.
509 if (evtValue
.length
> value
.length
) {
510 if (evtValue
.indexOf(value
) == 0) {
511 this.speak(evtValue
[evtValue
.length
- 1], evt
.triggeredByUser
,
514 } else if (evtValue
.indexOf(value
) == 1) {
515 this.speak(evtValue
[0], evt
.triggeredByUser
, personality
);
519 // The user deleted text either from the beginning or the end.
520 if (evtValue
.length
< value
.length
) {
521 if (value
.indexOf(evtValue
) == 0) {
522 this.speak(value
[value
.length
- 1], evt
.triggeredByUser
, personality
);
524 } else if (value
.indexOf(evtValue
) == 1) {
525 this.speak(value
[0], evt
.triggeredByUser
, personality
);
531 if (this.multiline
) {
532 // Fall back to announce deleted but omit the text that was deleted.
533 if (evt
.value
.length
< this.value
.length
) {
534 this.speak(cvox
.ChromeVox
.msgs
.getMsg('text_deleted'),
535 evt
.triggeredByUser
, personality
);
537 // The below is a somewhat loose way to deal with non-standard
538 // insertions/deletions. Intentionally skip for multiline since deletion
539 // announcements are covered above and insertions are non-standard (possibly
540 // due to auto complete). Since content editable's often refresh content by
541 // removing and inserting entire chunks of text, this type of logic often
542 // results in unintended consequences such as reading all text when only one
543 // character has been entered.
547 // If the text is short, just speak the whole thing.
548 if (newLen
<= this.maxShortPhraseLen
) {
549 this.describeTextChangedHelper(evt
, 0, 0, '', personality
);
553 // Otherwise, look for the common prefix and suffix, but back up so
554 // that we can speak complete words, to be minimally confusing.
556 while (prefixLen
< len
&&
557 prefixLen
< newLen
&&
558 value
[prefixLen
] == evtValue
[prefixLen
]) {
561 while (prefixLen
> 0 && !this.isWordBreakChar(value
[prefixLen
- 1])) {
566 while (suffixLen
< (len
- prefixLen
) &&
567 suffixLen
< (newLen
- prefixLen
) &&
568 value
[len
- suffixLen
- 1] == evtValue
[newLen
- suffixLen
- 1]) {
571 while (suffixLen
> 0 && !this.isWordBreakChar(value
[len
- suffixLen
])) {
575 this.describeTextChangedHelper(evt
, prefixLen
, suffixLen
, '', personality
);
580 * The function called by describeTextChanged after it's figured out
581 * what text was deleted, what text was inserted, and what additional
582 * autocomplete text was added.
583 * @param {cvox.TextChangeEvent} evt The text change event.
584 * @param {number} prefixLen The number of characters in the common prefix
585 * of this.value and newValue.
586 * @param {number} suffixLen The number of characters in the common suffix
587 * of this.value and newValue.
588 * @param {string} autocompleteSuffix The autocomplete string that was added
589 * to the end, if any. It should be spoken at the end of the utterance
590 * describing the change.
591 * @param {Object=} opt_personality Personality to speak the text.
593 cvox
.ChromeVoxEditableTextBase
.prototype.describeTextChangedHelper = function(
594 evt
, prefixLen
, suffixLen
, autocompleteSuffix
, opt_personality
) {
595 var len
= this.value
.length
;
596 var newLen
= evt
.value
.length
;
597 var deletedLen
= len
- prefixLen
- suffixLen
;
598 var deleted
= this.value
.substr(prefixLen
, deletedLen
);
599 var insertedLen
= newLen
- prefixLen
- suffixLen
;
600 var inserted
= evt
.value
.substr(prefixLen
, insertedLen
);
602 var triggeredByUser
= evt
.triggeredByUser
;
604 if (insertedLen
> 1) {
605 utterance
= inserted
;
606 } else if (insertedLen
== 1) {
607 if ((cvox
.ChromeVox
.typingEcho
== cvox
.TypingEcho
.WORD
||
608 cvox
.ChromeVox
.typingEcho
== cvox
.TypingEcho
.CHARACTER_AND_WORD
) &&
609 this.isWordBreakChar(inserted
) &&
611 !this.isWordBreakChar(evt
.value
.substr(prefixLen
- 1, 1))) {
612 // Speak previous word.
613 var index
= prefixLen
;
614 while (index
> 0 && !this.isWordBreakChar(evt
.value
[index
- 1])) {
617 if (index
< prefixLen
) {
618 utterance
= evt
.value
.substr(index
, prefixLen
+ 1 - index
);
620 utterance
= inserted
;
621 triggeredByUser
= false; // Implies QUEUE_MODE_QUEUE.
623 } else if (cvox
.ChromeVox
.typingEcho
== cvox
.TypingEcho
.CHARACTER
||
624 cvox
.ChromeVox
.typingEcho
== cvox
.TypingEcho
.CHARACTER_AND_WORD
) {
625 // This particular case is handled in event watcher. See the key press
626 // handler for more details.
627 utterance
= cvox
.ChromeVoxEditableTextBase
.eventTypingEcho
? '' :
630 } else if (deletedLen
> 1 && !autocompleteSuffix
) {
631 utterance
= deleted
+ ', deleted';
632 } else if (deletedLen
== 1) {
636 if (autocompleteSuffix
&& utterance
) {
637 utterance
+= ', ' + autocompleteSuffix
;
638 } else if (autocompleteSuffix
) {
639 utterance
= autocompleteSuffix
;
643 this.speak(utterance
, triggeredByUser
, opt_personality
);
649 * Moves the cursor forward by one character.
650 * @return {boolean} True if the action was handled.
652 cvox
.ChromeVoxEditableTextBase
.prototype.moveCursorToNextCharacter
=
653 function() { return false; };
657 * Moves the cursor backward by one character.
658 * @return {boolean} True if the action was handled.
660 cvox
.ChromeVoxEditableTextBase
.prototype.moveCursorToPreviousCharacter
=
661 function() { return false; };
665 * Moves the cursor forward by one word.
666 * @return {boolean} True if the action was handled.
668 cvox
.ChromeVoxEditableTextBase
.prototype.moveCursorToNextWord
=
669 function() { return false; };
673 * Moves the cursor backward by one word.
674 * @return {boolean} True if the action was handled.
676 cvox
.ChromeVoxEditableTextBase
.prototype.moveCursorToPreviousWord
=
677 function() { return false; };
681 * Moves the cursor forward by one line.
682 * @return {boolean} True if the action was handled.
684 cvox
.ChromeVoxEditableTextBase
.prototype.moveCursorToNextLine
=
685 function() { return false; };
689 * Moves the cursor backward by one line.
690 * @return {boolean} True if the action was handled.
692 cvox
.ChromeVoxEditableTextBase
.prototype.moveCursorToPreviousLine
=
693 function() { return false; };
697 * Moves the cursor forward by one paragraph.
698 * @return {boolean} True if the action was handled.
700 cvox
.ChromeVoxEditableTextBase
.prototype.moveCursorToNextParagraph
=
701 function() { return false; };
705 * Moves the cursor backward by one paragraph.
706 * @return {boolean} True if the action was handled.
708 cvox
.ChromeVoxEditableTextBase
.prototype.moveCursorToPreviousParagraph
=
709 function() { return false; };
712 /******************************************/