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

Reland "Refactor intent usage in ChromeVox"

This is a reland of 39716b26

Original change's description:
> 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: Akihiro Ota <akihiroota@chromium.org>
> Cr-Commit-Position: refs/heads/master@{#838241}
TBR=akihiroota@chromium.org, nektar@chromium.org, dtseng@chromium.org

Change-Id: I4fa306dc747cd623cdbb38f11c8ace144730af57
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2601639
Commit-Queue: Lan Wei <lanwei@chromium.org>
Reviewed-by: default avatarLan Wei <lanwei@chromium.org>
Cr-Commit-Position: refs/heads/master@{#839027}
parent 22326118
......@@ -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