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.
5 goog.provide('cvox.ChromeVoxEditableContentEditable');
6 goog.provide('cvox.ChromeVoxEditableElement');
7 goog.provide('cvox.ChromeVoxEditableHTMLInput');
8 goog.provide('cvox.ChromeVoxEditableTextArea');
9 goog.provide('cvox.TextHandlerInterface');
12 goog.require('cvox.BrailleTextHandler');
13 goog.require('cvox.ChromeVoxEditableTextBase');
14 goog.require('cvox.ContentEditableExtractor');
15 goog.require('cvox.DomUtil');
16 goog.require('cvox.EditableTextAreaShadow');
17 goog.require('cvox.TextChangeEvent');
18 goog.require('cvox.TtsInterface');
21 * @fileoverview Gives the user spoken and braille feedback as they type,
22 * select text, and move the cursor in editable HTML text controls, including
23 * multiline controls and contenteditable regions.
25 * The two subclasses, ChromeVoxEditableHTMLInput and
26 * ChromeVoxEditableTextArea, take a HTML input (type=text) or HTML
27 * textarea node (respectively) in the constructor, and automatically
28 * handle retrieving the current state of the control, including
29 * computing line break information for a textarea using an offscreen
30 * shadow object. It is the responsibility of the user of these classes to
31 * trap key and focus events and call the update method as needed.
37 * An interface for being notified when the text changes.
40 cvox.TextHandlerInterface = function() {};
44 * Called when text changes.
45 * @param {cvox.TextChangeEvent} evt The text change event.
47 cvox.TextHandlerInterface.prototype.changed = function(evt) {};
51 * A subclass of ChromeVoxEditableTextBase a text element that's part of
52 * the webpage DOM. Contains common code shared by both EditableHTMLInput
53 * and EditableTextArea, but that might not apply to a non-DOM text box.
54 * @param {Element} node A DOM node which allows text input.
55 * @param {string} value The string value of the editable text control.
56 * @param {number} start The 0-based start cursor/selection index.
57 * @param {number} end The 0-based end cursor/selection index.
58 * @param {boolean} isPassword Whether the text control if a password field.
59 * @param {cvox.TtsInterface} tts A TTS object.
60 * @extends {cvox.ChromeVoxEditableTextBase}
63 cvox.ChromeVoxEditableElement = function(node, value, start, end, isPassword,
65 goog.base(this, value, start, end, isPassword, tts);
68 * An optional handler for braille output.
69 * @type {cvox.BrailleTextHandler|undefined}
72 this.brailleHandler_ = cvox.ChromeVox.braille ?
73 new cvox.BrailleTextHandler(cvox.ChromeVox.braille) : undefined;
76 * The DOM node which allows text input.
83 * True if the description was just spoken.
87 this.justSpokeDescription_ = false;
89 goog.inherits(cvox.ChromeVoxEditableElement,
90 cvox.ChromeVoxEditableTextBase);
94 cvox.ChromeVoxEditableElement.prototype.changed = function(evt) {
95 // Ignore changes to the cursor and selection if they happen immediately
96 // after the description was just spoken. This avoid double-speaking when,
97 // for example, a text field is focused and then a moment later the
98 // contents are selected. If the value changes, though, this change will
100 if (this.justSpokeDescription_ && this.value == evt.value) {
101 this.value = evt.value;
102 this.start = evt.start;
104 this.justSpokeDescription_ = false;
106 goog.base(this, 'changed', evt);
107 if (this.lastChangeDescribed) {
108 this.brailleCurrentLine_();
114 cvox.ChromeVoxEditableElement.prototype.speak = function(
115 str, opt_triggeredByUser, opt_personality) {
116 // If there is a node associated with the editable text object,
117 // make sure that node has focus before speaking it.
118 if (this.node && (document.activeElement != this.node)) {
121 goog.base(this, 'speak', str, opt_triggeredByUser, opt_personality);
125 cvox.ChromeVoxEditableElement.prototype.moveCursorToNextCharacter = function() {
126 var node = this.node;
128 node.selectionStart = node.selectionEnd;
129 cvox.ChromeVoxEventWatcher.handleTextChanged(true);
135 cvox.ChromeVoxEditableElement.prototype.moveCursorToPreviousCharacter =
137 var node = this.node;
138 node.selectionStart--;
139 node.selectionEnd = node.selectionStart;
140 cvox.ChromeVoxEventWatcher.handleTextChanged(true);
146 cvox.ChromeVoxEditableElement.prototype.moveCursorToNextWord = function() {
147 var node = this.node;
148 var length = node.value.length;
150 var substring = node.value.substring(node.selectionEnd);
151 var match = re.exec(substring);
152 if (match !== null && match.index == 0) {
153 // Ignore word-breaking sequences right next to the cursor.
154 match = re.exec(substring);
156 var index = (match === null) ? length : match.index + node.selectionEnd;
157 node.selectionStart = node.selectionEnd = index;
158 cvox.ChromeVoxEventWatcher.handleTextChanged(true);
164 cvox.ChromeVoxEditableElement.prototype.moveCursorToPreviousWord = function() {
165 var node = this.node;
166 var length = node.value.length;
168 var substring = node.value.substring(0, node.selectionStart);
170 while (re.exec(substring) !== null) {
171 if (re.lastIndex < node.selectionStart) {
172 index = re.lastIndex;
175 node.selectionStart = node.selectionEnd = index;
176 cvox.ChromeVoxEventWatcher.handleTextChanged(true);
182 cvox.ChromeVoxEditableElement.prototype.moveCursorToNextParagraph =
184 var node = this.node;
185 var length = node.value.length;
186 var index = node.selectionEnd >= length ? length :
187 node.value.indexOf('\n', node.selectionEnd);
191 node.selectionStart = node.selectionEnd = index + 1;
192 cvox.ChromeVoxEventWatcher.handleTextChanged(true);
198 cvox.ChromeVoxEditableElement.prototype.moveCursorToPreviousParagraph =
200 var node = this.node;
201 var index = node.selectionStart <= 0 ? 0 :
202 node.value.lastIndexOf('\n', node.selectionStart - 2) + 1;
206 node.selectionStart = node.selectionEnd = index;
207 cvox.ChromeVoxEventWatcher.handleTextChanged(true);
212 * Shows the current line on the braille display.
215 cvox.ChromeVoxEditableElement.prototype.brailleCurrentLine_ = function() {
216 if (this.brailleHandler_) {
217 var lineIndex = this.getLineIndex(this.start);
218 var line = this.getLine(lineIndex);
219 // Collapsable whitespace inside the contenteditable is represented
220 // as non-breaking spaces. This confuses braille input (which relies on
221 // the text being added to be the same as the text in the input field).
222 // Since the non-breaking spaces are just an artifact of how
223 // contenteditable is implemented, normalize to normal spaces instead.
224 if (this instanceof cvox.ChromeVoxEditableContentEditable) {
225 line = line.replace(/\u00A0/g, ' ');
227 var lineStart = this.getLineStart(lineIndex);
228 var start = this.start - lineStart;
229 var end = Math.min(this.end - lineStart, line.length);
230 this.brailleHandler_.changed(line, start, end, this.multiline, this.node,
235 /******************************************/
239 * A subclass of ChromeVoxEditableElement for an HTMLInputElement.
240 * @param {HTMLInputElement} node The HTMLInputElement node.
241 * @param {cvox.TtsInterface} tts A TTS object.
242 * @extends {cvox.ChromeVoxEditableElement}
243 * @implements {cvox.TextHandlerInterface}
246 cvox.ChromeVoxEditableHTMLInput = function(node, tts) {
254 node.type === 'password',
257 goog.inherits(cvox.ChromeVoxEditableHTMLInput,
258 cvox.ChromeVoxEditableElement);
262 * Performs setup for this input node.
263 * This accounts for exception-throwing behavior introduced by crbug.com/324360.
266 cvox.ChromeVoxEditableHTMLInput.prototype.setup = function() {
270 if (!cvox.DomUtil.doesInputSupportSelection(this.node)) {
271 this.originalType = this.node.type;
272 this.node.type = 'text';
278 * Performs teardown for this input node.
279 * This accounts for exception-throwing behavior introduced by crbug.com/324360.
282 cvox.ChromeVoxEditableHTMLInput.prototype.teardown = function() {
283 if (this.node && this.originalType) {
284 this.node.type = this.originalType;
290 * Update the state of the text and selection and describe any changes as
293 * @param {boolean} triggeredByUser True if this was triggered by a user action.
295 cvox.ChromeVoxEditableHTMLInput.prototype.update = function(triggeredByUser) {
296 var newValue = this.node.value;
297 var textChangeEvent = new cvox.TextChangeEvent(newValue,
298 this.node.selectionStart,
299 this.node.selectionEnd,
301 this.changed(textChangeEvent);
305 /******************************************/
309 * A subclass of ChromeVoxEditableElement for an HTMLTextAreaElement.
310 * @param {HTMLTextAreaElement} node The HTMLTextAreaElement node.
311 * @param {cvox.TtsInterface} tts A TTS object.
312 * @extends {cvox.ChromeVoxEditableElement}
313 * @implements {cvox.TextHandlerInterface}
316 cvox.ChromeVoxEditableTextArea = function(node, tts) {
317 goog.base(this, node, node.value, node.selectionStart, node.selectionEnd,
318 false /* isPassword */, tts);
319 this.multiline = true;
322 * True if the shadow is up-to-date with the current value of this text area.
326 this.shadowIsCurrent_ = false;
328 goog.inherits(cvox.ChromeVoxEditableTextArea,
329 cvox.ChromeVoxEditableElement);
333 * An offscreen div used to compute the line numbers. A single div is
334 * shared by all instances of the class.
335 * @type {!cvox.EditableTextAreaShadow|undefined}
338 cvox.ChromeVoxEditableTextArea.shadow_;
342 * Update the state of the text and selection and describe any changes as
345 * @param {boolean} triggeredByUser True if this was triggered by a user action.
347 cvox.ChromeVoxEditableTextArea.prototype.update = function(triggeredByUser) {
348 if (this.node.value != this.value) {
349 this.shadowIsCurrent_ = false;
351 var textChangeEvent = new cvox.TextChangeEvent(this.node.value,
352 this.node.selectionStart, this.node.selectionEnd, triggeredByUser);
353 this.changed(textChangeEvent);
358 * Get the line number corresponding to a particular index.
359 * @param {number} index The 0-based character index.
360 * @return {number} The 0-based line number corresponding to that character.
362 cvox.ChromeVoxEditableTextArea.prototype.getLineIndex = function(index) {
363 return this.getShadow().getLineIndex(index);
368 * Get the start character index of a line.
369 * @param {number} index The 0-based line index.
370 * @return {number} The 0-based index of the first character in this line.
372 cvox.ChromeVoxEditableTextArea.prototype.getLineStart = function(index) {
373 return this.getShadow().getLineStart(index);
378 * Get the end character index of a line.
379 * @param {number} index The 0-based line index.
380 * @return {number} The 0-based index of the end of this line.
382 cvox.ChromeVoxEditableTextArea.prototype.getLineEnd = function(index) {
383 return this.getShadow().getLineEnd(index);
388 * Update the shadow object, an offscreen div used to compute line numbers.
389 * @return {!cvox.EditableTextAreaShadow} The shadow object.
391 cvox.ChromeVoxEditableTextArea.prototype.getShadow = function() {
392 var shadow = cvox.ChromeVoxEditableTextArea.shadow_;
394 shadow = cvox.ChromeVoxEditableTextArea.shadow_ =
395 new cvox.EditableTextAreaShadow();
397 if (!this.shadowIsCurrent_) {
398 shadow.update(this.node);
399 this.shadowIsCurrent_ = true;
406 cvox.ChromeVoxEditableTextArea.prototype.moveCursorToNextLine = function() {
407 var node = this.node;
408 var length = node.value.length;
409 if (node.selectionEnd >= length) {
412 var shadow = this.getShadow();
413 var lineIndex = shadow.getLineIndex(node.selectionEnd);
414 var lineStart = shadow.getLineStart(lineIndex);
415 var offset = node.selectionEnd - lineStart;
416 var lastLine = (length == 0) ? 0 : shadow.getLineIndex(length - 1);
417 var newCursorPosition = (lineIndex >= lastLine) ? length :
418 Math.min(shadow.getLineStart(lineIndex + 1) + offset,
419 shadow.getLineEnd(lineIndex + 1));
420 node.selectionStart = node.selectionEnd = newCursorPosition;
421 cvox.ChromeVoxEventWatcher.handleTextChanged(true);
427 cvox.ChromeVoxEditableTextArea.prototype.moveCursorToPreviousLine = function() {
428 var node = this.node;
429 if (node.selectionStart <= 0) {
432 var shadow = this.getShadow();
433 var lineIndex = shadow.getLineIndex(node.selectionStart);
434 var lineStart = shadow.getLineStart(lineIndex);
435 var offset = node.selectionStart - lineStart;
436 var newCursorPosition = (lineIndex <= 0) ? 0 :
437 Math.min(shadow.getLineStart(lineIndex - 1) + offset,
438 shadow.getLineEnd(lineIndex - 1));
439 node.selectionStart = node.selectionEnd = newCursorPosition;
440 cvox.ChromeVoxEventWatcher.handleTextChanged(true);
445 /******************************************/
449 * A subclass of ChromeVoxEditableElement for elements that are contentEditable.
450 * This is also used for a region of HTML with the ARIA role of "textbox",
451 * so that an author can create a pure-JavaScript editable text object - this
452 * will work the same as contentEditable as long as the DOM selection is
453 * updated properly within the textbox when it has focus.
454 * @param {Element} node The root contentEditable node.
455 * @param {cvox.TtsInterface} tts A TTS object.
456 * @extends {cvox.ChromeVoxEditableElement}
457 * @implements {cvox.TextHandlerInterface}
460 cvox.ChromeVoxEditableContentEditable = function(node, tts) {
461 goog.base(this, node, '', 0, 0, false /* isPassword */, tts);
465 * True if the ContentEditableExtractor is current with this field's data.
469 this.extractorIsCurrent_ = false;
471 var extractor = this.getExtractor();
472 this.value = extractor.getText();
473 this.start = extractor.getStartIndex();
474 this.end = extractor.getEndIndex();
475 this.multiline = true;
477 goog.inherits(cvox.ChromeVoxEditableContentEditable,
478 cvox.ChromeVoxEditableElement);
481 * A helper used to compute the line numbers. A single object is
482 * shared by all instances of the class.
483 * @type {!cvox.ContentEditableExtractor|undefined}
486 cvox.ChromeVoxEditableContentEditable.extractor_;
490 * Update the state of the text and selection and describe any changes as
493 * @param {boolean} triggeredByUser True if this was triggered by a user action.
495 cvox.ChromeVoxEditableContentEditable.prototype.update =
496 function(triggeredByUser) {
497 this.extractorIsCurrent_ = false;
498 var textChangeEvent = new cvox.TextChangeEvent(
499 this.getExtractor().getText(),
500 this.getExtractor().getStartIndex(),
501 this.getExtractor().getEndIndex(),
503 this.changed(textChangeEvent);
508 * Get the line number corresponding to a particular index.
509 * @param {number} index The 0-based character index.
510 * @return {number} The 0-based line number corresponding to that character.
512 cvox.ChromeVoxEditableContentEditable.prototype.getLineIndex = function(index) {
513 return this.getExtractor().getLineIndex(index);
518 * Get the start character index of a line.
519 * @param {number} index The 0-based line index.
520 * @return {number} The 0-based index of the first character in this line.
522 cvox.ChromeVoxEditableContentEditable.prototype.getLineStart = function(index) {
523 return this.getExtractor().getLineStart(index);
528 * Get the end character index of a line.
529 * @param {number} index The 0-based line index.
530 * @return {number} The 0-based index of the end of this line.
532 cvox.ChromeVoxEditableContentEditable.prototype.getLineEnd = function(index) {
533 return this.getExtractor().getLineEnd(index);
538 * Update the extractor object, an offscreen div used to compute line numbers.
539 * @return {!cvox.ContentEditableExtractor} The extractor object.
541 cvox.ChromeVoxEditableContentEditable.prototype.getExtractor = function() {
542 var extractor = cvox.ChromeVoxEditableContentEditable.extractor_;
544 extractor = cvox.ChromeVoxEditableContentEditable.extractor_ =
545 new cvox.ContentEditableExtractor();
547 if (!this.extractorIsCurrent_) {
548 extractor.update(this.node);
549 this.extractorIsCurrent_ = true;
556 cvox.ChromeVoxEditableContentEditable.prototype.changed =
558 if (!evt.triggeredByUser) {
561 // Take over here if we can't describe a change; assume it's a blank line.
562 if (!this.shouldDescribeChange(evt)) {
563 this.speak(cvox.ChromeVox.msgs.getMsg('text_box_blank'), true);
564 if (this.brailleHandler_) {
565 this.brailleHandler_.changed('' /*line*/, 0 /*start*/, 0 /*end*/,
566 true /*multiline*/, null /*element*/,
567 evt.start /*lineStart*/);
570 goog.base(this, 'changed', evt);
576 cvox.ChromeVoxEditableContentEditable.prototype.moveCursorToNextCharacter =
578 window.getSelection().modify('move', 'forward', 'character');
579 cvox.ChromeVoxEventWatcher.handleTextChanged(true);
585 cvox.ChromeVoxEditableContentEditable.prototype.moveCursorToPreviousCharacter =
587 window.getSelection().modify('move', 'backward', 'character');
588 cvox.ChromeVoxEventWatcher.handleTextChanged(true);
594 cvox.ChromeVoxEditableContentEditable.prototype.moveCursorToNextParagraph =
596 window.getSelection().modify('move', 'forward', 'paragraph');
597 cvox.ChromeVoxEventWatcher.handleTextChanged(true);
602 cvox.ChromeVoxEditableContentEditable.prototype.moveCursorToPreviousParagraph =
604 window.getSelection().modify('move', 'backward', 'paragraph');
605 cvox.ChromeVoxEventWatcher.handleTextChanged(true);
613 cvox.ChromeVoxEditableContentEditable.prototype.shouldDescribeChange =
615 var sel = window.getSelection();
616 var cursor = new cvox.Cursor(sel.baseNode, sel.baseOffset, '');
618 // This is a very specific work around because of our buggy content editable
619 // support. Blank new lines are not captured in the line indexing data
621 // Scenario: given a piece of text like:
628 // The new lines after Title are not traversed to by TraverseUtil. A root fix
629 // would make changes there. However, considering the fickle nature of that
630 // code, we specifically detect for new lines here.
631 if (Math.abs(this.start - evt.start) != 1 &&
632 this.start == this.end &&
633 evt.start == evt.end &&
634 sel.baseNode == sel.extentNode &&
635 sel.baseOffset == sel.extentOffset &&
636 sel.baseNode.nodeType == Node.ELEMENT_NODE &&
637 sel.baseNode.querySelector('BR') &&
638 cvox.TraverseUtil.forwardsChar(cursor, [], [])) {
639 // This case detects if the range selection surrounds a new line,
640 // but there is still content after the new line (like the example
641 // above after "Title"). In these cases, we "pretend" we're the
642 // last character so we speak "blank".
646 // Otherwise, we should never speak "blank" no matter what (even if
647 // we're at the end of a content editable).