Commit 397c6f3a authored by Lei Shi's avatar Lei Shi Committed by Chromium LUCI CQ

Navigate sentence across boundaries in Select to Speak

When doing sentence navigation, we will first search for next sentence
start within the current node group. If that fails, we will look for the
rest of the content within this paragraph. If we still cannot find a
sentence start, we will look for the next paragraph.

Once we find a sentence start, we will get the nodes from the sentence
start until the end of that paragraph. Then, we will send the new
content to TTS.

AX-Relnotes: N/A.
Bug: 1143823
Change-Id: I051c572d4a9f866aeb37fba32137ed81b9cac744
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2587799
Commit-Queue: Lei Shi <leileilei@google.com>
Reviewed-by: default avatarDavid Tseng <dtseng@chromium.org>
Reviewed-by: default avatarAkihiro Ota <akihiroota@chromium.org>
Cr-Commit-Position: refs/heads/master@{#838258}
parent 806e4478
......@@ -548,32 +548,80 @@ class NodeUtils {
/**
* Gets the remaining content of a paragraph with an assigned position. The
* position is defined by the |charIndex| to the text of |nodeGroup|. The
* remaining content is returned as a list of nodes with offset.
* @param {!ParagraphUtils.NodeGroup} nodeGroup
* position is defined by the |charIndex| to the text of |nodeGroup|. If
* |direction| is set to forward, we will look for trailing content after the
* position. Otherwise, we will get the leading content before the position.
* The remaining content is returned as a list of nodes with offset.
* @param {!ParagraphUtils.NodeGroup} nodeGroup The nodeGroup of the assigned
* position. The nodeGroup may contain the content of the entire paragraph
* or only a part of the paragraph.
* @param {number} charIndex The char index of the position. The index is
* relative to the text content of the |nodeGroup|. This index is
* inclusive: if we set |charIndex| to 0, we will get the remaining
* content of the paragraph including all the content in the input
* |nodeGroup|.
* inclusive for forward searching: if we set |direction| to forward with
* a 0 |charIndex|, we will get the remaining content of the paragraph
* including all the content in the input |nodeGroup|. However, it is
* exclusive for backward searching: when searching backward with a 0
* |charIndex|, we will exclude all the content in the input |nodeGroup|.
* @param {constants.Dir} direction
* @return {!{nodes: !Array<!AutomationNode>,
* offset: number}}
* nodes: the nodes that have the remaining content.
* offset: the offset for the nodes. The offset is into the name of the
* first node, and marks the start index of the remaining content in the first
* node, inclusively. For example, "Hello" with an offset 3 indicates that the
* remaining content is "lo".
* nodes: the nodes that have the remaining content.
* offset: the offset for the nodes. When searching forward, the offset is
* into the name of the first node, and marks the start index of the remaining
* content in the first node, inclusively. For example, "Hello" with an offset
* 3 indicates that the remaining content is "lo". Otherwise, the offset is
* into the name of the last node, and marks the end index of the remaining
* content in the last node, exclusively. For example, "Hello" with an offset
* 3 indicates that the remaining content is "Hel".
*/
static getNextNodesInParagraphFromNodeGroupWithOffset(nodeGroup, charIndex) {
static getNextNodesInParagraphFromNodeGroup(nodeGroup, charIndex, direction) {
if (nodeGroup.nodes.length === 0) {
return {nodes: [], offset: -1};
}
let {node: startNode, offset} =
ParagraphUtils.findNodeFromNodeGroupByCharIndex(nodeGroup, charIndex);
// If we did not find a node for the charIndex, we use the last node of the
// current node group as the starting node and set offset to the length of
// the node's name. This enables us to get all the content within this
// paragraph but after the current node group.
if (direction === constants.Dir.BACKWARD) {
// If we did not find a node for the charIndex, we use the first node of
// the current node group and set offset to 0. This enables us to get all
// the content before the current node group in this paragraph.
if (!startNode) {
const firstNode = nodeGroup.nodes[0].node;
const firstChildOfFirstNode = NodeUtils.getFirstLeafChild(firstNode);
startNode = firstChildOfFirstNode || firstNode;
offset = 0;
}
// Gets all the nodes before the startNode. We include the start node
// if it is not empty. Note that this is based on the assumption that
// this function will still include nodes with overflow text.
const leadingNodes =
NodeUtils.getNextNodesInParagraph(startNode, constants.Dir.BACKWARD);
const textInStartNode =
ParagraphUtils.getNodeName(startNode).substr(0, offset);
if (!ParagraphUtils.isWhitespace(textInStartNode)) {
leadingNodes.push(startNode);
return {nodes: leadingNodes, offset};
}
if (leadingNodes.length === 0) {
return {nodes: [], offset: -1};
}
// Returns all the nodes once we find a valid one among them.
for (const node of leadingNodes) {
if (NodeUtils.isValidLeafNode(node)) {
const lastLeadingNodeName =
ParagraphUtils.getNodeName(leadingNodes[leadingNodes.length - 1]);
return {nodes: leadingNodes, offset: lastLeadingNodeName.length};
}
}
return {nodes: [], offset: -1};
}
// If we search forward, we use the start node as anchor and look for
// remaining content. If we did not find a node for the charIndex, we use
// the last node of the current node group as the starting node and set
// offset to the length of the node's name. This enables us to get all the
// content within this paragraph but after the current node group.
if (!startNode) {
const lastNode = nodeGroup.nodes[nodeGroup.nodes.length - 1].node;
const lastChildOfLastNode = NodeUtils.getLastLeafChild(lastNode);
......
......@@ -832,44 +832,86 @@ TEST_F('SelectToSpeakNodeUtilsUnitTest', 'getAllNodesInParagraph', function() {
TEST_F(
'SelectToSpeakNodeUtilsUnitTest',
'getNextNodesInParagraphFromNodeGroupWithOffset', function() {
'getNextNodesInParagraphFromNodeGroup_forward', function() {
// The nodeGroup has four inline text nodes and one static text node.
// Their starting indexes are 0, 9, 20, 30, and 51.
const nodeGroup = generateTestNodeGroup();
let result = NodeUtils.getNextNodesInParagraphFromNodeGroupWithOffset(
nodeGroup, 0 /* charIndex */);
let result = NodeUtils.getNextNodesInParagraphFromNodeGroup(
nodeGroup, 0 /* charIndex */, constants.Dir.FORWARD /* direction */);
assertEquals(result.nodes.length, 5);
assertEquals(result.offset, 0);
result = NodeUtils.getNextNodesInParagraphFromNodeGroupWithOffset(
nodeGroup, 5 /* charIndex */);
result = NodeUtils.getNextNodesInParagraphFromNodeGroup(
nodeGroup, 5 /* charIndex */, constants.Dir.FORWARD /* direction */);
assertEquals(result.nodes.length, 5);
assertEquals(result.offset, 5);
result = NodeUtils.getNextNodesInParagraphFromNodeGroupWithOffset(
nodeGroup, 9 /* charIndex */);
result = NodeUtils.getNextNodesInParagraphFromNodeGroup(
nodeGroup, 9 /* charIndex */, constants.Dir.FORWARD /* direction */);
assertEquals(result.nodes.length, 4);
assertEquals(result.offset, 0);
result = NodeUtils.getNextNodesInParagraphFromNodeGroupWithOffset(
nodeGroup, 25 /* charIndex */);
result = NodeUtils.getNextNodesInParagraphFromNodeGroup(
nodeGroup, 25 /* charIndex */, constants.Dir.FORWARD /* direction */);
assertEquals(result.nodes.length, 3);
assertEquals(result.offset, 5);
result = NodeUtils.getNextNodesInParagraphFromNodeGroupWithOffset(
nodeGroup, 51 /* charIndex */);
result = NodeUtils.getNextNodesInParagraphFromNodeGroup(
nodeGroup, 51 /* charIndex */, constants.Dir.FORWARD /* direction */);
assertEquals(result.nodes.length, 1);
assertEquals(result.offset, 0);
result = NodeUtils.getNextNodesInParagraphFromNodeGroupWithOffset(
nodeGroup, 100 /* charIndex */);
result = NodeUtils.getNextNodesInParagraphFromNodeGroup(
nodeGroup, 100 /* charIndex */,
constants.Dir.FORWARD /* direction */);
assertEquals(result.nodes.length, 0);
});
TEST_F(
'SelectToSpeakNodeUtilsUnitTest',
'getNextNodesInParagraphFromNodeGroupWithOffsetWithEmptyTail', function() {
'getNextNodesInParagraphFromNodeGroup_backward', function() {
// The nodeGroup has four inline text nodes and one static text node.
// Their starting indexes are 0, 9, 20, 30, and 51.
const nodeGroup = generateTestNodeGroup();
let result = NodeUtils.getNextNodesInParagraphFromNodeGroup(
nodeGroup, 0 /* charIndex */, constants.Dir.BACKWARD /* direction */);
assertEquals(result.nodes.length, 0);
result = NodeUtils.getNextNodesInParagraphFromNodeGroup(
nodeGroup, 5 /* charIndex */, constants.Dir.BACKWARD /* direction */);
assertEquals(result.nodes.length, 1);
assertEquals(result.offset, 5);
result = NodeUtils.getNextNodesInParagraphFromNodeGroup(
nodeGroup, 9 /* charIndex */, constants.Dir.BACKWARD /* direction */);
assertEquals(result.nodes.length, 1);
assertEquals(result.offset, 9);
result = NodeUtils.getNextNodesInParagraphFromNodeGroup(
nodeGroup, 25 /* charIndex */,
constants.Dir.BACKWARD /* direction */);
assertEquals(result.nodes.length, 3);
assertEquals(result.offset, 5);
result = NodeUtils.getNextNodesInParagraphFromNodeGroup(
nodeGroup, 51 /* charIndex */,
constants.Dir.BACKWARD /* direction */);
assertEquals(result.nodes.length, 4);
assertEquals(result.offset, 20);
// The charIndex is out of the range of nodeGroup. It will try to get all
// the content before the nodeGroup. In this case, there is nothing.
result = NodeUtils.getNextNodesInParagraphFromNodeGroup(
nodeGroup, 100 /* charIndex */,
constants.Dir.BACKWARD /* direction */);
assertEquals(result.nodes.length, 0);
});
TEST_F(
'SelectToSpeakNodeUtilsUnitTest',
'getNextNodesInParagraphFromNodeGroup_forwardWithEmptyTail', function() {
// The nodeGroup consists of three inline text nodes: "Hello", "world ",
// and " ".
const nodeGroup = generateTestNodeGroupWithEmptyTail();
......@@ -881,21 +923,47 @@ TEST_F(
assertEquals(nodeWithOffset.node.name, 'world ');
assertEquals(nodeWithOffset.offset, 5);
let result = NodeUtils.getNextNodesInParagraphFromNodeGroupWithOffset(
nodeGroup, 11 /* charIndex */);
let result = NodeUtils.getNextNodesInParagraphFromNodeGroup(
nodeGroup, 11 /* charIndex */, constants.Dir.FORWARD /* direction */);
assertEquals(result.nodes.length, 0);
// If we decrease the charIndex, we will get the node with 'world ' but
// not any empty node.
result = NodeUtils.getNextNodesInParagraphFromNodeGroupWithOffset(
nodeGroup, 10 /* charIndex */);
result = NodeUtils.getNextNodesInParagraphFromNodeGroup(
nodeGroup, 10 /* charIndex */, constants.Dir.FORWARD /* direction */);
assertEquals(result.nodes.length, 1);
assertEquals(result.nodes[0].name, 'world ');
});
TEST_F(
'SelectToSpeakNodeUtilsUnitTest',
'getNextNodesInParagraphFromNodeGroupWithOffsetFromPartialParagraph',
'getNextNodesInParagraphFromNodeGroup_backwardWithEmptyHeads', function() {
// The nodeGroup consists of three inline text nodes: " ", " Hello",
// "world".
const nodeGroup = generateTestNodeGroupWithEmptyHead();
// We can find the non-empty node at this charIndex but there is actually
// no text content before this position.
const nodeWithOffset = ParagraphUtils.findNodeFromNodeGroupByCharIndex(
nodeGroup, 2 /* charIndex */);
assertEquals(nodeWithOffset.node.name, ' Hello');
assertEquals(nodeWithOffset.offset, 1);
let result = NodeUtils.getNextNodesInParagraphFromNodeGroup(
nodeGroup, 2 /* charIndex */, constants.Dir.BACKWARD /* direction */);
assertEquals(result.nodes.length, 0);
// If we increase the charIndex, we will get the node with ' Hello' but
// not any empty node.
result = NodeUtils.getNextNodesInParagraphFromNodeGroup(
nodeGroup, 3 /* charIndex */, constants.Dir.BACKWARD /* direction */);
assertEquals(result.nodes.length, 1);
assertEquals(result.nodes[0].name, ' Hello');
});
TEST_F(
'SelectToSpeakNodeUtilsUnitTest',
'getNextNodesInParagraphFromNodeGroup_forwardFromPartialParagraph',
function() {
// The nodeGroup consists only one static text node, which is "one". The
// entire paragraph has three static text nodes: "Sentence", "one",
......@@ -908,12 +976,34 @@ TEST_F(
nodeGroup, 3 /* charIndex */);
assertEquals(nodeWithOffset.node, null);
const result = NodeUtils.getNextNodesInParagraphFromNodeGroupWithOffset(
nodeGroup, 3 /* charIndex */);
const result = NodeUtils.getNextNodesInParagraphFromNodeGroup(
nodeGroup, 3 /* charIndex */, constants.Dir.FORWARD /* direction */);
assertEquals(result.nodes.length, 1);
assertEquals(result.offset, 0);
});
TEST_F(
'SelectToSpeakNodeUtilsUnitTest',
'getNextNodesInParagraphFromNodeGroup_backwardFromPartialParagraph',
function() {
// The nodeGroup consists only one static text node, which is "one". The
// entire paragraph has three static text nodes: "Sentence", "one",
// "here".
const nodeGroup = generateTestNodeGroupFromPartialParagraph();
// After reading "one", the TTS events will set charIndex to 3. Finding
// the static text node from the node group will return null.
const nodeWithOffset = ParagraphUtils.findNodeFromNodeGroupByCharIndex(
nodeGroup, 3 /* charIndex */);
assertEquals(nodeWithOffset.node, null);
const result = NodeUtils.getNextNodesInParagraphFromNodeGroup(
nodeGroup, 3 /* charIndex */, constants.Dir.BACKWARD /* direction */);
assertEquals(result.nodes.length, 1);
assertEquals(result.nodes[0].name, 'Sentence');
assertEquals(result.offset, 8);
});
/**
* Creates a AutomationNode-like object.
* @param {!Object} properties
......@@ -1019,6 +1109,31 @@ function generateTestNodeGroupWithEmptyTail() {
false /* do not split on language */);
}
/**
* Creates a nodeGroup that has an empty head (i.e., " Hello world").
* @return {!ParagraphUtils.NodeGroup}
*/
function generateTestNodeGroupWithEmptyHead() {
const root = createMockNode({role: 'rootWebArea'});
const paragraph =
createMockNode({role: 'paragraph', display: 'block', parent: root, root});
const text1 =
createMockNode({name: ' Hello', role: 'staticText', parent: paragraph});
const inlineText1 = createMockNode(
{role: 'inlineTextBox', name: ' ', indexInParent: 0, parent: text1});
const inlineText2 = createMockNode(
{role: 'inlineTextBox', name: ' Hello', indexInParent: 1, parent: text1});
const text2 =
createMockNode({name: 'world ', role: 'staticText', parent: paragraph});
const inlineText3 = createMockNode(
{role: 'inlineTextBox', name: 'world', indexInParent: 1, parent: text2});
return ParagraphUtils.buildNodeGroup(
[inlineText1, inlineText2, inlineText3], 0,
false /* do not split on language */);
}
/**
* Creates a nodeGroup that only has a part of the paragraph (e.g., the "one" in
* <p>Sentence <span>one</span> here</p>).
......
......@@ -316,15 +316,21 @@ class ParagraphUtils {
* @param {Array<!AutomationNode>} nodes List of automation nodes to use.
* @param {number} index The index into nodes at which to start.
* @param {{splitOnLanguage: (boolean|undefined),
* clipOverflowWords: (boolean|undefined)}=} opt_options
* splitOnLanguage: flag to determine if we should split nodes
* up based on language.
* splitOnParagraph: (boolean|undefined),
* clipOverflowWords: (boolean|undefined)}=} options
* splitOnLanguage: flag to determine if we should split nodes up based on
* language. If this is not passed, default to false.
* splitOnParagraph: flag to determine if we should split nodes up based
* on paragraph. If this is not passed, default to true.
* clipOverflowWords: Whether to clip generated text.
* @return {ParagraphUtils.NodeGroup} info about the node group
*/
static buildNodeGroup(nodes, index, opt_options) {
const options = opt_options || {};
static buildNodeGroup(nodes, index, options) {
options = options || {};
const splitOnLanguage = options.splitOnLanguage || false;
const splitOnParagraph = options.splitOnParagraph === undefined ?
true :
options.splitOnParagraph;
const clipOverflowWords = options.clipOverflowWords || false;
let node = nodes[index];
let next = nodes[index + 1];
......@@ -393,9 +399,10 @@ class ParagraphUtils {
// Stop if any of following is true:
// 1. we have no more nodes to process.
// 2. the next node is not part of the same paragraph.
// 2. we need to check for a paragraph split and if the next node is not
// part of the same paragraph.
if (index + 1 >= nodes.length ||
!ParagraphUtils.inSameParagraph(node, next)) {
(splitOnParagraph && !ParagraphUtils.inSameParagraph(node, next))) {
break;
}
......@@ -420,6 +427,59 @@ class ParagraphUtils {
return result;
}
/**
* Builds a single node group that contains all the input |nodes|. In
* addition, this function will transform the provided node offsets to values
* that are relative to the text of the resulting node group. This function
* can be used to check the sentence boundaries across all the input nodes.
* @param {!Array<!AutomationNode>} nodes The nodes for the selected content.
* @param {number=} opt_startIndex The index into the first node's text at
* which the selected content starts.
* @param {number=} opt_endIndex The index into the last node's text at which
* the selected content ends.
* @return {!{nodeGroup: ParagraphUtils.NodeGroup,
* startIndexInGroup: (number|undefined),
* endIndexInGroup: (number|undefined)}}
* nodeGroup: The node group that contains all the input |nodes|. The node
* group will not consider any language difference or paragraph split.
* startOffsetInGroup: The index into the node group's text at which the
* selected content starts.
* endOffsetInGroup: The index into the node group's text at which the
* selected content ends.
*/
static buildSingleNodeGroupWithOffset(nodes, opt_startIndex, opt_endIndex) {
const nodeGroup = ParagraphUtils.buildNodeGroup(
nodes, 0 /* index */,
{splitOnLanguage: false, splitOnParagraph: false});
if (opt_startIndex !== undefined) {
// The first node of the NodeGroup may not be at the beginning of the
// parent of the NodeGroup. (e.g., an inlineText in its staticText
// parent). Thus, we need to adjust the |opt_startIndex|.
const firstNodeHasInlineText =
nodeGroup.nodes.length > 0 && nodeGroup.nodes[0].hasInlineText;
const startIndexInNodeParent = firstNodeHasInlineText ?
ParagraphUtils.getStartCharIndexInParent(nodes[0]) :
0;
opt_startIndex += startIndexInNodeParent + nodeGroup.nodes[0].startChar;
}
if (opt_endIndex !== undefined) {
// Similarly, |opt_endIndex| needs to be adjusted.
const lastNodeHasInlineText = nodeGroup.nodes.length > 0 &&
nodeGroup.nodes[nodeGroup.nodes.length - 1].hasInlineText;
const startIndexInNodeParent = lastNodeHasInlineText ?
ParagraphUtils.getStartCharIndexInParent(nodes[0]) :
0;
opt_endIndex += startIndexInNodeParent +
nodeGroup.nodes[nodeGroup.nodes.length - 1].startChar;
}
return {
nodeGroup,
startIndexInGroup: opt_startIndex,
endIndexInGroup: opt_endIndex
};
}
/**
* Finds the AutomationNode that appears at the given character index within
* the |nodeGroup|.
......
......@@ -247,6 +247,33 @@ TEST_F(
assertEquals(paragraph1, result.blockParent);
});
TEST_F(
'SelectToSpeakParagraphUnitTest', 'BuildNodeGroupAcrossParagraphs',
function() {
const root = {role: 'rootWebArea'};
const paragraph1 =
{role: 'paragraph', display: 'block', parent: root, root};
const text1 =
{role: 'staticText', parent: paragraph1, name: 'text1', root};
const text2 =
{role: 'staticText', parent: paragraph1, name: 'text2', root};
const paragraph2 =
{role: 'paragraph', display: 'block', parent: root, root};
const text3 =
{role: 'staticText', parent: paragraph2, name: 'text3', root};
const result = ParagraphUtils.buildNodeGroup(
[text1, text2, text3], 0, {splitOnParagraph: false});
assertEquals('text1 text2 text3 ', result.text);
assertEquals(2, result.endIndex);
assertEquals(3, result.nodes.length);
assertEquals(0, result.nodes[0].startChar);
assertEquals(text1, result.nodes[0].node);
assertEquals(6, result.nodes[1].startChar);
assertEquals(text2, result.nodes[1].node);
assertEquals(12, result.nodes[2].startChar);
assertEquals(text3, result.nodes[2].node);
});
TEST_F(
'SelectToSpeakParagraphUnitTest', 'BuildNodeGroupStopsAtLanguageBoundary',
function() {
......@@ -489,14 +516,17 @@ TEST_F(
'SelectToSpeakParagraphUnitTest', 'findNodeFromNodeGroupByCharIndex',
function() {
// The array has four inline text nodes and one static text node.
// Their starting indexes are 0, 9, 20, 30, and 51.
const nodeGroup = ParagraphUtils.buildNodeGroup(
generateNodesForParagraph(), 0, false /* do not split on language */);
const nodes = generateNodesForParagraph();
const nodeGroup =
ParagraphUtils.buildNodeGroup(generateNodesForParagraph(), 0);
// Start index = 0
const firstInline = 'The first';
// Start index = 9
const secondInline = ' sentence.';
// Start index = 20
const thirdInline = 'The second';
// Start index = 30
const fourthInline = ' sentence is longer.';
// Start index = 51
const thirdStatic = 'No child sentence.';
let result = ParagraphUtils.findNodeFromNodeGroupByCharIndex(
......@@ -534,6 +564,65 @@ TEST_F(
assertEquals(result.node, null);
});
TEST_F(
'SelectToSpeakParagraphUnitTest', 'BuildSingleNodeGroupWithOffset',
function() {
// The array has four inline text nodes and one static text node.
// Their starting indexes are 0, 9, 20, 30, and 51.
const nodes = generateNodesForParagraph();
// Start index = 0
const firstInline = 'The first';
// Start index = 9
const secondInline = ' sentence.';
const firstSentence = firstInline + secondInline + ' ';
// Start index = 20
const thirdInline = 'The second';
// Start index = 30
const fourthInline = ' sentence is longer.';
const secondSentence = thirdInline + fourthInline + ' ';
// Start index = 51
const thirdStatic = 'No child sentence.';
const thirdSentence = thirdStatic + ' ';
let nodeGroup, startIndexInGroup, endIndexInGroup;
({nodeGroup, startIndexInGroup, endIndexInGroup} =
ParagraphUtils.buildSingleNodeGroupWithOffset(nodes));
assertEquals(
nodeGroup.text, firstSentence + secondSentence + thirdSentence);
assertEquals(startIndexInGroup, undefined);
assertEquals(endIndexInGroup, undefined);
({nodeGroup, startIndexInGroup, endIndexInGroup} =
ParagraphUtils.buildSingleNodeGroupWithOffset(
nodes, 5 /* startIndex */));
assertEquals(
nodeGroup.text, firstSentence + secondSentence + thirdSentence);
assertEquals(startIndexInGroup, 5);
assertEquals(endIndexInGroup, undefined);
({nodeGroup, startIndexInGroup, endIndexInGroup} =
ParagraphUtils.buildSingleNodeGroupWithOffset(
nodes.slice(1), 0 /* startIndex */));
assertEquals(
nodeGroup.text, firstSentence + secondSentence + thirdSentence);
assertEquals(startIndexInGroup, 9);
assertEquals(endIndexInGroup, undefined);
({nodeGroup, startIndexInGroup, endIndexInGroup} =
ParagraphUtils.buildSingleNodeGroupWithOffset(
nodes.slice(2, 5), 1 /* startIndex */, 1 /* endIndex */));
assertEquals(nodeGroup.text, secondSentence + thirdSentence);
assertEquals(startIndexInGroup, 1);
assertEquals(endIndexInGroup, 32);
({nodeGroup, startIndexInGroup, endIndexInGroup} =
ParagraphUtils.buildSingleNodeGroupWithOffset(
nodes.slice(4, 5), undefined, 5 /* endIndex */));
assertEquals(nodeGroup.text, thirdSentence);
assertEquals(startIndexInGroup, undefined);
assertEquals(endIndexInGroup, 5);
});
/**
* Creates an array of nodes that represents a paragraph.
* @return {Array<AutomationNode>}
......
......@@ -690,8 +690,9 @@ class SelectToSpeak {
return;
}
const {nodes: remainingNodes, offset} =
NodeUtils.getNextNodesInParagraphFromNodeGroupWithOffset(
this.currentNodeGroup_, this.navigationState_.currentCharIndex);
NodeUtils.getNextNodesInParagraphFromNodeGroup(
this.currentNodeGroup_, this.navigationState_.currentCharIndex,
constants.Dir.FORWARD);
// There is no remaining nodes in this paragraph so we navigate to the next
// paragraph.
if (remainingNodes.length === 0) {
......@@ -710,17 +711,9 @@ class SelectToSpeak {
return;
}
// If the current position is not a sentence start, move to the start of the
// current sentence.
const currentSentenceStart = SentenceUtils.getSentenceStart(
this.currentNodeGroup_, this.navigationState_.currentCharIndex,
constants.Dir.BACKWARD);
// Only navigate to the current sentence start if it exists, otherwise move
// to the beginning of the current paragraph.
// TODO(leileilei): check if the 0 char index would cause issues.
this.navigationState_.currentCharIndex =
currentSentenceStart === null ? 0 : currentSentenceStart;
this.startCurrentNodeGroup_();
// If the current position is not a sentence start, navigate to the start of
// this sentence.
this.navigateToNextSentence_(constants.Dir.BACKWARD);
}
/**
......@@ -1020,30 +1013,175 @@ class SelectToSpeak {
}
/**
* Navigate to the next sentence.
* @param {constants.Dir} direction whether to find the next sentence or
* previous sentence.
* Navigates to the next sentence. First, we search the next sentence in the
* current node group. If we do not find one, we will search within the
* remaining content in the current paragraph (i.e., text block). If this
* still fails, we will search the next paragraph.
* @param {constants.Dir} direction Direction to search for the next sentence.
* If set to forward, we look for the sentence start after the current
* position. Otherwise, we look for the sentence start before the current
* position.
* @private
*/
async navigateToNextSentence_(direction) {
// An empty node group is not expected and means that the user has not
// enqueued any text.
if (this.currentNodeGroup_ === null) {
if (!this.currentNodeGroup_) {
return;
}
await this.pause_();
const nextSentenceStartInCurrentNodeGroup = SentenceUtils.getSentenceStart(
if (!this.isPaused_()) {
await this.pause_();
}
// Check the next sentence start within this node group.
if (this.navigateToNextSentenceWithinNodeGroup_(
this.currentNodeGroup_, this.navigationState_.currentCharIndex,
direction)) {
return;
}
// If there is no sentence start at the current node group, look for the
// content within this paragraph. First, we get the remaining content in
// the paragraph. The returned offset marks the char index of the current
// position in the paragraph. When searching forward, the offset is the
// char index pointing to the beginning of the remaining content. When
// searching backward, the offset is the char index pointing to the char
// after the remaining content.
const {nodes, offset} = NodeUtils.getNextNodesInParagraphFromNodeGroup(
this.currentNodeGroup_, this.navigationState_.currentCharIndex,
direction);
if (nextSentenceStartInCurrentNodeGroup === null) {
// TODO(leileilei): if there is no sentence in the current node group,
// move to the next one.
// If we have reached to the end of a paragraph, navigate to the next
// paragraph.
if (nodes.length === 0) {
this.navigateToNextSentenceInNextParagraph_(direction);
return;
}
// Get the node group for the remaining content in the paragraph. If we are
// looking for the content after the current position, set startIndex as
// offset. Otherwise, set endIndex as offset.
const startIndex = direction === constants.Dir.FORWARD ? offset : undefined;
const endIndex = direction === constants.Dir.FORWARD ? undefined : offset;
const {nodeGroup, startIndexInGroup, endIndexInGroup} =
ParagraphUtils.buildSingleNodeGroupWithOffset(
nodes, startIndex, endIndex);
// Search in the remaining content.
const charIndex = direction === constants.Dir.FORWARD ? startIndexInGroup :
endIndexInGroup;
// The charIndex is guaranteed to be valid at this point, although the
// closure compiler is not able to detect it as a valid number.
if (charIndex === undefined) {
console.warn('Navigate sentence with an invalid char index', charIndex);
return;
}
if (this.navigateToNextSentenceWithinNodeGroup_(
nodeGroup, charIndex, direction)) {
return;
}
// If there is no sentence start within this paragraph, navigate to the next
// one.
this.navigateToNextSentenceInNextParagraph_(direction);
}
/**
* Navigates to the next sentence within the |nodeGroup|. If the |direction|
* is set to forward, it will navigate to the sentence start after the
* |startCharIndex|. Otherwise, it will look for the sentence start before the
* |startCharIndex|.
* @param {ParagraphUtils.NodeGroup} nodeGroup
* @param {number} startCharIndex The char index that we start from. This
* index is relative to the text content of this node group and is
* exclusive: if a sentence start at 0 and we search with a 0
* |startCharIndex|, this function will return the next sentence start
* after 0 if we search forward.
* @param {constants.Dir} direction
* @return {boolean} Whether we have found a sentence start in the given node
* group. If we found the sentence start, we will start TTS.
* @private
*/
navigateToNextSentenceWithinNodeGroup_(nodeGroup, startCharIndex, direction) {
if (!nodeGroup) {
return false;
}
const nextSentenceStart =
SentenceUtils.getSentenceStart(nodeGroup, startCharIndex, direction);
if (nextSentenceStart === null) {
return false;
}
// Get the content between the sentence start and the end of the paragraph.
const {nodes, offset} = NodeUtils.getNextNodesInParagraphFromNodeGroup(
nodeGroup, nextSentenceStart, constants.Dir.FORWARD);
if (nodes.length === 0) {
// There is no remaining content. Move to the next paragraph. This is
// unexpected since we already found a sentence start, which indicates
// there should be some content to read.
this.navigateToNextSentenceInNextParagraph_(direction);
} else {
this.navigationState_.currentCharIndex =
nextSentenceStartInCurrentNodeGroup;
this.startSpeechQueue_(
nodes, {clearFocusRing: false, startCharIndex: offset});
}
this.resume_();
return true;
}
/**
* Navigates to the next sentence in the next text block in the given
* direction. If the |direction| is set to forward, it will navigate to the
* start of the following text block. Otherwise, it will look for the last
* sentence in the previous text block. This function will also start TTS
* regardless of whether we have found a sentence start in the text block.
* @param {constants.Dir} direction
* @private
*/
navigateToNextSentenceInNextParagraph_(direction) {
const paragraphNodes = this.locateNodesForNextParagraph_(direction);
if (paragraphNodes.length === 0) {
return;
}
// Ensure the first node in the paragraph is visible.
paragraphNodes[0].makeVisible();
if (direction === constants.Dir.FORWARD) {
// If we are looking for the sentence start in the following text block,
// start reading the nodes.
this.startSpeechQueue_(paragraphNodes);
return;
}
// If we are looking for the previous sentence start, search the last
// sentence in the previous text block. Get the node group for the previous
// text block. The returned startIndexInGroup and endIndexInGroup are
// unused.
const {nodeGroup, startIndexInGroup, endIndexInGroup} =
ParagraphUtils.buildSingleNodeGroupWithOffset(paragraphNodes);
// We search backward for the sentence start before the end of the text
// block.
const searchOffset = nodeGroup.text.length;
const sentenceStartIndex = SentenceUtils.getSentenceStart(
nodeGroup, searchOffset, constants.Dir.BACKWARD);
// If there is no sentence start in the previous text block, start reading
// the block.
if (sentenceStartIndex === null) {
this.startSpeechQueue_(paragraphNodes);
return;
}
// Gets the remaining content between the sentence start till the end of the
// text block. The offset is the start char index for the first node in the
// remaining content.
const {nodes, offset} = NodeUtils.getNextNodesInParagraphFromNodeGroup(
nodeGroup, sentenceStartIndex, constants.Dir.FORWARD);
if (nodes.length === 0) {
// If there is no remaining content, start reading the block. This is
// unexpected since we already found a sentence start, which indicates
// there should be some content to read.
this.startSpeechQueue_(paragraphNodes);
return;
}
// Reads the remaining content from the sentence start till the end of the
// block.
this.startSpeechQueue_(
nodes, {clearFocusRing: false, startCharIndex: offset});
}
/**
......@@ -1057,6 +1195,26 @@ class SelectToSpeak {
await this.pause_();
}
const nodes = this.locateNodesForNextParagraph_(direction);
if (nodes.length === 0) {
return;
}
// Ensure the first node in the paragraph is visible.
nodes[0].makeVisible();
this.startSpeechQueue_(nodes);
}
/**
* Finds the nodes for the next text block in the given direction. This
* function is based on |NodeUtils.getNextParagraph| but provides additional
* checks on the anchor node used for searchiong.
* @param {constants.Dir} direction
* @return {Array<!AutomationNode>} A list of nodes for the next block in the
* given direction.
* @private
*/
locateNodesForNextParagraph_(direction) {
// Use current block parent as starting point to navigate from. If it is not
// a valid block, then use one of the nodes that are currently activated.
let node = this.currentBlockParent_;
......@@ -1066,25 +1224,22 @@ class SelectToSpeak {
}
if (node === null) {
// Could not find any nodes to navigate from.
return;
return [];
}
// Retrieve the nodes that make up the next/prev paragraph.
const nextParagraphNodes = NodeUtils.getNextParagraph(node, direction);
if (nextParagraphNodes.length === 0) {
// Cannot find any valid nodes in given direction.
return;
return [];
}
if (AutomationUtil.getAncestors(nextParagraphNodes[0])
.find((n) => this.isPanel_(n))) {
// Do not navigate to Select-to-speak panel.
return;
return [];
}
// Ensure the first node in the paragraph is visible.
nextParagraphNodes[0].makeVisible();
this.startSpeechQueue_(nextParagraphNodes);
return nextParagraphNodes;
}
/**
......
......@@ -276,6 +276,68 @@ TEST_F('SelectToSpeakNavigationControlTest', 'NextSentence', function() {
});
});
TEST_F(
'SelectToSpeakNavigationControlTest', 'NextSentenceWithinParagraph',
function() {
const bodyHtml = `
<p id="p1">Sent 1. <span id="s1">Sent 2.</span> Sent 3. Sent 4.</p>
`;
this.runWithLoadedTree(
this.generateHtmlWithSelectedElement('s1', bodyHtml), () => {
this.triggerReadSelectedText();
// Speaks the first word.
this.mockTts.speakUntilCharIndex(5);
assertTrue(this.mockTts.currentlySpeaking());
assertEquals(this.mockTts.pendingUtterances().length, 1);
this.assertEqualsCollapseWhitespace(
this.mockTts.pendingUtterances()[0], 'Sent 2.');
// Hitting next sentence will start from the next sentence.
selectToSpeak.onSelectToSpeakPanelAction_(
chrome.accessibilityPrivate.SelectToSpeakPanelAction
.NEXT_SENTENCE);
this.waitOneEventLoop(() => {
assertTrue(this.mockTts.currentlySpeaking());
assertEquals(this.mockTts.pendingUtterances().length, 1);
this.assertEqualsCollapseWhitespace(
this.mockTts.pendingUtterances()[0], 'Sent 3. Sent 4.');
});
});
});
TEST_F(
'SelectToSpeakNavigationControlTest', 'NextSentenceAcrossParagraph',
function() {
const bodyHtml = `
<p id="p1">Sent 1.</p>
<p id="p2">Sent 2. Sent 3.</p>'
`;
this.runWithLoadedTree(
this.generateHtmlWithSelectedElement('p1', bodyHtml), () => {
this.triggerReadSelectedText();
// Speaks the first word.
this.mockTts.speakUntilCharIndex(5);
assertTrue(this.mockTts.currentlySpeaking());
assertEquals(this.mockTts.pendingUtterances().length, 1);
this.assertEqualsCollapseWhitespace(
this.mockTts.pendingUtterances()[0], 'Sent 1.');
// Hitting next sentence will star from the next paragraph as there
// is no more sentence in the current paragraph.
selectToSpeak.onSelectToSpeakPanelAction_(
chrome.accessibilityPrivate.SelectToSpeakPanelAction
.NEXT_SENTENCE);
this.waitOneEventLoop(() => {
assertTrue(this.mockTts.currentlySpeaking());
assertEquals(this.mockTts.pendingUtterances().length, 1);
this.assertEqualsCollapseWhitespace(
this.mockTts.pendingUtterances()[0], 'Sent 2. Sent 3.');
});
});
});
TEST_F('SelectToSpeakNavigationControlTest', 'PrevSentence', function() {
const bodyHtml = `
<p id="p1">First sentence. Second sentence. Third sentence.</p>'
......@@ -304,6 +366,69 @@ TEST_F('SelectToSpeakNavigationControlTest', 'PrevSentence', function() {
});
});
TEST_F(
'SelectToSpeakNavigationControlTest', 'PrevSentenceWithinParagraph',
function() {
const bodyHtml = `
<p id="p1">Sent 0. Sent 1. <span id="s1">Sent 2.</span> Sent 3.</p>
`;
this.runWithLoadedTree(
this.generateHtmlWithSelectedElement('s1', bodyHtml), () => {
this.triggerReadSelectedText();
// Supposing we are at the start of the sentence.
assertTrue(this.mockTts.currentlySpeaking());
assertEquals(this.mockTts.pendingUtterances().length, 1);
this.assertEqualsCollapseWhitespace(
this.mockTts.pendingUtterances()[0], 'Sent 2.');
// Hitting previous sentence will start from the previous sentence.
selectToSpeak.onSelectToSpeakPanelAction_(
chrome.accessibilityPrivate.SelectToSpeakPanelAction
.PREVIOUS_SENTENCE);
this.waitOneEventLoop(() => {
assertTrue(this.mockTts.currentlySpeaking());
assertEquals(this.mockTts.pendingUtterances().length, 1);
this.assertEqualsCollapseWhitespace(
this.mockTts.pendingUtterances()[0],
'Sent 1. Sent 2. Sent 3.');
});
});
});
TEST_F(
'SelectToSpeakNavigationControlTest', 'PrevSentenceAcrossParagraph',
function() {
const bodyHtml = `
<p id="p1">Sent 1. Sent 2.</p>
<p id="p2">Sent 3.</p>'
`;
this.runWithLoadedTree(
this.generateHtmlWithSelectedElement('p2', bodyHtml), () => {
this.triggerReadSelectedText();
// We are at the start of the sentence.
assertTrue(this.mockTts.currentlySpeaking());
assertEquals(this.mockTts.pendingUtterances().length, 1);
this.assertEqualsCollapseWhitespace(
this.mockTts.pendingUtterances()[0], 'Sent 3.');
// Hitting previous sentence will start from the last sentence in
// the previous paragraph as there is no more sentence in the
// current paragraph.
selectToSpeak.onSelectToSpeakPanelAction_(
chrome.accessibilityPrivate.SelectToSpeakPanelAction
.PREVIOUS_SENTENCE);
this.waitOneEventLoop(() => {
assertTrue(this.mockTts.currentlySpeaking());
assertEquals(this.mockTts.pendingUtterances().length, 1);
this.assertEqualsCollapseWhitespace(
this.mockTts.pendingUtterances()[0], 'Sent 2.');
});
});
});
// TODO(https://crbug.com/1157817): Fix Flaky Test.
TEST_F(
'SelectToSpeakNavigationControlTest', 'ChangeSpeedWhilePlaying',
function() {
......
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