Commit 941fec3d authored by Katie D's avatar Katie D Committed by Commit Bot

Refactors STS node processing code into node_utils.js.

select_to_speak.js was getting too messy so this moves
automation node related logic into a separate class.

Bug: 803160
Cq-Include-Trybots: master.tryserver.chromium.linux:closure_compilation
Change-Id: I6867864f5ccefc14f4a5b98a31af5220d16f8259
Reviewed-on: https://chromium-review.googlesource.com/937802
Commit-Queue: Katie Dektar <katie@chromium.org>
Reviewed-by: default avatarDominic Mazzoni <dmazzoni@chromium.org>
Cr-Commit-Position: refs/heads/master@{#539242}
parent 276d2aa7
......@@ -33,6 +33,7 @@ run_jsbundler("select_to_speak_copied_files") {
"checked.png",
"closure_shim.js",
"earcons/null_selection.ogg",
"node_utils.js",
"options.css",
"options.html",
"paragraph_utils.js",
......
......@@ -8,27 +8,28 @@
'dependencies': [
'../chromevox/cvox2/background/constants',
'../chromevox/cvox2/background/automation_util',
'externs',
'externs',
'rect_utils',
'paragraph_utils',
'paragraph_utils',
'word_utils',
'<(EXTERNS_GYP):accessibility_private',
'<(EXTERNS_GYP):automation',
'<(EXTERNS_GYP):chrome_extensions',
'<(EXTERNS_GYP):clipboard',
'<(EXTERNS_GYP):command_line_private',
'<(EXTERNS_GYP):metrics_private',
'node_utils',
'<(EXTERNS_GYP):accessibility_private',
'<(EXTERNS_GYP):automation',
'<(EXTERNS_GYP):chrome_extensions',
'<(EXTERNS_GYP):clipboard',
'<(EXTERNS_GYP):command_line_private',
'<(EXTERNS_GYP):metrics_private',
],
'includes': ['../../../../../third_party/closure_compiler/compile_js2.gypi'],
},
{
'target_name': 'select_to_speak_options',
'dependencies': [
'externs',
'<(EXTERNS_GYP):accessibility_private',
'<(EXTERNS_GYP):automation',
'<(EXTERNS_GYP):chrome_extensions',
'<(EXTERNS_GYP):metrics_private',
'externs',
'<(EXTERNS_GYP):accessibility_private',
'<(EXTERNS_GYP):automation',
'<(EXTERNS_GYP):chrome_extensions',
'<(EXTERNS_GYP):metrics_private',
],
'includes': ['../../../../../third_party/closure_compiler/compile_js2.gypi'],
},
......@@ -36,22 +37,32 @@
'target_name': 'externs',
'includes': ['../../../../../third_party/closure_compiler/compile_js2.gypi'],
},
{
'target_name': 'node_utils',
'dependencies': [
'externs',
'rect_utils',
'paragraph_utils',
'<(EXTERNS_GYP):automation',
],
'includes': ['../../../../../third_party/closure_compiler/compile_js2.gypi'],
},
{
'target_name': 'word_utils',
'dependencies': [
'externs',
'externs',
'paragraph_utils',
'<(EXTERNS_GYP):automation',
'<(EXTERNS_GYP):automation',
],
'includes': ['../../../../../third_party/closure_compiler/compile_js2.gypi'],
},
{
'target_name': 'paragraph_utils',
'dependencies': [
'externs',
'<(EXTERNS_GYP):accessibility_private',
'<(EXTERNS_GYP):automation',
'<(EXTERNS_GYP):chrome_extensions',
'externs',
'<(EXTERNS_GYP):accessibility_private',
'<(EXTERNS_GYP):automation',
'<(EXTERNS_GYP):chrome_extensions',
],
'includes': ['../../../../../third_party/closure_compiler/compile_js2.gypi'],
},
......@@ -62,30 +73,30 @@
{
'target_name': '../chromevox/cvox2/background/automation_util',
'dependencies': [
'../chromevox/cvox2/background/automation_predicate',
'../chromevox/cvox2/background/tree_walker',
'../chromevox/cvox2/background/constants',
'<(EXTERNS_GYP):automation',
'<(EXTERNS_GYP):chrome_extensions',
'../chromevox/cvox2/background/automation_predicate',
'../chromevox/cvox2/background/tree_walker',
'../chromevox/cvox2/background/constants',
'<(EXTERNS_GYP):automation',
'<(EXTERNS_GYP):chrome_extensions',
],
'includes': ['../../../../../third_party/closure_compiler/compile_js2.gypi'],
},
{
'target_name': '../chromevox/cvox2/background/tree_walker',
'dependencies': [
'../chromevox/cvox2/background/automation_predicate',
'../chromevox/cvox2/background/constants',
'<(EXTERNS_GYP):automation',
'<(EXTERNS_GYP):chrome_extensions',
'../chromevox/cvox2/background/automation_predicate',
'../chromevox/cvox2/background/constants',
'<(EXTERNS_GYP):automation',
'<(EXTERNS_GYP):chrome_extensions',
],
'includes': ['../../../../../third_party/closure_compiler/compile_js2.gypi'],
},
{
'target_name': '../chromevox/cvox2/background/automation_predicate',
'dependencies': [
'../chromevox/cvox2/background/constants',
'<(EXTERNS_GYP):automation',
'<(EXTERNS_GYP):chrome_extensions',
'../chromevox/cvox2/background/constants',
'<(EXTERNS_GYP):automation',
'<(EXTERNS_GYP):chrome_extensions',
],
'includes': ['../../../../../third_party/closure_compiler/compile_js2.gypi'],
},
......
......@@ -16,6 +16,7 @@
"automation_predicate.js",
"tree_walker.js",
"automation_util.js",
"node_utils.js",
"paragraph_utils.js",
"word_utils.js",
"rect_utils.js",
......
// Copyright 2018 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.
// Utility functions for automation nodes in Select-to-Speak.
/**
* Node state. Nodes can be on-screen like normal, or they may
* be invisible if they are in a tab that is not in the foreground
* or similar, or they may be invalid if they were removed from their
* root, i.e. if they were in a window that was closed.
* @enum {number}
*/
const NodeState = {
NODE_STATE_INVALID: 0,
NODE_STATE_INVISIBLE: 1,
NODE_STATE_NORMAL: 2,
};
/**
* Gets the current visiblity state for a given node.
*
* @param {AutomationNode} node The starting node.
* @return {NodeState} the current node state.
*/
function getNodeState(node) {
if (node === undefined || node.root === null || node.root === undefined) {
// The node has been removed from the tree, perhaps because the
// window was closed.
return NodeState.NODE_STATE_INVALID;
}
// This might not be populated correctly on children nodes even if their
// parents or roots are now invisible.
// TODO: Update the C++ bindings to set 'invisible' automatically based
// on parents, rather than going through parents in JS below.
if (node.state.invisible) {
return NodeState.NODE_STATE_INVISIBLE;
}
// Walk up the tree to make sure the window it is in is not invisible.
var window = getNearestContainingWindow(node);
if (window != null && window.state[chrome.automation.StateType.INVISIBLE]) {
return NodeState.NODE_STATE_INVISIBLE;
}
// TODO: Also need a check for whether the window is minimized,
// which would also return NodeState.NODE_STATE_INVISIBLE.
return NodeState.NODE_STATE_NORMAL;
}
/**
* Returns true if a node should be ignored by Select-to-Speak.
* @param {AutomationNode} node The node to test
* @param {boolean} includeOffscreen Whether to include offscreen nodes.
* @return {boolean} whether this node should be ignored.
*/
function shouldIgnoreNode(node, includeOffscreen) {
return (
!node.name || isWhitespace(node.name) || !node.location ||
node.state.invisible || (node.state.offscreen && !includeOffscreen));
}
/**
* Gets the first window containing this node.
*/
function getNearestContainingWindow(node) {
// Go upwards to root nodes' parents until we find the first window.
if (node.root.role == RoleType.ROOT_WEB_AREA) {
var nextRootParent = node;
while (nextRootParent != null && nextRootParent.role != RoleType.WINDOW &&
nextRootParent.root != null &&
nextRootParent.root.role == RoleType.ROOT_WEB_AREA) {
nextRootParent = nextRootParent.root.parent;
}
return nextRootParent;
}
// If the parent isn't a root web area, just walk up the tree to find the
// nearest window.
var parent = node;
while (parent != null && parent.role != chrome.automation.RoleType.WINDOW) {
parent = parent.parent;
}
return parent;
}
/**
* Gets the length of a node's name. Returns 0 if the name is
* undefined.
* @param {AutomationNode} node The node for which to check the name.
* @return {number} The length of the node's name
*/
function nameLength(node) {
return node.name ? node.name.length : 0;
}
/**
* Gets the first (left-most) leaf node of a node. Returns undefined if
* none is found.
* @param {AutomationNode} node The node to search for the first leaf.
* @return {AutomationNode|undefined} The leaf node.
*/
function getFirstLeafChild(node) {
let result = node.firstChild;
while (result && result.firstChild) {
result = result.firstChild;
}
return result;
}
/**
* Gets the first (left-most) leaf node of a node. Returns undefined
* if none is found.
* @param {AutomationNode} node The node to search for the first leaf.
* @return {AutomationNode|undefined} The leaf node.
*/
function getLastLeafChild(node) {
let result = node.lastChild;
while (result && result.lastChild) {
result = result.lastChild;
}
return result;
}
/**
* Finds all nodes within the subtree rooted at |node| that overlap
* a given rectangle.
* @param {AutomationNode} node The starting node.
* @param {{left: number, top: number, width: number, height: number}} rect
* The bounding box to search.
* @param {Array<AutomationNode>} nodes The matching node array to be
* populated.
* @return {boolean} True if any matches are found.
*/
function findAllMatching(node, rect, nodes) {
var found = false;
for (var c = node.firstChild; c; c = c.nextSibling) {
if (findAllMatching(c, rect, nodes))
found = true;
}
if (found)
return true;
// Closure needs node.location check here to allow the next few
// lines to compile.
if (shouldIgnoreNode(node, /* don't include offscreen */ false) ||
node.location === undefined)
return false;
if (overlaps(node.location, rect)) {
if (!node.children || node.children.length == 0 ||
node.children[0].role != RoleType.INLINE_TEXT_BOX) {
// Only add a node if it has no inlineTextBox children. If
// it has text children, they will be more precisely bounded
// and specific, so no need to add the parent node.
nodes.push(node);
return true;
}
}
return false;
}
/**
* Class representing a position on the accessibility, made of a
* selected node and the offset of that selection.
* @typedef {{node: (!AutomationNode),
* offset: (number)}}
*/
var Position;
/**
* Finds the deep equivalent node where a selection starts given a node
* object and selection offset. This is meant to be used in conjunction with
* the anchorObject/anchorOffset and focusObject/focusOffset of the
* automation API.
* @param {AutomationNode} parent The parent node of the selection,
* similar to chrome.automation.focusObject.
* @param {number} offset The integer offset of the selection. This is
* similar to chrome.automation.focusOffset.
* @return {!Position} The node matching the selected offset.
*/
function getDeepEquivalentForSelection(parent, offset) {
if (parent.children.length == 0)
return {node: parent, offset: offset};
// Create a stack of children nodes to search through.
let nodesToCheck = parent.children.slice().reverse();
let index = 0;
var node;
// Delve down into the children recursively to find the
// one at this offset.
while (nodesToCheck.length > 0) {
node = nodesToCheck.pop();
if (node.children.length > 0) {
nodesToCheck = nodesToCheck.concat(node.children.slice().reverse());
} else {
index += node.name ? node.name.length : 0;
if (index > offset) {
return {node: node, offset: offset - index + node.name.length};
}
}
}
// We are off the end of the last node.
return {node: node, offset: node.name ? node.name.length : 0};
}
// Copyright 2016 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.
/**
* Test fixture for node_utils.js.
* @constructor
* @extends {testing.Test}
*/
function SelectToSpeakNodeUtilsUnitTest () {
testing.Test.call(this);
}
SelectToSpeakNodeUtilsUnitTest.prototype = {
__proto__: testing.Test.prototype,
/** @override */
extraLibraries: [
'test_support.js',
'paragraph_utils.js',
'node_utils.js',
'word_utils.js',
'rect_utils.js',
]
};
TEST_F('SelectToSpeakNodeUtilsUnitTest', 'GetNodeVisibilityState', function() {
let nodeWithoutRoot1 = {root: null};
let nodeWithoutRoot2 = {root: null, state: {invisible: true}};
assertEquals(getNodeState(nodeWithoutRoot1), NodeState.NODE_STATE_INVALID);
assertEquals(getNodeState(nodeWithoutRoot2), NodeState.NODE_STATE_INVALID);
let invisibleNode1 = {root: {}, parent: {role: ''}, state: {invisible: true}};
// Currently nodes aren't actually marked 'invisible', so we need to navigate
// up their tree.
let invisibleNode2 = {root: {}, parent: {role: 'window',
state: {invisible: true}}, state: {}};
let invisibleNode3 = {root: {}, parent: invisibleNode2, state: {}};
let invisibleNode4 = {root: {}, parent: invisibleNode3, state: {}};
assertEquals(getNodeState(invisibleNode1), NodeState.NODE_STATE_INVISIBLE);
assertEquals(getNodeState(invisibleNode2), NodeState.NODE_STATE_INVISIBLE);
assertEquals(getNodeState(invisibleNode3), NodeState.NODE_STATE_INVISIBLE);
let normalNode1 = {root: {}, parent: {role: 'window', state: {}}, state: {}};
let normalNode2 = {root: {}, parent: {normalNode1}, state: {}};
assertEquals(getNodeState(normalNode1), NodeState.NODE_STATE_NORMAL);
assertEquals(getNodeState(normalNode2), NodeState.NODE_STATE_NORMAL);
});
TEST_F('SelectToSpeakNodeUtilsUnitTest',
'GetNodeVisibilityStateWithRootWebArea', function() {
// Currently nodes aren't actually marked 'invisible', so we need to navigate
// up their tree.
let window = {root: {}, role: 'window', state: {invisible: true}};
let rootNode = {root: {}, parent: window, state: {}, role: 'rootWebArea'};
let container = {root: rootNode, parent: rootNode, state: {}};
let node = {root: rootNode, parent: container, state: {}};
assertEquals(getNodeState(window), NodeState.NODE_STATE_INVISIBLE);
assertEquals(getNodeState(container), NodeState.NODE_STATE_INVISIBLE);
assertEquals(getNodeState(node), NodeState.NODE_STATE_INVISIBLE);
// Make a fake iframe in this invisible window by adding another RootWebArea.
// The iframe has no root but is parented to the container above.
let iframeRoot = {parent: container, state: {}, role: 'rootWebArea'};
let iframeContainer = {root: iframeRoot, parent: iframeRoot, state: {}};
let iframeNode = {root: iframeRoot, parent: iframeContainer, state: {}};
assertEquals(getNodeState(iframeContainer), NodeState.NODE_STATE_INVISIBLE);
assertEquals(getNodeState(iframeNode), NodeState.NODE_STATE_INVISIBLE);
// Make the window visible and try again.
window.state = {};
assertEquals(getNodeState(window), NodeState.NODE_STATE_NORMAL);
assertEquals(getNodeState(container), NodeState.NODE_STATE_NORMAL);
assertEquals(getNodeState(node), NodeState.NODE_STATE_NORMAL);
assertEquals(getNodeState(iframeContainer), NodeState.NODE_STATE_NORMAL);
assertEquals(getNodeState(iframeNode), NodeState.NODE_STATE_NORMAL);
});
TEST_F('SelectToSpeakNodeUtilsUnitTest', 'findAllMatching', function() {
let rect = {left: 0, top: 0, width: 100, height: 100};
let rootNode = {root: {}, state: {}, role: 'rootWebArea', state: {},
location: {left: 0, top: 0, width: 600, height: 600}};
let container1 = {root: rootNode, parent: rootNode, role: 'staticText',
name: 'one two', state: {},
location: {left: 0, top: 0, width: 200, height: 200}};
let container2 = {root: rootNode, parent: rootNode, state: {},
role: 'genericContainer',
location: {left: 0, top: 0, width: 200, height: 200}};
let node1 = {root: rootNode, parent: container1, name: 'one',
role: 'inlineTextBox', state: {},
location: {left: 50, top: 0, width: 50, height: 50}};
let node2 = {root: rootNode, parent: container1, name: 'two',
role: 'inlineTextBox', state: {},
location: {left: 0, top: 50, width: 50, height: 50}};
// Set up relationships between nodes.
rootNode.children = [container1, container2];
rootNode.firstChild = container1;
container1.nextSibling = container2;
container1.children = [node1, node2];
container1.firstChild = node1;
node1.nextSibling = node2;
// Should get both children of the first container, without getting
// the first container itself or the empty container.
let result = [];
assertTrue(findAllMatching(rootNode, rect, result));
assertEquals(2, result.length);
assertEquals(node1, result[0]);
assertEquals(node2, result[1]);
// If a node doesn't have a name, it should not be included.
result = [];
node2.name = undefined;
assertTrue(findAllMatching(rootNode, rect, result));
assertEquals(1, result.length);
assertEquals(node1, result[0]);
// Try a rect that only overlaps one of the children.
result = [];
node2.name = 'two';
rect.height = 25;
assertTrue(findAllMatching(rootNode, rect, result));
assertEquals(1, result.length);
assertEquals(node1, result[0]);
// Now just overlap a different child.
result = [];
rect.top = 50;
assertTrue(findAllMatching(rootNode, rect, result));
assertEquals(1, result.length);
assertEquals(node2, result[0]);
// Offscreen should cause a node to be skipped.
result = [];
node2.state = {offscreen: true}
assertFalse(findAllMatching(rootNode, rect, result));
assertEquals(0, result.length);
// No location should cause a node to be skipped.
result = [];
node2.state = {};
node2.location = undefined;
assertFalse(findAllMatching(rootNode, rect, result));
// A non staticText container without a name should still have
// children found.
result = [];
let node3 = {root: rootNode, parent: container2, name: 'three',
state: {}, location: {left: 0, top: 50, width: 50, height: 50}};
container2.firstChild = node3;
assertTrue(findAllMatching(rootNode, rect, result));
assertEquals(1, result.length);
assertEquals(node3, result[0]);
// A non staticText container with a valid name should not be
// read if its children are read. Children take precidence.
result = [];
container2.name = 'container2';
assertTrue(findAllMatching(rootNode, rect, result));
assertEquals(1, result.length);
assertEquals(node3, result[0]);
// A non staticText container with a valid name which has only
// children without names should be read instead of its children.
result = [];
node3.name = undefined;
assertTrue(findAllMatching(rootNode, rect, result));
assertEquals(1, result.length);
assertEquals(container2, result[0]);
});
TEST_F('SelectToSpeakNodeUtilsUnitTest',
'getDeepEquivalentForSelectionNoChildren', function() {
let node = {name: 'Hello, world', children: []};
let result = getDeepEquivalentForSelection(node, 0);
assertEquals(node, result.node);
assertEquals(0, result.offset);
result = getDeepEquivalentForSelection(node, 6);
assertEquals(node, result.node);
assertEquals(6, result.offset);
});
TEST_F('SelectToSpeakNodeUtilsUnitTest', 'getDeepEquivalentForSelectionSimpleChildren',
function() {
let child1 = {name: 'Hello,', children: []};
let child2 = {name: ' world', children: []};
let root = {name: 'Hello, world', children: [child1, child2]};
let result = getDeepEquivalentForSelection(root, 0);
assertEquals(child1, result.node);
assertEquals(0, result.offset);
result = getDeepEquivalentForSelection(root, 5);
assertEquals(child1, result.node);
assertEquals(5, result.offset);
result = getDeepEquivalentForSelection(root, 6);
assertEquals(child2, result.node);
assertEquals(0, result.offset);
result = getDeepEquivalentForSelection(root, 9);
assertEquals(child2, result.node);
assertEquals(3, result.offset);
});
TEST_F('SelectToSpeakNodeUtilsUnitTest',
'getDeepEquivalentForSelectionComplexChildren', function() {
let child1 = {name: 'Hello', children: []};
let child2 = {name: undefined, children: []}; // Empty name
let child3 = {name: ',', children: []};
let child4 = {name: 'Hello,', children: [child1, child2, child3]};
let child5 = {name: ' ', children: []};
let child6 = {name: 'world', children: []};
let child7 = {name: ' world', children: [child5, child6]};
let root = {name: 'Hello, world', children: [child4, child7]};
let result = getDeepEquivalentForSelection(root, 0);
assertEquals(child1, result.node);
assertEquals(0, result.offset);
result = getDeepEquivalentForSelection(root, 5);
assertEquals(child3, result.node);
assertEquals(0, result.offset);
result = getDeepEquivalentForSelection(root, 6);
assertEquals(child5, result.node);
assertEquals(0, result.offset);
result = getDeepEquivalentForSelection(root, 9);
assertEquals(child6, result.node);
assertEquals(2, result.offset);
});
\ No newline at end of file
......@@ -24,166 +24,6 @@ const CLIPBOARD_READ_MAX_DELAY_MS = 1000;
const DRIVE_APP_REGEXP =
/^https:\/\/docs\.(?:sandbox\.)?google\.com\/(?:(?:presentation)|(?:document)|(?:spreadsheets)|(?:drawings)){1}\//;
/**
* Node state. Nodes can be on-screen like normal, or they may
* be invisible if they are in a tab that is not in the foreground
* or similar, or they may be invalid if they were removed from their
* root, i.e. if they were in a window that was closed.
* @enum {number}
*/
const NodeState = {
NODE_STATE_INVALID: 0,
NODE_STATE_INVISIBLE: 1,
NODE_STATE_NORMAL: 2,
};
/**
* Gets the first window containing this node.
*/
function getNearestContainingWindow(node) {
// Go upwards to root nodes' parents until we find the first window.
if (node.root.role == RoleType.ROOT_WEB_AREA) {
var nextRootParent = node;
while (nextRootParent != null && nextRootParent.role != RoleType.WINDOW &&
nextRootParent.root != null &&
nextRootParent.root.role == RoleType.ROOT_WEB_AREA) {
nextRootParent = nextRootParent.root.parent;
}
return nextRootParent;
}
// If the parent isn't a root web area, just walk up the tree to find the
// nearest window.
var parent = node;
while (parent != null && parent.role != chrome.automation.RoleType.WINDOW) {
parent = parent.parent;
}
return parent;
}
/**
* Gets the current visiblity state for a given node.
*
* @param {AutomationNode} node The starting node.
* @return {NodeState} the current node state.
*/
function getNodeState(node) {
if (node === undefined || node.root === null || node.root === undefined) {
// The node has been removed from the tree, perhaps because the
// window was closed.
return NodeState.NODE_STATE_INVALID;
}
// This might not be populated correctly on children nodes even if their
// parents or roots are now invisible.
// TODO: Update the C++ bindings to set 'invisible' automatically based
// on parents, rather than going through parents in JS below.
if (node.state.invisible) {
return NodeState.NODE_STATE_INVISIBLE;
}
// Walk up the tree to make sure the window it is in is not invisible.
var window = getNearestContainingWindow(node);
if (window != null && window.state[chrome.automation.StateType.INVISIBLE]) {
return NodeState.NODE_STATE_INVISIBLE;
}
// TODO: Also need a check for whether the window is minimized,
// which would also return NodeState.NODE_STATE_INVISIBLE.
return NodeState.NODE_STATE_NORMAL;
}
/**
* Returns true if a node should be ignored by Select-to-Speak.
* @param {AutomationNode} node The node to test
* @param {boolean} includeOffscreen Whether to include offscreen nodes.
* @return {boolean} whether this node should be ignored.
*/
function shouldIgnoreNode(node, includeOffscreen) {
return (
!node.name || isWhitespace(node.name) || !node.location ||
node.state.invisible || (node.state.offscreen && !includeOffscreen));
}
/**
* Finds all nodes within the subtree rooted at |node| that overlap
* a given rectangle.
* @param {AutomationNode} node The starting node.
* @param {{left: number, top: number, width: number, height: number}} rect
* The bounding box to search.
* @param {Array<AutomationNode>} nodes The matching node array to be
* populated.
* @return {boolean} True if any matches are found.
*/
function findAllMatching(node, rect, nodes) {
var found = false;
for (var c = node.firstChild; c; c = c.nextSibling) {
if (findAllMatching(c, rect, nodes))
found = true;
}
if (found)
return true;
// Closure needs node.location check here to allow the next few
// lines to compile.
if (shouldIgnoreNode(node, /* don't include offscreen */ false) ||
node.location === undefined)
return false;
if (overlaps(node.location, rect)) {
if (!node.children || node.children.length == 0 ||
node.children[0].role != RoleType.INLINE_TEXT_BOX) {
// Only add a node if it has no inlineTextBox children. If
// it has text children, they will be more precisely bounded
// and specific, so no need to add the parent node.
nodes.push(node);
return true;
}
}
return false;
}
/**
* Class representing a position on the accessibility, made of a
* selected node and the offset of that selection.
* @typedef {{node: (!AutomationNode),
* offset: (number)}}
*/
var Position;
/**
* Finds the deep equivalent node where a selection starts given a node
* object and selection offset. This is meant to be used in conjunction with
* the anchorObject/anchorOffset and focusObject/focusOffset of the
* automation API.
* @param {AutomationNode} parent The parent node of the selection,
* similar to chrome.automation.focusObject.
* @param {number} offset The integer offset of the selection. This is
* similar to chrome.automation.focusOffset.
* @return {!Position} The node matching the selected offset.
*/
function getDeepEquivalentForSelection(parent, offset) {
if (parent.children.length == 0)
return {node: parent, offset: offset};
// Create a stack of children nodes to search through.
let nodesToCheck = parent.children.slice().reverse();
let index = 0;
var node;
// Delve down into the children recursively to find the
// one at this offset.
while (nodesToCheck.length > 0) {
node = nodesToCheck.pop();
if (node.children.length > 0) {
nodesToCheck = nodesToCheck.concat(node.children.slice().reverse());
} else {
index += node.name ? node.name.length : 0;
if (index > offset) {
return {node: node, offset: offset - index + node.name.length};
}
}
}
// We are off the end of the last node.
return {node: node, offset: node.name ? node.name.length : 0};
}
/**
* Determines if a node is in one of the known Google Drive apps that needs
* special case treatment for speaking selected text. Not all Google Drive pages
......@@ -535,7 +375,8 @@ SelectToSpeak.prototype = {
},
/**
* Queues up selected text for reading.
* Queues up selected text for reading by finding the Position objects
* representing the selection.
*/
requestSpeakSelectedText_: function(focusedNode) {
// If nothing is selected, return early.
......@@ -552,6 +393,16 @@ SelectToSpeak.prototype = {
this.onNullSelection_();
return;
}
// First calculate the equivalant position for this selection.
// Sometimes the automation selection returns a offset into a root
// node rather than a child node, which may be a bug. This allows us to
// work around that bug until it is fixed or redefined.
// Note that this calculation is imperfect: it uses node name length
// to index into child nodes. However, not all node names are
// user-visible text, so this does not always work. Instead, we must
// fix the Blink bug where focus offset is not specific enough to
// say which node is selected and at what charOffset. See
// https://crbug.com/803160 for more.
let anchorPosition =
getDeepEquivalentForSelection(anchorObject, anchorOffset);
let focusPosition = getDeepEquivalentForSelection(focusObject, focusOffset);
......@@ -589,7 +440,17 @@ SelectToSpeak.prototype = {
lastPosition.offset =
lastPosition.node.name ? lastPosition.node.name.length : 0;
}
this.readNodesInSelection_(firstPosition, lastPosition, focusedNode);
},
/**
* Reads nodes between the first and last position selected by the user.
* @param {Position} firstPosition The first position at which to start
* reading.
* @param {Position} lastPosition The last position at which to stop reading.
* @param {AutomationNode} focusedNode The node with user focus.
*/
readNodesInSelection_: function(firstPosition, lastPosition, focusedNode) {
let nodes = [];
let selectedNode = firstPosition.node;
if (selectedNode.name && firstPosition.offset < selectedNode.name.length &&
......@@ -653,6 +514,11 @@ SelectToSpeak.prototype = {
this.recordStartEvent_(START_SPEECH_METHOD_KEYSTROKE);
},
/**
* Gets ready to cancel future scrolling to offscreen nodes as soon as
* a user-initiated scroll is done.
* @param {AutomationNode=} root The root node to listen for events on.
*/
initializeScrollingToOffscreenNodes_: function(root) {
this.scrollToSpokenNode_ = true;
let listener = (event) => {
......
......@@ -196,4 +196,4 @@ TEST_F('SelectToSpeakKeystrokeSelectionTest',
focusObject: lastNode, focusOffset: 5
});
}, 'This is some bold text');
});
\ No newline at end of file
});
......@@ -17,223 +17,10 @@ SelectToSpeakUnitTest.prototype = {
/** @override */
extraLibraries: [
'test_support.js',
'paragraph_utils.js',
'word_utils.js',
'rect_utils.js',
'select_to_speak.js'
]
};
TEST_F('SelectToSpeakUnitTest', 'GetNodeVisibilityState', function() {
let nodeWithoutRoot1 = {root: null};
let nodeWithoutRoot2 = {root: null, state: {invisible: true}};
assertEquals(getNodeState(nodeWithoutRoot1), NodeState.NODE_STATE_INVALID);
assertEquals(getNodeState(nodeWithoutRoot2), NodeState.NODE_STATE_INVALID);
let invisibleNode1 = {root: {}, parent: {role: ''}, state: {invisible: true}};
// Currently nodes aren't actually marked 'invisible', so we need to navigate
// up their tree.
let invisibleNode2 = {root: {}, parent: {role: 'window',
state: {invisible: true}}, state: {}};
let invisibleNode3 = {root: {}, parent: invisibleNode2, state: {}};
let invisibleNode4 = {root: {}, parent: invisibleNode3, state: {}};
assertEquals(getNodeState(invisibleNode1), NodeState.NODE_STATE_INVISIBLE);
assertEquals(getNodeState(invisibleNode2), NodeState.NODE_STATE_INVISIBLE);
assertEquals(getNodeState(invisibleNode3), NodeState.NODE_STATE_INVISIBLE);
let normalNode1 = {root: {}, parent: {role: 'window', state: {}}, state: {}};
let normalNode2 = {root: {}, parent: {normalNode1}, state: {}};
assertEquals(getNodeState(normalNode1), NodeState.NODE_STATE_NORMAL);
assertEquals(getNodeState(normalNode2), NodeState.NODE_STATE_NORMAL);
});
TEST_F('SelectToSpeakUnitTest', 'GetNodeVisibilityStateWithRootWebArea',
function() {
// Currently nodes aren't actually marked 'invisible', so we need to navigate
// up their tree.
let window = {root: {}, role: 'window', state: {invisible: true}};
let rootNode = {root: {}, parent: window, state: {}, role: 'rootWebArea'};
let container = {root: rootNode, parent: rootNode, state: {}};
let node = {root: rootNode, parent: container, state: {}};
assertEquals(getNodeState(window), NodeState.NODE_STATE_INVISIBLE);
assertEquals(getNodeState(container), NodeState.NODE_STATE_INVISIBLE);
assertEquals(getNodeState(node), NodeState.NODE_STATE_INVISIBLE);
// Make a fake iframe in this invisible window by adding another RootWebArea.
// The iframe has no root but is parented to the container above.
let iframeRoot = {parent: container, state: {}, role: 'rootWebArea'};
let iframeContainer = {root: iframeRoot, parent: iframeRoot, state: {}};
let iframeNode = {root: iframeRoot, parent: iframeContainer, state: {}};
assertEquals(getNodeState(iframeContainer), NodeState.NODE_STATE_INVISIBLE);
assertEquals(getNodeState(iframeNode), NodeState.NODE_STATE_INVISIBLE);
// Make the window visible and try again.
window.state = {};
assertEquals(getNodeState(window), NodeState.NODE_STATE_NORMAL);
assertEquals(getNodeState(container), NodeState.NODE_STATE_NORMAL);
assertEquals(getNodeState(node), NodeState.NODE_STATE_NORMAL);
assertEquals(getNodeState(iframeContainer), NodeState.NODE_STATE_NORMAL);
assertEquals(getNodeState(iframeNode), NodeState.NODE_STATE_NORMAL);
});
TEST_F('SelectToSpeakUnitTest', 'findAllMatching', function() {
let rect = {left: 0, top: 0, width: 100, height: 100};
let rootNode = {root: {}, state: {}, role: 'rootWebArea', state: {},
location: {left: 0, top: 0, width: 600, height: 600}};
let container1 = {root: rootNode, parent: rootNode, role: 'staticText',
name: 'one two', state: {},
location: {left: 0, top: 0, width: 200, height: 200}};
let container2 = {root: rootNode, parent: rootNode, state: {},
role: 'genericContainer',
location: {left: 0, top: 0, width: 200, height: 200}};
let node1 = {root: rootNode, parent: container1, name: 'one',
role: 'inlineTextBox', state: {},
location: {left: 50, top: 0, width: 50, height: 50}};
let node2 = {root: rootNode, parent: container1, name: 'two',
role: 'inlineTextBox', state: {},
location: {left: 0, top: 50, width: 50, height: 50}};
// Set up relationships between nodes.
rootNode.children = [container1, container2];
rootNode.firstChild = container1;
container1.nextSibling = container2;
container1.children = [node1, node2];
container1.firstChild = node1;
node1.nextSibling = node2;
// Should get both children of the first container, without getting
// the first container itself or the empty container.
let result = [];
assertTrue(findAllMatching(rootNode, rect, result));
assertEquals(2, result.length);
assertEquals(node1, result[0]);
assertEquals(node2, result[1]);
// If a node doesn't have a name, it should not be included.
result = [];
node2.name = undefined;
assertTrue(findAllMatching(rootNode, rect, result));
assertEquals(1, result.length);
assertEquals(node1, result[0]);
// Try a rect that only overlaps one of the children.
result = [];
node2.name = 'two';
rect.height = 25;
assertTrue(findAllMatching(rootNode, rect, result));
assertEquals(1, result.length);
assertEquals(node1, result[0]);
// Now just overlap a different child.
result = [];
rect.top = 50;
assertTrue(findAllMatching(rootNode, rect, result));
assertEquals(1, result.length);
assertEquals(node2, result[0]);
// Offscreen should cause a node to be skipped.
result = [];
node2.state = {offscreen: true}
assertFalse(findAllMatching(rootNode, rect, result));
assertEquals(0, result.length);
// No location should cause a node to be skipped.
result = [];
node2.state = {};
node2.location = undefined;
assertFalse(findAllMatching(rootNode, rect, result));
// A non staticText container without a name should still have
// children found.
result = [];
let node3 = {root: rootNode, parent: container2, name: 'three',
state: {}, location: {left: 0, top: 50, width: 50, height: 50}};
container2.firstChild = node3;
assertTrue(findAllMatching(rootNode, rect, result));
assertEquals(1, result.length);
assertEquals(node3, result[0]);
// A non staticText container with a valid name should not be
// read if its children are read. Children take precidence.
result = [];
container2.name = 'container2';
assertTrue(findAllMatching(rootNode, rect, result));
assertEquals(1, result.length);
assertEquals(node3, result[0]);
// A non staticText container with a valid name which has only
// children without names should be read instead of its children.
result = [];
node3.name = undefined;
assertTrue(findAllMatching(rootNode, rect, result));
assertEquals(1, result.length);
assertEquals(container2, result[0]);
});
TEST_F('SelectToSpeakUnitTest', 'getDeepEquivalentForSelectionNoChildren',
function() {
let node = {name: 'Hello, world', children: []};
let result = getDeepEquivalentForSelection(node, 0);
assertEquals(node, result.node);
assertEquals(0, result.offset);
result = getDeepEquivalentForSelection(node, 6);
assertEquals(node, result.node);
assertEquals(6, result.offset);
});
TEST_F('SelectToSpeakUnitTest', 'getDeepEquivalentForSelectionSimpleChildren',
function() {
let child1 = {name: 'Hello,', children: []};
let child2 = {name: ' world', children: []};
let root = {name: 'Hello, world', children: [child1, child2]};
let result = getDeepEquivalentForSelection(root, 0);
assertEquals(child1, result.node);
assertEquals(0, result.offset);
result = getDeepEquivalentForSelection(root, 5);
assertEquals(child1, result.node);
assertEquals(5, result.offset);
result = getDeepEquivalentForSelection(root, 6);
assertEquals(child2, result.node);
assertEquals(0, result.offset);
result = getDeepEquivalentForSelection(root, 9);
assertEquals(child2, result.node);
assertEquals(3, result.offset);
});
TEST_F('SelectToSpeakUnitTest', 'getDeepEquivalentForSelectionComplexChildren',
function() {
let child1 = {name: 'Hello', children: []};
let child2 = {name: undefined, children: []}; // Empty name
let child3 = {name: ',', children: []};
let child4 = {name: 'Hello,', children: [child1, child2, child3]};
let child5 = {name: ' ', children: []};
let child6 = {name: 'world', children: []};
let child7 = {name: ' world', children: [child5, child6]};
let root = {name: 'Hello, world', children: [child4, child7]};
let result = getDeepEquivalentForSelection(root, 0);
assertEquals(child1, result.node);
assertEquals(0, result.offset);
result = getDeepEquivalentForSelection(root, 5);
assertEquals(child3, result.node);
assertEquals(0, result.offset);
result = getDeepEquivalentForSelection(root, 6);
assertEquals(child5, result.node);
assertEquals(0, result.offset);
result = getDeepEquivalentForSelection(root, 9);
assertEquals(child6, result.node);
assertEquals(2, result.offset);
});
TEST_F('SelectToSpeakUnitTest', 'getDriveAppRoot', function() {
let root = {url: 'https://docs.google.com/presentation/p/cats_r_awesome'};
let div1 = {root: root};
......
......@@ -165,6 +165,7 @@ js2gtest("unit_tests_js") {
if (is_chromeos) {
sources += [
"../../../browser/resources/chromeos/braille_ime/braille_ime_unittest.gtestjs",
"../../../browser/resources/chromeos/select_to_speak/node_utils_unittest.gtestjs",
"../../../browser/resources/chromeos/select_to_speak/paragraph_utils_unittest.gtestjs",
"../../../browser/resources/chromeos/select_to_speak/rect_utils_unittest.gtestjs",
"../../../browser/resources/chromeos/select_to_speak/select_to_speak_unittest.gtestjs",
......@@ -177,6 +178,7 @@ js2gtest("unit_tests_js") {
"../../../browser/resources/chromeos/select_to_speak/select_to_speak.js",
"../../../browser/resources/chromeos/select_to_speak/test_support.js",
"../../../browser/resources/chromeos/select_to_speak/word_utils.js",
"../../../browser/resources/chromeos/select_to_speak/node_utils.js",
]
}
}
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