Commit 92350af5 authored by Joel Riley's avatar Joel Riley Committed by Chromium LUCI CQ

Support Search Key + single click to navigate.

Search key + single click will navigate to paragraph. In existing STS, Search key + single click while STS is active would exit STS, now it will navigate to the given paragraph.

When sentence navigation is implemented, we can navigate to the specific sentence rather than always starting from the beginning.

AX-Relnotes: N/A
Bug: 1143819
Change-Id: I5b5a73a20c8080922f7e9984af982c1ffc5bfc90
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2569785
Commit-Queue: Joel Riley <joelriley@google.com>
Reviewed-by: default avatarDominic Mazzoni <dmazzoni@chromium.org>
Cr-Commit-Position: refs/heads/master@{#834292}
parent 8970a3d2
...@@ -107,31 +107,46 @@ AutomationUtil = class { ...@@ -107,31 +107,46 @@ AutomationUtil = class {
* @return {AutomationNode} * @return {AutomationNode}
*/ */
static findNextNode(cur, dir, pred, opt_restrictions) { static findNextNode(cur, dir, pred, opt_restrictions) {
const restrictions = {}; const walker = createWalker(cur, dir, pred, opt_restrictions);
opt_restrictions = opt_restrictions || {
leaf: undefined,
root: undefined,
visit: undefined,
skipInitialSubtree: !AutomationPredicate.container(cur) && pred(cur)
};
restrictions.root = opt_restrictions.root || AutomationPredicate.root;
restrictions.leaf = opt_restrictions.leaf || function(node) {
// Treat nodes matched by |pred| as leaves except for containers.
return !AutomationPredicate.container(node) && pred(node);
};
restrictions.skipInitialSubtree = opt_restrictions.skipInitialSubtree;
restrictions.skipInitialAncestry = opt_restrictions.skipInitialAncestry;
restrictions.visit = function(node) {
return pred(node) && !AutomationPredicate.shouldIgnoreNode(node);
};
const walker = new AutomationTreeWalker(cur, dir, restrictions);
return walker.next().node; return walker.next().node;
} }
/**
* Finds all nodes in the given direction in depth first order.
*
* Let D be the dfs linearization of |cur.root|. Then, let F be the list after
* applying |pred| as a filter to D. This method will return the directed next
* node of |cur| in F.
* The restrictions option will further filter F. For example,
* |skipInitialSubtree| will remove any |pred| matches in the subtree of |cur|
* from F.
* @param {!AutomationNode} cur Node to begin the search
* from.
* @param {Dir} dir
* @param {AutomationPredicate.Unary} pred A predicate to apply
* to a candidate node.
* @param {AutomationTreeWalkerRestriction=} opt_restrictions |leaf|, |root|,
* |skipInitialAncestry|, and |skipInitialSubtree| are valid restrictions
* used when finding the next node.
* By default:
* the root predicate ges set to |AutomationPredicate.root|.
* |skipInitialSubtree| is false if |cur| is a container or matches
* |pred|. This alleviates the caller from syncing forwards.
* Leaves are nodes matched by |prred| which are not also containers.
* This takes care of syncing backwards.
* @return {!Array<!AutomationNode>}
*/
static findAllNodes(cur, dir, pred, opt_restrictions) {
const walker = createWalker(cur, dir, pred, opt_restrictions);
const nodes = [];
let currentNode = walker.next().node;
while (currentNode) {
nodes.push(currentNode);
currentNode = walker.next().node;
}
return nodes;
}
/** /**
* Given nodes a_1, ..., a_n starting at |cur| in pre order traversal, apply * Given nodes a_1, ..., a_n starting at |cur| in pre order traversal, apply
* |pred| to a_i and a_(i - 1) until |pred| is satisfied. Returns a_(i - 1) * |pred| to a_i and a_(i - 1) until |pred| is satisfied. Returns a_(i - 1)
...@@ -443,4 +458,42 @@ AutomationUtil = class { ...@@ -443,4 +458,42 @@ AutomationUtil = class {
return null; return null;
} }
}; };
/**
* @param {!AutomationNode} cur Node to begin the search
* from.
* @param {Dir} dir
* @param {AutomationPredicate.Unary} pred A predicate to apply
* to a candidate node.
* @param {AutomationTreeWalkerRestriction=} opt_restrictions |leaf|, |root|,
* |skipInitialAncestry|, and |skipInitialSubtree| are valid restrictions
* used when finding the next node.
* @return {!AutomationTreeWalker} Instance of tree walker initialized with
* given parameters.
*/
function createWalker(cur, dir, pred, opt_restrictions) {
const restrictions = {};
opt_restrictions = opt_restrictions || {
leaf: undefined,
root: undefined,
visit: undefined,
skipInitialSubtree: !AutomationPredicate.container(cur) && pred(cur)
};
restrictions.root = opt_restrictions.root || AutomationPredicate.root;
restrictions.leaf = opt_restrictions.leaf || function(node) {
// Treat nodes matched by |pred| as leaves except for containers.
return !AutomationPredicate.container(node) && pred(node);
};
restrictions.skipInitialSubtree = opt_restrictions.skipInitialSubtree;
restrictions.skipInitialAncestry = opt_restrictions.skipInitialAncestry;
restrictions.visit = function(node) {
return pred(node) && !AutomationPredicate.shouldIgnoreNode(node);
};
return new AutomationTreeWalker(cur, dir, restrictions);
}
}); // goog.scope }); // goog.scope
...@@ -466,37 +466,82 @@ class NodeUtils { ...@@ -466,37 +466,82 @@ class NodeUtils {
* within the paragraph adjacent to the given node. * within the paragraph adjacent to the given node.
*/ */
static getNextParagraph(node, direction) { static getNextParagraph(node, direction) {
const blockParent = ParagraphUtils.getFirstBlockAncestor(node);
if (blockParent === null) {
return [];
}
let nextNode = AutomationUtil.findNextNode( let nextNode = AutomationUtil.findNextNode(
node, direction, AutomationPredicate.leafWithText, node, direction, NodeUtils.isValidLeafNode, {skipInitialSubtree: true});
{skipInitialSubtree: true}); while (nextNode !== null &&
while ( ParagraphUtils.getFirstBlockAncestor(nextNode) === blockParent) {
nextNode !== null &&
(NodeUtils.shouldIgnoreNode(nextNode, /* includeOffscreen= */ true) ||
NodeUtils.isNotSelectable(nextNode) ||
ParagraphUtils.inSameParagraph(node, nextNode))) {
nextNode = AutomationUtil.findNextNode( nextNode = AutomationUtil.findNextNode(
nextNode, direction, AutomationPredicate.leafWithText); nextNode, direction, NodeUtils.isValidLeafNode);
} }
if (nextNode === null) { if (nextNode === null) {
return []; return [];
} }
// Now construct an array with all leaf nodes within the block. // Now construct an array with all leaf nodes within the block.
const nodes = []; const nodes = NodeUtils.getNextNodesInParagraph(nextNode, direction);
do { if (direction === constants.Dir.FORWARD) {
if (!NodeUtils.shouldIgnoreNode(nextNode, /* includeOffscreen= */ true) && nodes.unshift(nextNode);
!NodeUtils.isNotSelectable(nextNode)) { } else {
nodes.push(nextNode); nodes.push(nextNode);
} }
nextNode = AutomationUtil.findNextNode( return nodes;
nextNode, direction, AutomationPredicate.leafWithText); }
} while (nextNode !== null &&
ParagraphUtils.inSameParagraph(nodes[0], nextNode)); /**
* @param {!AutomationNode} node Leaf node.
* @param {constants.Dir} direction
* @return {!Array<!AutomationNode>} The selectable leaf nodes in the given
* direction from the given node, until a paragraph break is reached.
*/
static getNextNodesInParagraph(node, direction) {
const blockParent = ParagraphUtils.getFirstBlockAncestor(node);
if (blockParent === null) {
return [];
}
const nodes = AutomationUtil.findAllNodes(
node, direction,
/* pred= */ NodeUtils.isValidLeafNode, /* opt_restrictions= */ {
root: (node) =>
node === blockParent, // Only traverse within the block
});
// Reverse the nodes if we were traversing backward, so the returned result // Reverse the nodes if we were traversing backward, so the returned result
// is in natural DOM order. // is in natural DOM order.
return direction === constants.Dir.BACKWARD ? nodes.reverse() : nodes; return direction === constants.Dir.BACKWARD ? nodes.reverse() : nodes;
} }
/**
* @param {!AutomationNode} node Leaf node.
* @return {!Array<!AutomationNode>} All selectable leaf nodes in the
* paragraph that the given leaf node belongs to.
*/
static getAllNodesInParagraph(node) {
const blockParent = ParagraphUtils.getFirstBlockAncestor(node);
if (blockParent === null) {
return [];
}
return AutomationUtil.findAllNodes(
blockParent, constants.Dir.FORWARD,
/* pred= */ NodeUtils.isValidLeafNode, /* opt_restrictions= */ {
root: (node) =>
node === blockParent, // Only traverse within the block
});
}
/**
* @param {!AutomationNode} node
* @return {boolean} Whether the given node is a valid leaf node that is can
* be ingested by Select-to-speak.
*/
static isValidLeafNode(node) {
return AutomationPredicate.leafWithText(node) &&
!NodeUtils.shouldIgnoreNode(node, /* includeOffscreen= */ true) &&
!NodeUtils.isNotSelectable(node);
}
} }
/** /**
......
...@@ -551,6 +551,90 @@ TEST_F('SelectToSpeakNodeUtilsUnitTest', 'getNextParagraph', function() { ...@@ -551,6 +551,90 @@ TEST_F('SelectToSpeakNodeUtilsUnitTest', 'getNextParagraph', function() {
assertEquals(result.length, 0); assertEquals(result.length, 0);
}); });
TEST_F('SelectToSpeakNodeUtilsUnitTest', 'getNextNodesInParagraph', function() {
const root = createMockNode({role: 'rootWebArea'});
createMockNode({role: 'paragraph', display: 'block', parent: root, root});
const paragraph2 =
createMockNode({role: 'paragraph', display: 'block', parent: root, root});
const text1 = createMockNode(
{role: 'staticText', parent: paragraph2, root, name: 'Line 1'});
const text2 = createMockNode(
{role: 'staticText', parent: paragraph2, root, name: 'Line 2'});
const text3 = createMockNode(
{role: 'staticText', parent: paragraph2, root, name: 'Line 3'});
createMockNode({role: 'paragraph', display: 'block', parent: root, root});
let result = NodeUtils.getNextNodesInParagraph(text2, constants.Dir.FORWARD);
assertEquals(result.length, 1);
assertEquals(result[0], text3);
result = NodeUtils.getNextNodesInParagraph(text1, constants.Dir.FORWARD);
assertEquals(result.length, 2);
assertEquals(result[0], text2);
assertEquals(result[1], text3);
result = NodeUtils.getNextNodesInParagraph(text3, constants.Dir.FORWARD);
assertEquals(result.length, 0);
result = NodeUtils.getNextNodesInParagraph(text3, constants.Dir.BACKWARD);
assertEquals(result.length, 2);
assertEquals(result[0], text1);
assertEquals(result[1], text2);
result = NodeUtils.getNextNodesInParagraph(text2, constants.Dir.BACKWARD);
assertEquals(result.length, 1);
assertEquals(result[0], text1);
result = NodeUtils.getNextNodesInParagraph(text1, constants.Dir.BACKWARD);
assertEquals(result.length, 0);
});
TEST_F('SelectToSpeakNodeUtilsUnitTest', 'getAllNodesInParagraph', function() {
const root = createMockNode({role: 'rootWebArea'});
const paragraph1 =
createMockNode({role: 'paragraph', display: 'block', parent: root, root});
const text1 = createMockNode(
{role: 'staticText', parent: paragraph1, root, name: 'Line 1'});
const text2 = createMockNode(
{role: 'staticText', parent: paragraph1, root, name: 'Line 2'});
const paragraph2 =
createMockNode({role: 'paragraph', display: 'block', parent: root, root});
const text3 = createMockNode(
{role: 'staticText', parent: paragraph2, root, name: 'Line 3'});
const text4 = createMockNode(
{role: 'staticText', parent: paragraph2, root, name: 'Line 4'});
const text5 = createMockNode(
{role: 'staticText', parent: paragraph2, root, name: 'Line 5'});
let result = NodeUtils.getAllNodesInParagraph(text1);
assertEquals(result.length, 2);
assertEquals(result[0], text1);
assertEquals(result[1], text2);
result = NodeUtils.getAllNodesInParagraph(text2);
assertEquals(result.length, 2);
assertEquals(result[0], text1);
assertEquals(result[1], text2);
result = NodeUtils.getAllNodesInParagraph(text3);
assertEquals(result.length, 3);
assertEquals(result[0], text3);
assertEquals(result[1], text4);
assertEquals(result[2], text5);
result = NodeUtils.getAllNodesInParagraph(text4);
assertEquals(result.length, 3);
assertEquals(result[0], text3);
assertEquals(result[1], text4);
assertEquals(result[2], text5);
result = NodeUtils.getAllNodesInParagraph(text5);
assertEquals(result.length, 3);
assertEquals(result[0], text3);
assertEquals(result[1], text4);
assertEquals(result[2], text5);
});
/** /**
* Creates a AutomationNode-like object. * Creates a AutomationNode-like object.
* @param {!Object} properties * @param {!Object} properties
......
...@@ -277,6 +277,15 @@ class SelectToSpeak { ...@@ -277,6 +277,15 @@ class SelectToSpeak {
// more items are being read. // more items are being read.
return; return;
} }
if (this.navigationControlFlag_ && nodes.length === 1 &&
nodes[0].role === RoleType.INLINE_TEXT_BOX &&
(rect.width === 0 || rect.height === 0)) {
// If this is a single click (zero sized selection) on a text node, then
// expand to entire paragraph.
// TODO(crbug.com/1143823): Navigate to the sentence instead of whole
// block.
nodes = NodeUtils.getAllNodesInParagraph(nodes[0]);
}
this.startSpeechQueue_(nodes, {clearFocusRing: true}); this.startSpeechQueue_(nodes, {clearFocusRing: true});
MetricsUtils.recordStartEvent( MetricsUtils.recordStartEvent(
MetricsUtils.StartSpeechMethod.MOUSE, this.prefsManager_); MetricsUtils.StartSpeechMethod.MOUSE, this.prefsManager_);
......
...@@ -75,4 +75,17 @@ SelectToSpeakE2ETest = class extends E2ETestBase { ...@@ -75,4 +75,17 @@ SelectToSpeakE2ETest = class extends E2ETestBase {
{keyCode: SelectToSpeak.READ_SELECTION_KEY_CODE}); {keyCode: SelectToSpeak.READ_SELECTION_KEY_CODE});
selectToSpeak.fireMockKeyUpEvent({keyCode: SelectToSpeak.SEARCH_KEY_CODE}); selectToSpeak.fireMockKeyUpEvent({keyCode: SelectToSpeak.SEARCH_KEY_CODE});
} }
/**
* Triggers speech using the search key and clicking with the mouse.
* @param {Object} downEvent The mouse-down event.
* @param {Object} upEvent The mouse-up event.
*/
triggerReadMouseSelectedText(downEvent, upEvent) {
selectToSpeak.fireMockKeyDownEvent(
{keyCode: SelectToSpeak.SEARCH_KEY_CODE});
selectToSpeak.fireMockMouseDownEvent(downEvent);
selectToSpeak.fireMockMouseUpEvent(upEvent);
selectToSpeak.fireMockKeyUpEvent({keyCode: SelectToSpeak.SEARCH_KEY_CODE});
}
}; };
...@@ -16,19 +16,6 @@ SelectToSpeakMouseSelectionTest = class extends SelectToSpeakE2ETest { ...@@ -16,19 +16,6 @@ SelectToSpeakMouseSelectionTest = class extends SelectToSpeakE2ETest {
chrome.tts = this.mockTts; chrome.tts = this.mockTts;
} }
/**
* Triggers speech using the search key and clicking with the mouse.
* @param {Object} downEvent The mouse-down event.
* @param {Object} upEvent The mouse-up event.
*/
selectRangeForSpeech(downEvent, upEvent) {
selectToSpeak.fireMockKeyDownEvent(
{keyCode: SelectToSpeak.SEARCH_KEY_CODE});
selectToSpeak.fireMockMouseDownEvent(downEvent);
selectToSpeak.fireMockMouseUpEvent(upEvent);
selectToSpeak.fireMockKeyUpEvent({keyCode: SelectToSpeak.SEARCH_KEY_CODE});
}
tapTrayButton(desktop, callback) { tapTrayButton(desktop, callback) {
const button = desktop.find({ const button = desktop.find({
roleType: 'button', roleType: 'button',
...@@ -64,7 +51,7 @@ TEST_F('SelectToSpeakMouseSelectionTest', 'SpeaksNodeWhenClicked', function() { ...@@ -64,7 +51,7 @@ TEST_F('SelectToSpeakMouseSelectionTest', 'SpeaksNodeWhenClicked', function() {
screenX: textNode.location.left + 1, screenX: textNode.location.left + 1,
screenY: textNode.location.top + 1 screenY: textNode.location.top + 1
}; };
this.selectRangeForSpeech(event, event); this.triggerReadMouseSelectedText(event, event);
}); });
}); });
...@@ -100,7 +87,7 @@ TEST_F( ...@@ -100,7 +87,7 @@ TEST_F(
screenX: lastNode.location.left + lastNode.location.width, screenX: lastNode.location.left + lastNode.location.width,
screenY: lastNode.location.top + lastNode.location.height screenY: lastNode.location.top + lastNode.location.height
}; };
this.selectRangeForSpeech(downEvent, upEvent); this.triggerReadMouseSelectedText(downEvent, upEvent);
}); });
}); });
...@@ -134,7 +121,7 @@ TEST_F( ...@@ -134,7 +121,7 @@ TEST_F(
screenX: lastNode.location.left + lastNode.location.width, screenX: lastNode.location.left + lastNode.location.width,
screenY: lastNode.location.top + lastNode.location.height screenY: lastNode.location.top + lastNode.location.height
}; };
this.selectRangeForSpeech(downEvent, upEvent); this.triggerReadMouseSelectedText(downEvent, upEvent);
}); });
}); });
...@@ -227,7 +214,7 @@ TEST_F( ...@@ -227,7 +214,7 @@ TEST_F(
screenX: textNode.location.left + 1, screenX: textNode.location.left + 1,
screenY: textNode.location.top + 1 screenY: textNode.location.top + 1
}; };
this.selectRangeForSpeech(event, event); this.triggerReadMouseSelectedText(event, event);
}); });
}); });
......
...@@ -99,3 +99,45 @@ TEST_F( ...@@ -99,3 +99,45 @@ TEST_F(
this.mockTts.pendingUtterances()[0], 'Paragraph 1'); this.mockTts.pendingUtterances()[0], 'Paragraph 1');
}); });
}); });
TEST_F(
'SelectToSpeakNavigationControlTest', 'ReadsParagraphOnClick', function() {
const bodyHtml = `
<p id="p1">Sentence <span>one</span>. Sentence two.</p>
<p id="p2">Paragraph <span>two</span></p>'
`;
this.runWithLoadedTree(bodyHtml, (root) => {
this.mockTts.setOnSpeechCallbacks([this.newCallback((utterance) => {
// Speech for first click.
assertTrue(this.mockTts.currentlySpeaking());
assertEquals(this.mockTts.pendingUtterances().length, 1);
this.assertEqualsCollapseWhitespace(
this.mockTts.pendingUtterances()[0],
'Sentence one . Sentence two.');
this.mockTts.setOnSpeechCallbacks([this.newCallback((utterance) => {
// Speech for second click.
assertTrue(this.mockTts.currentlySpeaking());
assertEquals(this.mockTts.pendingUtterances().length, 1);
this.assertEqualsCollapseWhitespace(
this.mockTts.pendingUtterances()[0], 'Paragraph two');
})]);
// Click on node in second paragraph.
const textNode2 = this.findTextNode(root, 'two');
const mouseEvent2 = {
screenX: textNode2.location.left + 1,
screenY: textNode2.location.top + 1
};
this.triggerReadMouseSelectedText(mouseEvent2, mouseEvent2);
})]);
// Click on node in first paragraph.
const textNode1 = this.findTextNode(root, 'one');
const event1 = {
screenX: textNode1.location.left + 1,
screenY: textNode1.location.top + 1
};
this.triggerReadMouseSelectedText(event1, event1);
});
});
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