blob: 438858370f7791c13c8f45f58492388c0da3d697 [file] [log] [blame]
// Copyright 2017 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.
var AutomationNode = chrome.automation.AutomationNode;
var RoleType = chrome.automation.RoleType;
/**
* @constructor
*/
let ParagraphUtils = function() {};
/**
* Gets the first ancestor of a node which is a paragraph or is not inline,
* or get the root node if none is found.
* @param {AutomationNode} node The node to get the parent for.
* @return {?AutomationNode} the parent paragraph or null if there is none.
*/
ParagraphUtils.getFirstBlockAncestor = function(node) {
let parent = node.parent;
let root = node.root;
while (parent != null) {
if (parent == root) {
return parent;
}
if (parent.role == RoleType.PARAGRAPH || parent.role == RoleType.SVG_ROOT) {
return parent;
}
if (parent.display !== undefined && parent.display != 'inline' &&
parent.role != RoleType.STATIC_TEXT &&
(parent.parent && parent.parent.role != RoleType.SVG_ROOT)) {
return parent;
}
parent = parent.parent;
}
return null;
};
/**
* Determines whether two nodes are in the same block-like ancestor, i.e.
* whether they are in the same paragraph.
* @param {AutomationNode|undefined} first The first node to compare.
* @param {AutomationNode|undefined} second The second node to compare.
* @return {boolean} whether two nodes are in the same paragraph.
*/
ParagraphUtils.inSameParagraph = function(first, second) {
if (first === undefined || second === undefined) {
return false;
}
// TODO: Clean up this check after crbug.com/774308 is resolved.
// At that point we will only need to check for display:block or inline-block.
if (((first.display == 'block' || first.display == 'inline-block') &&
first.role != RoleType.STATIC_TEXT &&
first.role != RoleType.INLINE_TEXT_BOX) ||
((second.display == 'block' || second.display == 'inline-block') &&
second.role != RoleType.STATIC_TEXT &&
second.role != RoleType.INLINE_TEXT_BOX)) {
// 'block' or 'inline-block' elements cannot be in the same paragraph.
return false;
}
let firstBlock = ParagraphUtils.getFirstBlockAncestor(first);
let secondBlock = ParagraphUtils.getFirstBlockAncestor(second);
return firstBlock != undefined && firstBlock == secondBlock;
};
/**
* Determines whether a string is only whitespace.
* @param {string|undefined} name A string to test
* @return {boolean} whether the string is only whitespace
*/
ParagraphUtils.isWhitespace = function(name) {
if (name === undefined || name.length == 0) {
return true;
}
// Search for one or more whitespace characters
let re = /^\s+$/;
return re.exec(name) != null;
};
/**
* Gets the text to be read aloud for a particular node.
* @param {!AutomationNode} node
* @return {string} The text to read for this node.
*/
ParagraphUtils.getNodeName = function(node) {
if (node.role === RoleType.TEXT_FIELD &&
(node.children === undefined || node.children.length === 0) &&
node.value) {
// A text field with no children should use its value instead of
// the name element, this is the contents of the text field.
// This occurs in native UI such as the omnibox.
return node.value;
} else if (
node.role === RoleType.CHECK_BOX ||
node.role === RoleType.MENU_ITEM_CHECK_BOX) {
let stateString = chrome.i18n.getMessage(
'select_to_speak_checkbox_' +
(node.checked === 'true' ?
'checked' :
(node.checked === 'mixed' ? 'mixed' : 'unchecked')));
return !ParagraphUtils.isWhitespace(node.name) ?
node.name + ' ' + stateString :
stateString;
} else if (
node.role === RoleType.RADIO_BUTTON ||
node.role === RoleType.MENU_ITEM_RADIO) {
let stateString = chrome.i18n.getMessage(
'select_to_speak_radiobutton_' +
(node.checked === 'true' ?
'selected' :
(node.checked === 'mixed' ? 'mixed' : 'unselected')));
return !ParagraphUtils.isWhitespace(node.name) ?
node.name + ' ' + stateString :
stateString;
}
return node.name ? node.name : '';
};
/**
* Determines the index into the parent name at which the inlineTextBox
* node name begins.
* @param {AutomationNode} inlineTextNode An inlineTextBox type node.
* @return {number} The character index into the parent node at which
* this node begins.
*/
ParagraphUtils.getStartCharIndexInParent = function(inlineTextNode) {
let result = 0;
for (let i = 0; i < inlineTextNode.indexInParent; i++) {
result += inlineTextNode.parent.children[i].name.length;
}
return result;
};
/**
* Determines the inlineTextBox child of a staticText node that appears
* at the given character index into the name of the staticText node. Uses
* the inlineTextBoxes name length to determine position. For example, if
* a staticText has name "abc 123" and two children with names "abc " and
* "123", indexes 0-3 would return the first child and indexes 4+ would
* return the second child.
* @param {AutomationNode} staticTextNode The staticText node to search.
* @param {number} index The index into the staticTextNode's name.
* @return {?AutomationNode} The inlineTextBox node within the staticText
* node that appears at this index into the staticText node's name, or
* the last inlineTextBox in the staticText node if the index is too
* large.
*/
ParagraphUtils.findInlineTextNodeByCharacterIndex = function(
staticTextNode, index) {
if (staticTextNode.children.length == 0) {
return null;
}
let textLength = 0;
for (var i = 0; i < staticTextNode.children.length; i++) {
let node = staticTextNode.children[i];
if (node.name.length + textLength > index) {
return node;
}
textLength += node.name.length;
}
return staticTextNode.children[staticTextNode.children.length - 1];
};
/**
* Builds information about nodes in a group until it reaches the end of the
* group. It may return a NodeGroup with a single node, or a large group
* representing a paragraph of inline nodes.
* @param {Array<!AutomationNode>} nodes List of automation nodes to use.
* @param {number} index The index into nodes at which to start.
* @param {boolean} splitOnLanguage flag to determine if we should split nodes
* up based on language.
* @return {ParagraphUtils.NodeGroup} info about the node group
*/
ParagraphUtils.buildNodeGroup = function(nodes, index, splitOnLanguage) {
let node = nodes[index];
let next = nodes[index + 1];
let result = new ParagraphUtils.NodeGroup(
ParagraphUtils.getFirstBlockAncestor(nodes[index]));
let staticTextParent = null;
let currentLanguage = undefined;
// TODO: Don't skip nodes. Instead, go through every node in
// this paragraph from the first to the last in the nodes list.
// This will catch nodes at the edges of the user's selection like
// short links at the beginning or ends of sentences.
//
// While next node is in the same paragraph as this node AND is
// a text type node, continue building the paragraph.
while (index < nodes.length) {
let name = ParagraphUtils.getNodeName(node);
if (!ParagraphUtils.isWhitespace(name)) {
let newNode;
if (node.role == RoleType.INLINE_TEXT_BOX && node.parent !== undefined) {
if (node.parent.role == RoleType.STATIC_TEXT) {
// This is an inlineTextBox node with a staticText parent. If that
// parent is already added to the result, we can skip. This adds
// each parent only exactly once.
if (staticTextParent && staticTextParent.node !== node.parent) {
// We are on a new staticText. Make a new parent to add to.
staticTextParent = null;
}
if (staticTextParent === null) {
staticTextParent = new ParagraphUtils.NodeGroupItem(
node.parent, result.text.length, true);
newNode = staticTextParent;
}
} else {
// Not an staticText parent node. Add it directly.
newNode =
new ParagraphUtils.NodeGroupItem(node, result.text.length, false);
}
} else {
// Not an inlineTextBox node. Add it directly, as each node in this list
// is relevant.
newNode =
new ParagraphUtils.NodeGroupItem(node, result.text.length, false);
}
if (newNode) {
result.text += ParagraphUtils.getNodeName(newNode.node) + ' ';
result.nodes.push(newNode);
}
}
// Set currentLanguage if we don't have one yet.
// We have to do this before we consider stopping otherwise we miss out on
// the last language attribute of each NodeGroup which could be important if
// this NodeGroup only contains a single node, or if all previous nodes
// lacked any language information.
if (!currentLanguage) {
currentLanguage = node.detectedLanguage;
}
// 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.
if (index + 1 >= nodes.length ||
!ParagraphUtils.inSameParagraph(node, next)) {
break;
}
// Stop if the next node would change our currentLanguage (if we have
// one). We allow an undefined detectedLanguage to match with any previous
// language, so that we will never break up a NodeGroup on an undefined
// detectedLanguage.
if (splitOnLanguage && currentLanguage && next.detectedLanguage &&
currentLanguage !== next.detectedLanguage) {
break;
}
index += 1;
node = next;
next = nodes[index + 1];
}
if (splitOnLanguage && currentLanguage) {
result.detectedLanguage = currentLanguage;
}
result.endIndex = index;
return result;
};
/**
* Class representing a node group, which may be a single node or a
* full paragraph of nodes.
*
* @param {?AutomationNode} blockParent The first block ancestor of
* this group. This may be the paragraph parent, for example.
* @constructor
*/
ParagraphUtils.NodeGroup = function(blockParent) {
/**
* Full text of this paragraph.
* @type {string}
*/
this.text = '';
/**
* List of nodes in this paragraph in order.
* @type {Array<ParagraphUtils.NodeGroupItem>}
*/
this.nodes = [];
/**
* The block parent of this NodeGroup, if there is one.
* @type {?AutomationNode}
*/
this.blockParent = blockParent;
/**
* The index of the last node in this paragraph from the list of
* nodes originally selected by the user.
* Note that this may not be stable over time, because nodes may
* come and go from the automation tree. This should not be used
* in any callbacks / asynchronously.
* @type {number}
*/
this.endIndex = -1;
/**
* Language and country code for all nodes within this NodeGroup.
* @type {string|undefined}
*/
this.detectedLanguage = undefined;
};
/**
* Class representing an automation node within a block of text, like
* a paragraph. Each Item in a NodeGroup has a start index within the
* total text, as well as the original AutomationNode it was associated
* with.
*
* @param {!AutomationNode} node The AutomationNode associated with this item
* @param {number} startChar The index into the NodeGroup's text string where
* this item begins.
* @param {boolean=} opt_hasInlineText If this NodeGroupItem has inlineText
* children.
* @constructor
*/
ParagraphUtils.NodeGroupItem = function(node, startChar, opt_hasInlineText) {
/**
* @type {!AutomationNode}
*/
this.node = node;
/**
* The index into the NodeGroup's text string that is the first character
* of the text of this automation node.
* @type {number}
*/
this.startChar = startChar;
/**
* If this is a staticText node which has inlineTextBox children which should
* be selected. We cannot select the inlineTextBox children directly because
* they are not guarenteed to be stable.
* @type {boolean}
*/
this.hasInlineText =
opt_hasInlineText !== undefined ? opt_hasInlineText : false;
};