Commit 39716b26 authored by David Tseng's avatar David Tseng Committed by Chromium LUCI CQ

Refactor intent usage in ChromeVox

This is a purely refactoring change to set up ChromeVox to use all
possible intents. IntentHandler, the newly introduced class, will
cleanly process all intents, along with whatever computed metadata is
needed (e.g. EditableLine).

R=akihiroota@chromium.org, nektar@chromium.org

AX-Relnotes: n/a
Test: existing browser_tests --gtest_filter=ChromeVoxEditing*.*
Change-Id: I74d9454794fc6a40075bbf56fb0037b607c2b974
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2594240
Commit-Queue: David Tseng <dtseng@chromium.org>
Reviewed-by: default avatarAkihiro Ota <akihiroota@chromium.org>
Cr-Commit-Position: refs/heads/master@{#838241}
parent 4730bea4
......@@ -48,7 +48,9 @@ chromevox_modules = [
"background/desktop_automation_handler.js",
"background/download_handler.js",
"background/earcon_engine.js",
"background/editing.js",
"background/editing/editable_line.js",
"background/editing/editing.js",
"background/editing/intent_handler.js",
"background/event_source.js",
"background/find_handler.js",
"background/focus_automation_handler.js",
......@@ -450,7 +452,7 @@ if (is_chromeos_ash) {
"background/color_test.js",
"background/cursors_test.js",
"background/download_handler_test.js",
"background/editing_test.js",
"background/editing/editing_test.js",
"background/keyboard_handler_test.js",
"background/live_regions_test.js",
"background/locale_output_helper_test.js",
......
// Copyright 2020 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.
/**
* @fileoverview An EditableLine encapsulates all data concerning a line in the
* automation tree necessary to provide output. Editable: an editable selection
* (e.g. start/end offsets) get saved. Line: nodes/offsets at the beginning/end
* of a line get saved.
*/
goog.provide('editing.EditableLine');
goog.scope(function() {
const AutomationEvent = chrome.automation.AutomationEvent;
const AutomationNode = chrome.automation.AutomationNode;
const Cursor = cursors.Cursor;
const Dir = constants.Dir;
const EventType = chrome.automation.EventType;
const FormType = LibLouis.FormType;
const Range = cursors.Range;
const RoleType = chrome.automation.RoleType;
const StateType = chrome.automation.StateType;
const Movement = cursors.Movement;
const Unit = cursors.Unit;
editing.EditableLine = class {
/**
* @param {!AutomationNode} startNode
* @param {number} startIndex
* @param {!AutomationNode} endNode
* @param {number} endIndex
* @param {boolean=} opt_baseLineOnStart Controls whether to use
* |startNode| or |endNode| for Line computations. Selections are
* automatically truncated up to either the line start or end.
*/
constructor(startNode, startIndex, endNode, endIndex, opt_baseLineOnStart) {
/** @private {!Cursor} */
this.start_ = new Cursor(startNode, startIndex);
this.start_ = this.start_.deepEquivalent || this.start_;
/** @private {!Cursor} */
this.end_ = new Cursor(endNode, endIndex);
this.end_ = this.end_.deepEquivalent || this.end_;
/** @private {AutomationNode|undefined} */
this.endContainer_;
// Update |startIndex| and |endIndex| if the calls above to
// cursors.Cursor.deepEquivalent results in cursors to different container
// nodes. The cursors can point directly to inline text boxes, in which case
// we should not adjust the container start or end index.
if (!AutomationPredicate.text(startNode) ||
(this.start_.node !== startNode &&
this.start_.node.parent !== startNode)) {
startIndex =
(this.start_.index === cursors.NODE_INDEX && this.start_.node.name) ?
this.start_.node.name.length :
this.start_.index;
}
if (!AutomationPredicate.text(endNode) ||
(this.end_.node !== endNode && this.end_.node.parent !== endNode)) {
endIndex =
(this.end_.index === cursors.NODE_INDEX && this.end_.node.name) ?
this.end_.node.name.length :
this.end_.index;
}
/** @private {number} */
this.localContainerStartOffset_ = startIndex;
/** @private {number} */
this.localContainerEndOffset_ = endIndex;
// Computed members.
/** @private {Spannable} */
this.value_;
/** @private {AutomationNode|undefined} */
this.lineStart_;
/** @private {AutomationNode|undefined} */
this.lineEnd_;
/** @private {AutomationNode|undefined} */
this.startContainer_;
/** @private {string} */
this.startContainerValue_ = '';
/** @private {AutomationNode|undefined} */
this.lineStartContainer_;
/** @private {number} */
this.localLineStartContainerOffset_ = 0;
/** @private {AutomationNode|undefined} */
this.lineEndContainer_;
/** @private {number} */
this.localLineEndContainerOffset_ = 0;
/** @type {RecoveryStrategy} */
this.lineStartContainerRecovery_;
this.computeLineData_(opt_baseLineOnStart);
}
/**
* @param {boolean=} opt_baseLineOnStart Computes the line based on the start
node if true.
@private
*/
computeLineData_(opt_baseLineOnStart) {
// Note that we calculate the line based only upon |start_| or
// |end_| even if they do not fall on the same line. It is up to
// the caller to specify which end to base this line upon since it requires
// reasoning about two lines.
let nameLen = 0;
const lineBase = opt_baseLineOnStart ? this.start_ : this.end_;
const lineExtend = opt_baseLineOnStart ? this.end_ : this.start_;
if (lineBase.node.name) {
nameLen = lineBase.node.name.length;
}
this.value_ = new Spannable(lineBase.node.name || '', lineBase);
if (lineBase.node === lineExtend.node) {
this.value_.setSpan(lineExtend, 0, nameLen);
}
this.startContainer_ = this.start_.node;
if (this.startContainer_.role === RoleType.INLINE_TEXT_BOX) {
this.startContainer_ = this.startContainer_.parent;
}
this.startContainerValue_ =
this.startContainer_.role === RoleType.TEXT_FIELD ?
this.startContainer_.value || '' :
this.startContainer_.name || '';
this.endContainer_ = this.end_.node;
if (this.endContainer_.role === RoleType.INLINE_TEXT_BOX) {
this.endContainer_ = this.endContainer_.parent;
}
// Initialize defaults.
this.lineStart_ = lineBase.node;
this.lineEnd_ = this.lineStart_;
this.lineStartContainer_ = this.lineStart_.parent;
this.lineEndContainer_ = this.lineStart_.parent;
// Annotate each chunk with its associated inline text box node.
this.value_.setSpan(this.lineStart_, 0, nameLen);
// Also, track the nodes necessary for selection (either their parents, in
// the case of inline text boxes, or the node itself).
const parents = [this.startContainer_];
// Compute the start of line.
let lineStart = this.lineStart_;
// Hack: note underlying bugs require these hacks.
while ((lineStart.previousOnLine && lineStart.previousOnLine.role) ||
(lineStart.previousSibling && lineStart.previousSibling.lastChild &&
lineStart.previousSibling.lastChild.nextOnLine === lineStart)) {
if (lineStart.previousOnLine) {
lineStart = lineStart.previousOnLine;
} else {
lineStart = lineStart.previousSibling.lastChild;
}
this.lineStart_ = lineStart;
if (lineStart.role !== RoleType.INLINE_TEXT_BOX) {
parents.unshift(lineStart);
} else if (parents[0] !== lineStart.parent) {
parents.unshift(lineStart.parent);
}
const prepend = new Spannable(lineStart.name, lineStart);
prepend.append(this.value_);
this.value_ = prepend;
}
this.lineStartContainer_ = this.lineStart_.parent;
let lineEnd = this.lineEnd_;
// Hack: note underlying bugs require these hacks.
while ((lineEnd.nextOnLine && lineEnd.nextOnLine.role) ||
(lineEnd.nextSibling &&
lineEnd.nextSibling.previousOnLine === lineEnd)) {
if (lineEnd.nextOnLine) {
lineEnd = lineEnd.nextOnLine;
} else {
lineEnd = lineEnd.nextSibling.firstChild;
}
this.lineEnd_ = lineEnd;
if (lineEnd.role !== RoleType.INLINE_TEXT_BOX) {
parents.push(this.lineEnd_);
} else if (parents[parents.length - 1] !== lineEnd.parent) {
parents.push(this.lineEnd_.parent);
}
let annotation = lineEnd;
if (lineEnd === this.end_.node) {
annotation = this.end_;
}
this.value_.append(new Spannable(lineEnd.name, annotation));
}
this.lineEndContainer_ = this.lineEnd_.parent;
// Finally, annotate with all parent static texts as NodeSpan's so that
// braille routing can key properly into the node with an offset.
// Note that both line start and end needs to account for
// potential offsets into the static texts as follows.
let textCountBeforeLineStart = 0, textCountAfterLineEnd = 0;
let finder = this.lineStart_;
while (finder.previousSibling) {
finder = finder.previousSibling;
textCountBeforeLineStart += finder.name ? finder.name.length : 0;
}
this.localLineStartContainerOffset_ = textCountBeforeLineStart;
if (this.lineStartContainer_) {
this.lineStartContainerRecovery_ =
new TreePathRecoveryStrategy(this.lineStartContainer_);
}
finder = this.lineEnd_;
while (finder.nextSibling) {
finder = finder.nextSibling;
textCountAfterLineEnd += finder.name ? finder.name.length : 0;
}
if (this.lineEndContainer_.name) {
this.localLineEndContainerOffset_ =
this.lineEndContainer_.name.length - textCountAfterLineEnd;
}
let len = 0;
for (let i = 0; i < parents.length; i++) {
const parent = parents[i];
if (!parent.name) {
continue;
}
const prevLen = len;
let currentLen = parent.name.length;
let offset = 0;
// Subtract off the text count before when at the start of line.
if (i === 0) {
currentLen -= textCountBeforeLineStart;
offset = textCountBeforeLineStart;
}
// Subtract text count after when at the end of the line.
if (i === parents.length - 1) {
currentLen -= textCountAfterLineEnd;
}
len += currentLen;
try {
this.value_.setSpan(new Output.NodeSpan(parent, offset), prevLen, len);
// Also, annotate this span if it is associated with line containre.
if (parent === this.startContainer_) {
this.value_.setSpan(parent, prevLen, len);
}
} catch (e) {
}
}
}
/**
* Gets the selection offset based on the text content of this line.
* @return {number}
*/
get startOffset() {
// It is possible that the start cursor points to content before this line
// (e.g. in a multi-line selection).
try {
return this.value_.getSpanStart(this.start_) +
(this.start_.index === cursors.NODE_INDEX ? 0 : this.start_.index);
} catch (e) {
// When that happens, fall back to the start of this line.
return 0;
}
}
/**
* Gets the selection offset based on the text content of this line.
* @return {number}
*/
get endOffset() {
try {
return this.value_.getSpanStart(this.end_) +
(this.end_.index === cursors.NODE_INDEX ? 0 : this.end_.index);
} catch (e) {
return this.value_.length;
}
}
/**
* Gets the selection offset based on the parent's text.
* The parent is expected to be static text.
* @return {number}
*/
get localStartOffset() {
return this.localContainerStartOffset_;
}
/**
* Gets the selection offset based on the parent's text.
* The parent is expected to be static text.
* @return {number}
*/
get localEndOffset() {
return this.localContainerEndOffset_;
}
/**
* Gets the start offset of the container, relative to the line text
* content. The container refers to the static text parenting the inline
* text box.
* @return {number}
*/
get containerStartOffset() {
return this.value_.getSpanStart(this.startContainer_);
}
/**
* Gets the end offset of the container, relative to the line text content.
* The container refers to the static text parenting the inline text box.
* @return {number}
*/
get containerEndOffset() {
return this.value_.getSpanEnd(this.startContainer_) - 1;
}
/**
* The text content of this line.
* @return {string} The text of this line.
*/
get text() {
return this.value_.toString();
}
/** @return {string} */
get selectedText() {
return this.value_.toString().substring(this.startOffset, this.endOffset);
}
/** @return {AutomationNode|undefined} */
get startContainer() {
return this.startContainer_;
}
/** @return {AutomationNode|undefined} */
get endContainer() {
return this.endContainer_;
}
/** @return {Spannable} */
get value() {
return this.value_;
}
/** @return {!cursors.Cursor} */
get start() {
return this.start_;
}
/** @return {!cursors.Cursor} */
get end() {
return this.end_;
}
/** @return {number} */
get localContainerStartOffset() {
return this.localContainerStartOffset_;
}
/** @return {number} */
get localContainerEndOffset() {
return this.localContainerEndOffset_;
}
/** @return {string} */
get startContainerValue() {
return this.startContainerValue_;
}
/** @return {boolean} */
hasCollapsedSelection() {
return this.start_.equals(this.end_);
}
/**
* Returns whether this line has selection over text nodes.
* @return {boolean}
*/
hasTextSelection() {
if (this.start_.node && this.end_.node) {
return AutomationPredicate.text(this.start_.node) &&
AutomationPredicate.text(this.end_.node);
}
return false;
}
/**
* Returns true if |otherLine| surrounds the same line as |this|. Note that
* the contents of the line might be different.
* @param {editing.EditableLine} otherLine
* @return {boolean}
*/
isSameLine(otherLine) {
// Equality is intentionally loose here as any of the state nodes can be
// invalidated at any time. We rely upon the start/anchor of the line
// staying the same.
return (otherLine.lineStartContainer_ === this.lineStartContainer_ &&
otherLine.localLineStartContainerOffset_ ===
this.localLineStartContainerOffset_) ||
(otherLine.lineEndContainer_ === this.lineEndContainer_ &&
otherLine.localLineEndContainerOffset_ ===
this.localLineEndContainerOffset_) ||
(otherLine.lineStartContainerRecovery_.node ===
this.lineStartContainerRecovery_.node &&
otherLine.localLineStartContainerOffset_ ===
this.localLineStartContainerOffset_);
}
/**
* Returns true if |otherLine| surrounds the same line as |this| and has the
* same selection.
* @param {editing.EditableLine} otherLine
* @return {boolean}
*/
isSameLineAndSelection(otherLine) {
return this.isSameLine(otherLine) &&
this.startOffset === otherLine.startOffset &&
this.endOffset === otherLine.endOffset;
}
/**
* Returns whether this line comes before |otherLine| in document order.
* @param {!editing.EditableLine} otherLine
* @return {boolean}
*/
isBeforeLine(otherLine) {
if (this.isSameLine(otherLine) || !this.lineStartContainer_ ||
!otherLine.lineStartContainer_) {
return false;
}
return AutomationUtil.getDirection(
this.lineStartContainer_, otherLine.lineStartContainer_) ===
Dir.FORWARD;
}
/**
* Performs a validation that this line still refers to a line given its
* internally tracked state.
* @return {boolean}
*/
isValidLine() {
if (!this.lineStartContainer_ || !this.lineEndContainer_) {
return false;
}
const start = new cursors.Cursor(
this.lineStartContainer_, this.localLineStartContainerOffset_);
const end = new cursors.Cursor(
this.lineEndContainer_, this.localLineEndContainerOffset_ - 1);
const localStart = start.deepEquivalent || start;
const localEnd = end.deepEquivalent || end;
const localStartNode = localStart.node;
const localEndNode = localEnd.node;
// Unfortunately, there are asymmetric errors in lines, so we need to
// check in both directions.
let testStartNode = localStartNode;
do {
if (testStartNode === localEndNode) {
return true;
}
// Hack/workaround for broken *OnLine links.
if (testStartNode.nextOnLine && testStartNode.nextOnLine.role) {
testStartNode = testStartNode.nextOnLine;
} else if (
testStartNode.nextSibling &&
testStartNode.nextSibling.previousOnLine === testStartNode) {
testStartNode = testStartNode.nextSibling;
} else {
break;
}
} while (testStartNode);
let testEndNode = localEndNode;
do {
if (testEndNode === localStartNode) {
return true;
}
// Hack/workaround for broken *OnLine links.
if (testEndNode.previousOnLine && testEndNode.previousOnLine.role) {
testEndNode = testEndNode.previousOnLine;
} else if (
testEndNode.previousSibling &&
testEndNode.previousSibling.nextOnLine === testEndNode) {
testEndNode = testEndNode.previousSibling;
} else {
break;
}
} while (testEndNode);
return false;
}
/**
* Speaks the line using text to speech.
* @param {editing.EditableLine} prevLine
*/
speakLine(prevLine) {
let prev = (prevLine && prevLine.startContainer_.role) ?
prevLine.startContainer_ :
null;
const lineNodes =
/** @type {Array<!AutomationNode>} */ (this.value_.getSpansInstanceOf(
/** @type {function()} */ (this.startContainer_.constructor)));
let queueMode = QueueMode.CATEGORY_FLUSH;
for (let i = 0, cur; cur = lineNodes[i]; i++) {
if (cur.children.length) {
continue;
}
const o = new Output()
.withRichSpeech(
Range.fromNode(cur),
prev ? Range.fromNode(prev) : Range.fromNode(cur),
Output.EventType.NAVIGATE)
.withQueueMode(queueMode);
// Ignore whitespace only output except if it is leading content on the
// line.
if (!o.isOnlyWhitespace || i === 0) {
o.go();
}
prev = cur;
queueMode = QueueMode.QUEUE;
}
}
};
}); // goog.scope
......@@ -11,11 +11,13 @@ goog.provide('editing.TextEditHandler');
goog.require('AutomationTreeWalker');
goog.require('AutomationUtil');
goog.require('IntentHandler');
goog.require('Output');
goog.require('Output.EventType');
goog.require('TreePathRecoveryStrategy');
goog.require('cursors.Cursor');
goog.require('cursors.Range');
goog.require('editing.EditableLine');
goog.require('BrailleBackground');
goog.require('ChromeVoxEditableTextBase');
goog.require('LibLouis.FormType');
......@@ -349,7 +351,7 @@ const AutomationRichEditableText = class extends AutomationEditableText {
/** @override */
isSelectionOnFirstLine() {
let deep = this.line_.end_.node;
let deep = this.line_.end.node;
while (deep.previousOnLine) {
deep = deep.previousOnLine;
}
......@@ -366,7 +368,7 @@ const AutomationRichEditableText = class extends AutomationEditableText {
/** @override */
isSelectionOnLastLine() {
let deep = this.line_.end_.node;
let deep = this.line_.end.node;
while (deep.nextOnLine) {
deep = deep.nextOnLine;
}
......@@ -487,8 +489,8 @@ const AutomationRichEditableText = class extends AutomationEditableText {
// Handle description of non-textual lines.
new Output()
.withRichSpeech(
new Range(cur.start_, cur.end_),
new Range(prev.start_, prev.end_), Output.EventType.NAVIGATE)
new Range(cur.start, cur.end), new Range(prev.start, prev.end),
Output.EventType.NAVIGATE)
.go();
}
......@@ -502,16 +504,16 @@ const AutomationRichEditableText = class extends AutomationEditableText {
const curBase = baseLineOnStart ? endLine : startLine;
if (cur.text === '\u00a0' && cur.hasCollapsedSelection() &&
!cur.end_.node.nextOnLine) {
!cur.end.node.nextOnLine) {
// This is a specific pattern seen in Google Docs. A single node (static
// text/in line text box), containing a non-breaking-space signifies a new
// line.
ChromeVox.tts.speak('\n', QueueMode.CATEGORY_FLUSH);
} else if (
(cur.startContainer_.role === RoleType.TEXT_FIELD ||
(cur.startContainer_ === prev.startContainer_ &&
cur.endContainer_ === prev.endContainer_)) &&
cur.startContainerValue_ !== prev.startContainerValue_) {
(cur.startContainer.role === RoleType.TEXT_FIELD ||
(cur.startContainer === prev.startContainer &&
cur.endContainer === prev.endContainer)) &&
cur.startContainerValue !== prev.startContainerValue) {
// This block catches text changes between |prev| and | cur|. Note that we
// can end up here if |prevStartLine| or |prevEndLine| were invalid
// above for intra-line changes. This block therefore catches all text
......@@ -524,17 +526,17 @@ const AutomationRichEditableText = class extends AutomationEditableText {
// of the container) and speak that.
this.describeTextChanged(
new TextChangeEvent(
prev.startContainerValue_, prev.localContainerStartOffset_,
prev.localContainerEndOffset_, true),
prev.startContainerValue, prev.localContainerStartOffset,
prev.localContainerEndOffset, true),
new TextChangeEvent(
cur.startContainerValue_, cur.localContainerStartOffset_,
cur.localContainerEndOffset_, true));
cur.startContainerValue, cur.localContainerStartOffset,
cur.localContainerEndOffset, true));
} else if (cur.text === '') {
// This line has no text content. Describe the DOM selection.
new Output()
.withRichSpeech(
new Range(cur.start_, cur.end_),
new Range(prev.start_, prev.end_), Output.EventType.NAVIGATE)
new Range(cur.start, cur.end), new Range(prev.start, prev.end),
Output.EventType.NAVIGATE)
.go();
} else if (
!prev.hasCollapsedSelection() && !cur.hasCollapsedSelection() &&
......@@ -552,21 +554,21 @@ const AutomationRichEditableText = class extends AutomationEditableText {
// Wrapped across the baseline. Read out the new selection.
suffixMsg = 'selected';
text = this.getTextSelection_(
curBase.startContainer_, curBase.localStartOffset,
curExtent.endContainer_, curExtent.localEndOffset);
curBase.startContainer, curBase.localStartOffset,
curExtent.endContainer, curExtent.localEndOffset);
} else {
if (prev.isBeforeLine(curExtent)) {
// Grew.
suffixMsg = 'selected';
text = this.getTextSelection_(
prev.endContainer_, prev.localEndOffset,
curExtent.endContainer_, curExtent.localEndOffset);
prev.endContainer, prev.localEndOffset, curExtent.endContainer,
curExtent.localEndOffset);
} else {
// Shrank.
suffixMsg = 'unselected';
text = this.getTextSelection_(
curExtent.endContainer_, curExtent.localEndOffset,
prev.endContainer_, prev.localEndOffset);
curExtent.endContainer, curExtent.localEndOffset,
prev.endContainer, prev.localEndOffset);
}
}
} else {
......@@ -575,21 +577,21 @@ const AutomationRichEditableText = class extends AutomationEditableText {
// Wrapped across the baseline. Read out the new selection.
suffixMsg = 'selected';
text = this.getTextSelection_(
curExtent.startContainer_, curExtent.localStartOffset,
curBase.endContainer_, curBase.localEndOffset);
curExtent.startContainer, curExtent.localStartOffset,
curBase.endContainer, curBase.localEndOffset);
} else {
if (curExtent.isBeforeLine(prev)) {
// Grew.
suffixMsg = 'selected';
text = this.getTextSelection_(
curExtent.startContainer_, curExtent.localStartOffset,
prev.startContainer_, prev.localStartOffset);
curExtent.startContainer, curExtent.localStartOffset,
prev.startContainer, prev.localStartOffset);
} else {
// Shrank.
suffixMsg = 'unselected';
text = this.getTextSelection_(
prev.startContainer_, prev.localStartOffset,
curExtent.startContainer_, curExtent.localStartOffset);
prev.startContainer, prev.localStartOffset,
curExtent.startContainer, curExtent.localStartOffset);
}
}
}
......@@ -600,7 +602,7 @@ const AutomationRichEditableText = class extends AutomationEditableText {
// Without any other information, try describing the selection. This state
// catches things like select all.
const text = this.getTextSelection_(
cur.startContainer_, cur.localStartOffset, cur.endContainer_,
cur.startContainer, cur.localStartOffset, cur.endContainer,
cur.localEndOffset);
ChromeVox.tts.speak(text, QueueMode.CATEGORY_FLUSH);
ChromeVox.tts.speak(Msgs.getMsg('selected'), QueueMode.QUEUE);
......@@ -611,7 +613,7 @@ const AutomationRichEditableText = class extends AutomationEditableText {
// selections and picking the line edge boundary that changed (as computed
// above). This is also the code path for describing paste. It also covers
// jump commands which are non-overlapping selections from prev to cur.
this.speakCurrentRichLine_(prev);
this.line_.speakLine(prev);
}
this.updateIntraLineState_(cur);
}
......@@ -620,11 +622,11 @@ const AutomationRichEditableText = class extends AutomationEditableText {
handleBraille_() {
const isFirstLine = this.isSelectionOnFirstLine();
const cur = this.line_;
if (cur.value_ === null) {
if (cur.value === null) {
return;
}
let value = new MultiSpannable(cur.value_);
let value = new MultiSpannable(cur.value);
if (!this.node_.constructor) {
return;
}
......@@ -656,7 +658,7 @@ const AutomationRichEditableText = class extends AutomationEditableText {
});
// Provide context for the current selection.
const context = cur.startContainer_;
const context = cur.startContainer;
if (context && context.role !== RoleType.TEXT_FIELD) {
const output = new Output().suppress('name').withBraille(
Range.fromNode(context), Range.fromNode(this.node_),
......@@ -683,7 +685,7 @@ const AutomationRichEditableText = class extends AutomationEditableText {
}
value.append(Msgs.getMsg('tag_textarea_brl'));
}
value.setSpan(new ValueSpan(0), 0, cur.value_.length);
value.setSpan(new ValueSpan(0), 0, cur.value.length);
value.setSpan(new ValueSelectionSpan(), cur.startOffset, cur.endOffset);
ChromeVox.braille.write(new NavBraille(
{text: value, startIndex: cur.startOffset, endIndex: cur.endOffset}));
......@@ -838,41 +840,6 @@ const AutomationRichEditableText = class extends AutomationEditableText {
}
}
/**
* @param {editing.EditableLine} prevLine
* @private
*/
speakCurrentRichLine_(prevLine) {
let prev = (prevLine && prevLine.startContainer_.role) ?
prevLine.startContainer_ :
null;
const lineNodes =
/** @type {Array<!AutomationNode>} */ (
this.line_.value_.getSpansInstanceOf(
/** @type {function()} */ (this.node_.constructor)));
let queueMode = QueueMode.CATEGORY_FLUSH;
for (let i = 0, cur; cur = lineNodes[i]; i++) {
if (cur.children.length) {
continue;
}
const o = new Output()
.withRichSpeech(
Range.fromNode(cur),
prev ? Range.fromNode(prev) : Range.fromNode(cur),
Output.EventType.NAVIGATE)
.withQueueMode(queueMode);
// Ignore whitespace only output except if it is leading content on the
// line.
if (!o.isOnlyWhitespace || i === 0) {
o.go();
}
prev = cur;
queueMode = QueueMode.QUEUE;
}
}
/** @override */
describeSelectionChanged(evt) {
// Note that since Chrome allows for selection to be placed immediately at
......@@ -927,42 +894,16 @@ const AutomationRichEditableText = class extends AutomationEditableText {
* @param {!Array<AutomationIntent>} intents
* @param {!editing.EditableLine} cur
* @param {!editing.EditableLine} prev
* @return {boolean}
* @private
*/
maybeSpeakUsingIntents_(intents, cur, prev) {
if (intents.length === 0) {
return false;
}
// Only consider selection moves.
const intent = intents.find(
i => i.command === chrome.automation.IntentCommandType.MOVE_SELECTION);
if (!intent) {
return false;
}
if (intent.textBoundary ===
chrome.automation.IntentTextBoundaryType.CHARACTER) {
if (IntentHandler.onIntents(intents, cur, prev)) {
this.updateIntraLineState_(cur);
// Read character to the right of the cursor. It is assumed to be a new
// line if empty.
const text =
cur.text.substring(cur.startOffset, cur.startOffset + 1) || '\n';
ChromeVox.tts.speak(text, QueueMode.CATEGORY_FLUSH);
this.speakAllMarkers_(cur);
return true;
}
if (intent.textBoundary ===
chrome.automation.IntentTextBoundaryType.LINE_START ||
intent.textBoundary ===
chrome.automation.IntentTextBoundaryType.LINE_END) {
this.updateIntraLineState_(cur);
this.speakCurrentRichLine_(prev);
return true;
}
return false;
}
......@@ -971,7 +912,7 @@ const AutomationRichEditableText = class extends AutomationEditableText {
* @private
*/
speakAllMarkers_(cur) {
const container = cur.startContainer_;
const container = cur.startContainer;
if (!container) {
return;
}
......@@ -1013,451 +954,4 @@ editing.EditingChromeVoxStateObserver = class {
* @private {ChromeVoxStateObserver}
*/
editing.observer_ = new editing.EditingChromeVoxStateObserver();
/**
* An EditableLine encapsulates all data concerning a line in the automation
* tree necessary to provide output.
* Editable: an editable selection (e.g. start/end offsets) get saved.
* Line: nodes/offsets at the beginning/end of a line get saved.
*/
editing.EditableLine = class {
/**
* @param {!AutomationNode} startNode
* @param {number} startIndex
* @param {!AutomationNode} endNode
* @param {number} endIndex
* @param {boolean=} opt_baseLineOnStart Controls whether to use
* |startNode| or |endNode| for Line computations. Selections are
* automatically truncated up to either the line start or end.
*/
constructor(startNode, startIndex, endNode, endIndex, opt_baseLineOnStart) {
/** @private {!Cursor} */
this.start_ = new Cursor(startNode, startIndex);
this.start_ = this.start_.deepEquivalent || this.start_;
/** @private {!Cursor} */
this.end_ = new Cursor(endNode, endIndex);
this.end_ = this.end_.deepEquivalent || this.end_;
/** @private {AutomationNode|undefined} */
this.endContainer_;
// Update |startIndex| and |endIndex| if the calls above to
// cursors.Cursor.deepEquivalent results in cursors to different container
// nodes. The cursors can point directly to inline text boxes, in which case
// we should not adjust the container start or end index.
if (!AutomationPredicate.text(startNode) ||
(this.start_.node !== startNode &&
this.start_.node.parent !== startNode)) {
startIndex =
(this.start_.index === cursors.NODE_INDEX && this.start_.node.name) ?
this.start_.node.name.length :
this.start_.index;
}
if (!AutomationPredicate.text(endNode) ||
(this.end_.node !== endNode && this.end_.node.parent !== endNode)) {
endIndex =
(this.end_.index === cursors.NODE_INDEX && this.end_.node.name) ?
this.end_.node.name.length :
this.end_.index;
}
/** @private {number} */
this.localContainerStartOffset_ = startIndex;
/** @private {number} */
this.localContainerEndOffset_ = endIndex;
// Computed members.
/** @private {Spannable} */
this.value_;
/** @private {AutomationNode|undefined} */
this.lineStart_;
/** @private {AutomationNode|undefined} */
this.lineEnd_;
/** @private {AutomationNode|undefined} */
this.startContainer_;
/** @private {string} */
this.startContainerValue_ = '';
/** @private {AutomationNode|undefined} */
this.lineStartContainer_;
/** @private {number} */
this.localLineStartContainerOffset_ = 0;
/** @private {AutomationNode|undefined} */
this.lineEndContainer_;
/** @private {number} */
this.localLineEndContainerOffset_ = 0;
/** @type {RecoveryStrategy} */
this.lineStartContainerRecovery_;
this.computeLineData_(opt_baseLineOnStart);
}
/** @private */
computeLineData_(opt_baseLineOnStart) {
// Note that we calculate the line based only upon |start_| or
// |end_| even if they do not fall on the same line. It is up to
// the caller to specify which end to base this line upon since it requires
// reasoning about two lines.
let nameLen = 0;
const lineBase = opt_baseLineOnStart ? this.start_ : this.end_;
const lineExtend = opt_baseLineOnStart ? this.end_ : this.start_;
if (lineBase.node.name) {
nameLen = lineBase.node.name.length;
}
this.value_ = new Spannable(lineBase.node.name || '', lineBase);
if (lineBase.node === lineExtend.node) {
this.value_.setSpan(lineExtend, 0, nameLen);
}
this.startContainer_ = this.start_.node;
if (this.startContainer_.role === RoleType.INLINE_TEXT_BOX) {
this.startContainer_ = this.startContainer_.parent;
}
this.startContainerValue_ =
this.startContainer_.role === RoleType.TEXT_FIELD ?
this.startContainer_.value || '' :
this.startContainer_.name || '';
this.endContainer_ = this.end_.node;
if (this.endContainer_.role === RoleType.INLINE_TEXT_BOX) {
this.endContainer_ = this.endContainer_.parent;
}
// Initialize defaults.
this.lineStart_ = lineBase.node;
this.lineEnd_ = this.lineStart_;
this.lineStartContainer_ = this.lineStart_.parent;
this.lineEndContainer_ = this.lineStart_.parent;
// Annotate each chunk with its associated inline text box node.
this.value_.setSpan(this.lineStart_, 0, nameLen);
// Also, track the nodes necessary for selection (either their parents, in
// the case of inline text boxes, or the node itself).
const parents = [this.startContainer_];
// Compute the start of line.
let lineStart = this.lineStart_;
// Hack: note underlying bugs require these hacks.
while ((lineStart.previousOnLine && lineStart.previousOnLine.role) ||
(lineStart.previousSibling && lineStart.previousSibling.lastChild &&
lineStart.previousSibling.lastChild.nextOnLine === lineStart)) {
if (lineStart.previousOnLine) {
lineStart = lineStart.previousOnLine;
} else {
lineStart = lineStart.previousSibling.lastChild;
}
this.lineStart_ = lineStart;
if (lineStart.role !== RoleType.INLINE_TEXT_BOX) {
parents.unshift(lineStart);
} else if (parents[0] !== lineStart.parent) {
parents.unshift(lineStart.parent);
}
const prepend = new Spannable(lineStart.name, lineStart);
prepend.append(this.value_);
this.value_ = prepend;
}
this.lineStartContainer_ = this.lineStart_.parent;
let lineEnd = this.lineEnd_;
// Hack: note underlying bugs require these hacks.
while ((lineEnd.nextOnLine && lineEnd.nextOnLine.role) ||
(lineEnd.nextSibling &&
lineEnd.nextSibling.previousOnLine === lineEnd)) {
if (lineEnd.nextOnLine) {
lineEnd = lineEnd.nextOnLine;
} else {
lineEnd = lineEnd.nextSibling.firstChild;
}
this.lineEnd_ = lineEnd;
if (lineEnd.role !== RoleType.INLINE_TEXT_BOX) {
parents.push(this.lineEnd_);
} else if (parents[parents.length - 1] !== lineEnd.parent) {
parents.push(this.lineEnd_.parent);
}
let annotation = lineEnd;
if (lineEnd === this.end_.node) {
annotation = this.end_;
}
this.value_.append(new Spannable(lineEnd.name, annotation));
}
this.lineEndContainer_ = this.lineEnd_.parent;
// Finally, annotate with all parent static texts as NodeSpan's so that
// braille routing can key properly into the node with an offset.
// Note that both line start and end needs to account for
// potential offsets into the static texts as follows.
let textCountBeforeLineStart = 0, textCountAfterLineEnd = 0;
let finder = this.lineStart_;
while (finder.previousSibling) {
finder = finder.previousSibling;
textCountBeforeLineStart += finder.name ? finder.name.length : 0;
}
this.localLineStartContainerOffset_ = textCountBeforeLineStart;
if (this.lineStartContainer_) {
this.lineStartContainerRecovery_ =
new TreePathRecoveryStrategy(this.lineStartContainer_);
}
finder = this.lineEnd_;
while (finder.nextSibling) {
finder = finder.nextSibling;
textCountAfterLineEnd += finder.name ? finder.name.length : 0;
}
if (this.lineEndContainer_.name) {
this.localLineEndContainerOffset_ =
this.lineEndContainer_.name.length - textCountAfterLineEnd;
}
let len = 0;
for (let i = 0; i < parents.length; i++) {
const parent = parents[i];
if (!parent.name) {
continue;
}
const prevLen = len;
let currentLen = parent.name.length;
let offset = 0;
// Subtract off the text count before when at the start of line.
if (i === 0) {
currentLen -= textCountBeforeLineStart;
offset = textCountBeforeLineStart;
}
// Subtract text count after when at the end of the line.
if (i === parents.length - 1) {
currentLen -= textCountAfterLineEnd;
}
len += currentLen;
try {
this.value_.setSpan(new Output.NodeSpan(parent, offset), prevLen, len);
// Also, annotate this span if it is associated with line containre.
if (parent === this.startContainer_) {
this.value_.setSpan(parent, prevLen, len);
}
} catch (e) {
}
}
}
/**
* Gets the selection offset based on the text content of this line.
* @return {number}
*/
get startOffset() {
// It is possible that the start cursor points to content before this line
// (e.g. in a multi-line selection).
try {
return this.value_.getSpanStart(this.start_) +
(this.start_.index === cursors.NODE_INDEX ? 0 : this.start_.index);
} catch (e) {
// When that happens, fall back to the start of this line.
return 0;
}
}
/**
* Gets the selection offset based on the text content of this line.
* @return {number}
*/
get endOffset() {
try {
return this.value_.getSpanStart(this.end_) +
(this.end_.index === cursors.NODE_INDEX ? 0 : this.end_.index);
} catch (e) {
return this.value_.length;
}
}
/**
* Gets the selection offset based on the parent's text.
* The parent is expected to be static text.
* @return {number}
*/
get localStartOffset() {
return this.localContainerStartOffset_;
}
/**
* Gets the selection offset based on the parent's text.
* The parent is expected to be static text.
* @return {number}
*/
get localEndOffset() {
return this.localContainerEndOffset_;
}
/**
* Gets the start offset of the container, relative to the line text
* content. The container refers to the static text parenting the inline
* text box.
* @return {number}
*/
get containerStartOffset() {
return this.value_.getSpanStart(this.startContainer_);
}
/**
* Gets the end offset of the container, relative to the line text content.
* The container refers to the static text parenting the inline text box.
* @return {number}
*/
get containerEndOffset() {
return this.value_.getSpanEnd(this.startContainer_) - 1;
}
/**
* The text content of this line.
* @return {string} The text of this line.
*/
get text() {
return this.value_.toString();
}
/** @return {string} */
get selectedText() {
return this.value_.toString().substring(this.startOffset, this.endOffset);
}
/** @return {boolean} */
hasCollapsedSelection() {
return this.start_.equals(this.end_);
}
/**
* Returns whether this line has selection over text nodes.
*/
hasTextSelection() {
if (this.start_.node && this.end_.node) {
return AutomationPredicate.text(this.start_.node) &&
AutomationPredicate.text(this.end_.node);
}
}
/**
* Returns true if |otherLine| surrounds the same line as |this|. Note that
* the contents of the line might be different.
* @param {editing.EditableLine} otherLine
* @return {boolean}
*/
isSameLine(otherLine) {
// Equality is intentionally loose here as any of the state nodes can be
// invalidated at any time. We rely upon the start/anchor of the line
// staying the same.
return (otherLine.lineStartContainer_ === this.lineStartContainer_ &&
otherLine.localLineStartContainerOffset_ ===
this.localLineStartContainerOffset_) ||
(otherLine.lineEndContainer_ === this.lineEndContainer_ &&
otherLine.localLineEndContainerOffset_ ===
this.localLineEndContainerOffset_) ||
(otherLine.lineStartContainerRecovery_.node ===
this.lineStartContainerRecovery_.node &&
otherLine.localLineStartContainerOffset_ ===
this.localLineStartContainerOffset_);
}
/**
* Returns true if |otherLine| surrounds the same line as |this| and has the
* same selection.
* @param {editing.EditableLine} otherLine
* @return {boolean}
*/
isSameLineAndSelection(otherLine) {
return this.isSameLine(otherLine) &&
this.startOffset === otherLine.startOffset &&
this.endOffset === otherLine.endOffset;
}
/**
* Returns whether this line comes before |otherLine| in document order.
* @return {boolean}
*/
isBeforeLine(otherLine) {
if (this.isSameLine(otherLine) || !this.lineStartContainer_ ||
!otherLine.lineStartContainer_) {
return false;
}
return AutomationUtil.getDirection(
this.lineStartContainer_, otherLine.lineStartContainer_) ===
Dir.FORWARD;
}
/**
* Performs a validation that this line still refers to a line given its
* internally tracked state.
*/
isValidLine() {
if (!this.lineStartContainer_ || !this.lineEndContainer_) {
return false;
}
const start = new cursors.Cursor(
this.lineStartContainer_, this.localLineStartContainerOffset_);
const end = new cursors.Cursor(
this.lineEndContainer_, this.localLineEndContainerOffset_ - 1);
const localStart = start.deepEquivalent || start;
const localEnd = end.deepEquivalent || end;
const localStartNode = localStart.node;
const localEndNode = localEnd.node;
// Unfortunately, there are asymmetric errors in lines, so we need to
// check in both directions.
let testStartNode = localStartNode;
do {
if (testStartNode === localEndNode) {
return true;
}
// Hack/workaround for broken *OnLine links.
if (testStartNode.nextOnLine && testStartNode.nextOnLine.role) {
testStartNode = testStartNode.nextOnLine;
} else if (
testStartNode.nextSibling &&
testStartNode.nextSibling.previousOnLine === testStartNode) {
testStartNode = testStartNode.nextSibling;
} else {
break;
}
} while (testStartNode);
let testEndNode = localEndNode;
do {
if (testEndNode === localStartNode) {
return true;
}
// Hack/workaround for broken *OnLine links.
if (testEndNode.previousOnLine && testEndNode.previousOnLine.role) {
testEndNode = testEndNode.previousOnLine;
} else if (
testEndNode.previousSibling &&
testEndNode.previousSibling.nextOnLine === testEndNode) {
testEndNode = testEndNode.previousSibling;
} else {
break;
}
} while (testEndNode);
return false;
}
};
}); // goog.scope
......@@ -267,7 +267,8 @@ TEST_F(
`
<div role="textbox" contenteditable>
<p style="font-size:20px; font-family:times">
<b style="color:#ff0000">Move</b> <i>through</i> <u style="font-family:georgia">text</u>
<b style="color:#ff0000">Move</b>
<i>through</i> <u style="font-family:georgia">text</u>
by <strike style="font-size:12px; color:#0000ff">character</strike>
<a href="#">test</a>!
</p>
......
// Copyright 2020 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.
/**
* @fileoverview Handles automation intents for speech feedback.
*/
goog.provide('IntentHandler');
goog.require('editing.EditableLine');
goog.scope(function() {
const AutomationIntent = chrome.automation.AutomationIntent;
const IntentCommandType = chrome.automation.IntentCommandType;
const IntentTextBoundaryType = chrome.automation.IntentTextBoundaryType;
/**
* A stateless class that turns intents into speech.
*/
IntentHandler = class {
/**
* Called when intents are received from an AutomationEvent.
* @param {!Array<AutomationIntent>} intents
* @param {!editing.EditableLine} cur The current line.
* @param {editing.EditableLine} prev The previous line.
* @return {boolean} Whether intents are handled.
*/
static onIntents(intents, cur, prev) {
if (intents.length === 0) {
return false;
}
// Currently, discard all other intents once one is handled.
for (let i = 0; i < intents.length; i++) {
if (IntentHandler.onIntent(intents[i], cur, prev)) {
return true;
}
}
return false;
}
/**
* Called when an intent is received.
* @param {!AutomationIntent} intent
* @param {!editing.EditableLine} cur The current line.
* @param {editing.EditableLine} prev The previous line.
* @return {boolean} Whether the intent was handled.
*/
static onIntent(intent, cur, prev) {
switch (intent.command) {
case IntentCommandType.MOVE_SELECTION:
return IntentHandler.onMoveSelection(intent, cur, prev);
// TODO: implement support.
case IntentCommandType.CLEAR_SELECTION:
case IntentCommandType.DELETE:
case IntentCommandType.DICTATE:
case IntentCommandType.EXTEND_SELECTION:
case IntentCommandType.FORMAT:
case IntentCommandType.HISTORY:
case IntentCommandType.INSERT:
case IntentCommandType.MARKER:
case IntentCommandType.SET_SELECTION:
break;
}
return false;
}
/**
* Called when the text selection moves.
* @param {!AutomationIntent} intent A move selection
* intent.
* @param {!editing.EditableLine} cur The current line.
* @param {editing.EditableLine} prev The previous line.
* @return {boolean} Whether the intent was handled.
*/
static onMoveSelection(intent, cur, prev) {
switch (intent.textBoundary) {
case IntentTextBoundaryType.CHARACTER:
// Read character to the right of the cursor. It is assumed to be a new
// line if empty.
// TODO: detect when this is the end of the document; read "end of text"
// if so.
ChromeVox.tts.speak(
cur.text.substring(cur.startOffset, cur.startOffset + 1) || '\n',
QueueMode.CATEGORY_FLUSH);
return true;
case IntentTextBoundaryType.LINE_END:
case IntentTextBoundaryType.LINE_START:
case IntentTextBoundaryType.LINE_START_OR_END:
cur.speakLine(prev);
return true;
// TODO: implement support.
case IntentTextBoundaryType.FORMAT:
case IntentTextBoundaryType.OBJECT:
case IntentTextBoundaryType.PAGE_END:
case IntentTextBoundaryType.PAGE_START:
case IntentTextBoundaryType.PAGE_START_OR_END:
case IntentTextBoundaryType.PARAGRAPH_END:
case IntentTextBoundaryType.PARAGRAPH_START:
case IntentTextBoundaryType.PARAGRAPH_START_OR_END:
case IntentTextBoundaryType.SENTENCE_END:
case IntentTextBoundaryType.SENTENCE_START:
case IntentTextBoundaryType.SENTENCE_START_OR_END:
case IntentTextBoundaryType.WEB_PAGE:
case IntentTextBoundaryType.WORD_END:
case IntentTextBoundaryType.WORD_START:
case IntentTextBoundaryType.WORD_START_OR_END:
break;
}
return false;
}
};
}); // goog.scope
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment