blob: a18a44cf83f1419b6ffbef4b6a6505f19fc94b70 [file] [log] [blame]
// Copyright 2014 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
goog.provide('cvox.ChromeVoxEditableContentEditable');
goog.provide('cvox.ChromeVoxEditableElement');
goog.provide('cvox.ChromeVoxEditableHTMLInput');
goog.provide('cvox.ChromeVoxEditableTextArea');
goog.provide('cvox.TextHandlerInterface');
goog.require('cvox.BrailleTextHandler');
goog.require('cvox.ChromeVoxEditableTextBase');
goog.require('cvox.ContentEditableExtractor');
goog.require('cvox.DomUtil');
goog.require('cvox.EditableTextAreaShadow');
goog.require('cvox.TextChangeEvent');
goog.require('cvox.TtsInterface');
/**
* @fileoverview Gives the user spoken and braille feedback as they type,
* select text, and move the cursor in editable HTML text controls, including
* multiline controls and contenteditable regions.
*
* The two subclasses, ChromeVoxEditableHTMLInput and
* ChromeVoxEditableTextArea, take a HTML input (type=text) or HTML
* textarea node (respectively) in the constructor, and automatically
* handle retrieving the current state of the control, including
* computing line break information for a textarea using an offscreen
* shadow object. It is the responsibility of the user of these classes to
* trap key and focus events and call the update method as needed.
*
*/
/**
* An interface for being notified when the text changes.
* @interface
*/
cvox.TextHandlerInterface = function() {};
/**
* Called when text changes.
* @param {cvox.TextChangeEvent} evt The text change event.
*/
cvox.TextHandlerInterface.prototype.changed = function(evt) {};
/**
* A subclass of ChromeVoxEditableTextBase a text element that's part of
* the webpage DOM. Contains common code shared by both EditableHTMLInput
* and EditableTextArea, but that might not apply to a non-DOM text box.
* @param {Element} node A DOM node which allows text input.
* @param {string} value The string value of the editable text control.
* @param {number} start The 0-based start cursor/selection index.
* @param {number} end The 0-based end cursor/selection index.
* @param {boolean} isPassword Whether the text control if a password field.
* @param {cvox.TtsInterface} tts A TTS object.
* @extends {cvox.ChromeVoxEditableTextBase}
* @constructor
*/
cvox.ChromeVoxEditableElement = function(
node, value, start, end, isPassword, tts) {
goog.base(this, value, start, end, isPassword, tts);
/**
* An optional handler for braille output.
* @type {cvox.BrailleTextHandler|undefined}
* @private
*/
this.brailleHandler_ = cvox.ChromeVox.braille ?
new cvox.BrailleTextHandler(cvox.ChromeVox.braille) :
undefined;
/**
* The DOM node which allows text input.
* @type {Element}
* @protected
*/
this.node = node;
/**
* True if the description was just spoken.
* @type {boolean}
* @private
*/
this.justSpokeDescription_ = false;
};
goog.inherits(cvox.ChromeVoxEditableElement, cvox.ChromeVoxEditableTextBase);
/** @override */
cvox.ChromeVoxEditableElement.prototype.changed = function(evt) {
// Ignore changes to the cursor and selection if they happen immediately
// after the description was just spoken. This avoid double-speaking when,
// for example, a text field is focused and then a moment later the
// contents are selected. If the value changes, though, this change will
// not be ignored.
if (this.justSpokeDescription_ && this.value == evt.value) {
this.value = evt.value;
this.start = evt.start;
this.end = evt.end;
this.justSpokeDescription_ = false;
}
goog.base(this, 'changed', evt);
if (this.lastChangeDescribed) {
this.brailleCurrentLine_();
}
};
/** @override */
cvox.ChromeVoxEditableElement.prototype.speak = function(
str, opt_triggeredByUser, opt_personality) {
// If there is a node associated with the editable text object,
// make sure that node has focus before speaking it.
if (this.node && (document.activeElement != this.node)) {
return;
}
goog.base(this, 'speak', str, opt_triggeredByUser, opt_personality);
};
/** @override */
cvox.ChromeVoxEditableElement.prototype.moveCursorToNextCharacter = function() {
var node = this.node;
node.selectionEnd++;
node.selectionStart = node.selectionEnd;
cvox.ChromeVoxEventWatcher.handleTextChanged(true);
return true;
};
/** @override */
cvox.ChromeVoxEditableElement.prototype.moveCursorToPreviousCharacter =
function() {
var node = this.node;
if (node.selectionStart > 0) {
node.selectionStart--;
node.selectionEnd = node.selectionStart;
}
cvox.ChromeVoxEventWatcher.handleTextChanged(true);
return true;
};
/** @override */
cvox.ChromeVoxEditableElement.prototype.moveCursorToNextWord = function() {
var node = this.node;
var length = node.value.length;
var re = /\W+/gm;
var substring = node.value.substring(node.selectionEnd);
var match = re.exec(substring);
if (match !== null && match.index == 0) {
// Ignore word-breaking sequences right next to the cursor.
match = re.exec(substring);
}
var index = (match === null) ? length : match.index + node.selectionEnd;
node.selectionStart = node.selectionEnd = index;
cvox.ChromeVoxEventWatcher.handleTextChanged(true);
return true;
};
/** @override */
cvox.ChromeVoxEditableElement.prototype.moveCursorToPreviousWord = function() {
var node = this.node;
var length = node.value.length;
var re = /\W+/gm;
var substring = node.value.substring(0, node.selectionStart);
var index = 0;
while (re.exec(substring) !== null) {
if (re.lastIndex < node.selectionStart) {
index = re.lastIndex;
}
}
node.selectionStart = node.selectionEnd = index;
cvox.ChromeVoxEventWatcher.handleTextChanged(true);
return true;
};
/** @override */
cvox.ChromeVoxEditableElement.prototype.moveCursorToNextParagraph = function() {
var node = this.node;
var length = node.value.length;
var index = node.selectionEnd >= length ?
length :
node.value.indexOf('\n', node.selectionEnd);
if (index < 0) {
index = length;
}
node.selectionStart = node.selectionEnd = index + 1;
cvox.ChromeVoxEventWatcher.handleTextChanged(true);
return true;
};
/** @override */
cvox.ChromeVoxEditableElement.prototype.moveCursorToPreviousParagraph =
function() {
var node = this.node;
var index = node.selectionStart <= 0 ?
0 :
node.value.lastIndexOf('\n', node.selectionStart - 2) + 1;
if (index < 0) {
index = 0;
}
node.selectionStart = node.selectionEnd = index;
cvox.ChromeVoxEventWatcher.handleTextChanged(true);
return true;
};
/**
* Shows the current line on the braille display.
* @private
*/
cvox.ChromeVoxEditableElement.prototype.brailleCurrentLine_ = function() {
if (this.brailleHandler_) {
var lineIndex = this.getLineIndex(this.start);
var line = this.getLine(lineIndex);
// Collapsable whitespace inside the contenteditable is represented
// as non-breaking spaces. This confuses braille input (which relies on
// the text being added to be the same as the text in the input field).
// Since the non-breaking spaces are just an artifact of how
// contenteditable is implemented, normalize to normal spaces instead.
if (this instanceof cvox.ChromeVoxEditableContentEditable) {
line = line.replace(/\u00A0/g, ' ');
}
var lineStart = this.getLineStart(lineIndex);
var start = this.start - lineStart;
var end = Math.min(this.end - lineStart, line.length);
this.brailleHandler_.changed(
line, start, end, this.multiline, this.node, lineStart);
}
};
/******************************************/
/**
* A subclass of ChromeVoxEditableElement for an HTMLInputElement.
* @param {HTMLInputElement} node The HTMLInputElement node.
* @param {cvox.TtsInterface} tts A TTS object.
* @extends {cvox.ChromeVoxEditableElement}
* @implements {cvox.TextHandlerInterface}
* @constructor
*/
cvox.ChromeVoxEditableHTMLInput = function(node, tts) {
this.node = node;
this.setup();
goog.base(
this, node, node.value, node.selectionStart, node.selectionEnd,
node.type === 'password', tts);
};
goog.inherits(cvox.ChromeVoxEditableHTMLInput, cvox.ChromeVoxEditableElement);
/**
* Performs setup for this input node.
* This accounts for exception-throwing behavior introduced by crbug.com/324360.
* @override
*/
cvox.ChromeVoxEditableHTMLInput.prototype.setup = function() {
if (!this.node) {
return;
}
if (!cvox.DomUtil.doesInputSupportSelection(this.node)) {
this.originalType = this.node.type;
this.node.type = 'text';
}
};
/**
* Performs teardown for this input node.
* This accounts for exception-throwing behavior introduced by crbug.com/324360.
* @override
*/
cvox.ChromeVoxEditableHTMLInput.prototype.teardown = function() {
if (this.node && this.originalType) {
this.node.type = this.originalType;
}
};
/**
* Update the state of the text and selection and describe any changes as
* appropriate.
*
* @param {boolean} triggeredByUser True if this was triggered by a user action.
*/
cvox.ChromeVoxEditableHTMLInput.prototype.update = function(triggeredByUser) {
var newValue = this.node.value;
var textChangeEvent = new cvox.TextChangeEvent(
newValue, this.node.selectionStart, this.node.selectionEnd,
triggeredByUser);
this.changed(textChangeEvent);
};
/******************************************/
/**
* A subclass of ChromeVoxEditableElement for an HTMLTextAreaElement.
* @param {HTMLTextAreaElement} node The HTMLTextAreaElement node.
* @param {cvox.TtsInterface} tts A TTS object.
* @extends {cvox.ChromeVoxEditableElement}
* @implements {cvox.TextHandlerInterface}
* @constructor
*/
cvox.ChromeVoxEditableTextArea = function(node, tts) {
goog.base(
this, node, node.value, node.selectionStart, node.selectionEnd,
false /* isPassword */, tts);
this.multiline = true;
/**
* True if the shadow is up to date with the current value of this text area.
* @type {boolean}
* @private
*/
this.shadowIsCurrent_ = false;
};
goog.inherits(cvox.ChromeVoxEditableTextArea, cvox.ChromeVoxEditableElement);
/**
* An offscreen div used to compute the line numbers. A single div is
* shared by all instances of the class.
* @type {!cvox.EditableTextAreaShadow|undefined}
* @private
*/
cvox.ChromeVoxEditableTextArea.shadow_;
/**
* Update the state of the text and selection and describe any changes as
* appropriate.
*
* @param {boolean} triggeredByUser True if this was triggered by a user action.
*/
cvox.ChromeVoxEditableTextArea.prototype.update = function(triggeredByUser) {
if (this.node.value != this.value) {
this.shadowIsCurrent_ = false;
}
var textChangeEvent = new cvox.TextChangeEvent(
this.node.value, this.node.selectionStart, this.node.selectionEnd,
triggeredByUser);
this.changed(textChangeEvent);
};
/**
* Get the line number corresponding to a particular index.
* @param {number} index The 0-based character index.
* @return {number} The 0-based line number corresponding to that character.
*/
cvox.ChromeVoxEditableTextArea.prototype.getLineIndex = function(index) {
return this.getShadow().getLineIndex(index);
};
/**
* Get the start character index of a line.
* @param {number} index The 0-based line index.
* @return {number} The 0-based index of the first character in this line.
*/
cvox.ChromeVoxEditableTextArea.prototype.getLineStart = function(index) {
return this.getShadow().getLineStart(index);
};
/**
* Get the end character index of a line.
* @param {number} index The 0-based line index.
* @return {number} The 0-based index of the end of this line.
*/
cvox.ChromeVoxEditableTextArea.prototype.getLineEnd = function(index) {
return this.getShadow().getLineEnd(index);
};
/**
* Update the shadow object, an offscreen div used to compute line numbers.
* @return {!cvox.EditableTextAreaShadow} The shadow object.
*/
cvox.ChromeVoxEditableTextArea.prototype.getShadow = function() {
var shadow = cvox.ChromeVoxEditableTextArea.shadow_;
if (!shadow) {
shadow = cvox.ChromeVoxEditableTextArea.shadow_ =
new cvox.EditableTextAreaShadow();
}
if (!this.shadowIsCurrent_) {
shadow.update(this.node);
this.shadowIsCurrent_ = true;
}
return shadow;
};
/** @override */
cvox.ChromeVoxEditableTextArea.prototype.moveCursorToNextLine = function() {
var node = this.node;
var length = node.value.length;
if (node.selectionEnd >= length) {
return false;
}
var shadow = this.getShadow();
var lineIndex = shadow.getLineIndex(node.selectionEnd);
var lineStart = shadow.getLineStart(lineIndex);
var offset = node.selectionEnd - lineStart;
var lastLine = (length == 0) ? 0 : shadow.getLineIndex(length - 1);
var newCursorPosition = (lineIndex >= lastLine) ?
length :
Math.min(
shadow.getLineStart(lineIndex + 1) + offset,
shadow.getLineEnd(lineIndex + 1));
node.selectionStart = node.selectionEnd = newCursorPosition;
cvox.ChromeVoxEventWatcher.handleTextChanged(true);
return true;
};
/** @override */
cvox.ChromeVoxEditableTextArea.prototype.moveCursorToPreviousLine = function() {
var node = this.node;
if (node.selectionStart <= 0) {
return false;
}
var shadow = this.getShadow();
var lineIndex = shadow.getLineIndex(node.selectionStart);
var lineStart = shadow.getLineStart(lineIndex);
var offset = node.selectionStart - lineStart;
var newCursorPosition = (lineIndex <= 0) ?
0 :
Math.min(
shadow.getLineStart(lineIndex - 1) + offset,
shadow.getLineEnd(lineIndex - 1));
node.selectionStart = node.selectionEnd = newCursorPosition;
cvox.ChromeVoxEventWatcher.handleTextChanged(true);
return true;
};
/******************************************/
/**
* A subclass of ChromeVoxEditableElement for elements that are contentEditable.
* This is also used for a region of HTML with the ARIA role of "textbox",
* so that an author can create a pure-JavaScript editable text object - this
* will work the same as contentEditable as long as the DOM selection is
* updated properly within the textbox when it has focus.
* @param {Element} node The root contentEditable node.
* @param {cvox.TtsInterface} tts A TTS object.
* @extends {cvox.ChromeVoxEditableElement}
* @implements {cvox.TextHandlerInterface}
* @constructor
*/
cvox.ChromeVoxEditableContentEditable = function(node, tts) {
goog.base(this, node, '', 0, 0, false /* isPassword */, tts);
/**
* True if the ContentEditableExtractor is current with this field's data.
* @type {boolean}
* @private
*/
this.extractorIsCurrent_ = false;
var extractor = this.getExtractor();
this.value = extractor.getText();
this.start = extractor.getStartIndex();
this.end = extractor.getEndIndex();
this.multiline = true;
};
goog.inherits(
cvox.ChromeVoxEditableContentEditable, cvox.ChromeVoxEditableElement);
/**
* A helper used to compute the line numbers. A single object is
* shared by all instances of the class.
* @type {!cvox.ContentEditableExtractor|undefined}
* @private
*/
cvox.ChromeVoxEditableContentEditable.extractor_;
/**
* Update the state of the text and selection and describe any changes as
* appropriate.
*
* @param {boolean} triggeredByUser True if this was triggered by a user action.
*/
cvox.ChromeVoxEditableContentEditable.prototype.update = function(
triggeredByUser) {
this.extractorIsCurrent_ = false;
var textChangeEvent = new cvox.TextChangeEvent(
this.getExtractor().getText(), this.getExtractor().getStartIndex(),
this.getExtractor().getEndIndex(), triggeredByUser);
this.changed(textChangeEvent);
};
/**
* Get the line number corresponding to a particular index.
* @param {number} index The 0-based character index.
* @return {number} The 0-based line number corresponding to that character.
*/
cvox.ChromeVoxEditableContentEditable.prototype.getLineIndex = function(index) {
return this.getExtractor().getLineIndex(index);
};
/**
* Get the start character index of a line.
* @param {number} index The 0-based line index.
* @return {number} The 0-based index of the first character in this line.
*/
cvox.ChromeVoxEditableContentEditable.prototype.getLineStart = function(index) {
return this.getExtractor().getLineStart(index);
};
/**
* Get the end character index of a line.
* @param {number} index The 0-based line index.
* @return {number} The 0-based index of the end of this line.
*/
cvox.ChromeVoxEditableContentEditable.prototype.getLineEnd = function(index) {
return this.getExtractor().getLineEnd(index);
};
/**
* Update the extractor object, an offscreen div used to compute line numbers.
* @return {!cvox.ContentEditableExtractor} The extractor object.
*/
cvox.ChromeVoxEditableContentEditable.prototype.getExtractor = function() {
var extractor = cvox.ChromeVoxEditableContentEditable.extractor_;
if (!extractor) {
extractor = cvox.ChromeVoxEditableContentEditable.extractor_ =
new cvox.ContentEditableExtractor();
}
if (!this.extractorIsCurrent_) {
extractor.update(this.node);
this.extractorIsCurrent_ = true;
}
return extractor;
};
/** @override */
cvox.ChromeVoxEditableContentEditable.prototype.changed = function(evt) {
if (!evt.triggeredByUser) {
return;
}
// Take over here if we can't describe a change; assume it's a blank line.
if (!this.shouldDescribeChange(evt)) {
this.speak(Msgs.getMsg('text_box_blank'), true);
if (this.brailleHandler_) {
this.brailleHandler_.changed(
'' /*line*/, 0 /*start*/, 0 /*end*/, true /*multiline*/,
null /*element*/, evt.start /*lineStart*/);
}
} else {
goog.base(this, 'changed', evt);
}
};
/** @override */
cvox.ChromeVoxEditableContentEditable.prototype.moveCursorToNextCharacter =
function() {
window.getSelection().modify('move', 'forward', 'character');
cvox.ChromeVoxEventWatcher.handleTextChanged(true);
return true;
};
/** @override */
cvox.ChromeVoxEditableContentEditable.prototype.moveCursorToPreviousCharacter =
function() {
window.getSelection().modify('move', 'backward', 'character');
cvox.ChromeVoxEventWatcher.handleTextChanged(true);
return true;
};
/** @override */
cvox.ChromeVoxEditableContentEditable.prototype.moveCursorToNextParagraph =
function() {
window.getSelection().modify('move', 'forward', 'paragraph');
cvox.ChromeVoxEventWatcher.handleTextChanged(true);
return true;
};
/** @override */
cvox.ChromeVoxEditableContentEditable.prototype.moveCursorToPreviousParagraph =
function() {
window.getSelection().modify('move', 'backward', 'paragraph');
cvox.ChromeVoxEventWatcher.handleTextChanged(true);
return true;
};
/**
* @override
*/
cvox.ChromeVoxEditableContentEditable.prototype.shouldDescribeChange = function(
evt) {
var sel = window.getSelection();
var cursor = new cvox.Cursor(sel.baseNode, sel.baseOffset, '');
// This is a very specific work around because of our buggy content editable
// support. Blank new lines are not captured in the line indexing data
// structures.
// Scenario: given a piece of text like:
//
// Some Title
//
// Description
// Footer
//
// The new lines after Title are not traversed to by TraverseUtil. A root fix
// would make changes there. However, considering the fickle nature of that
// code, we specifically detect for new lines here.
if (Math.abs(this.start - evt.start) != 1 && this.start == this.end &&
evt.start == evt.end && sel.baseNode == sel.extentNode &&
sel.baseOffset == sel.extentOffset &&
sel.baseNode.nodeType == Node.ELEMENT_NODE &&
sel.baseNode.querySelector('BR') &&
cvox.TraverseUtil.forwardsChar(cursor, [], [])) {
// This case detects if the range selection surrounds a new line,
// but there is still content after the new line (like the example
// above after "Title"). In these cases, we "pretend" we're the
// last character so we speak "blank".
return false;
}
// Otherwise, we should never speak "blank" no matter what (even if
// we're at the end of a content editable).
return true;
};