blob: 1c8b628f752283b35e3330ffb38a021b838d38a3 [file] [log] [blame]
// Copyright 2014 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* @fileoverview Classes related to cursors that point to and select parts of
* the automation tree.
*/
goog.provide('cursors.Cursor');
goog.provide('cursors.Movement');
goog.provide('cursors.Range');
goog.provide('cursors.Unit');
goog.require('AncestryRecoveryStrategy');
goog.require('AutomationPredicate');
goog.require('AutomationUtil');
goog.require('RecoveryStrategy');
goog.require('StringUtil');
goog.require('constants');
/**
* The special index that represents a cursor pointing to a node without
* pointing to any part of its accessible text.
*/
cursors.NODE_INDEX = -1;
/**
* Represents units of CursorMovement.
* @enum {string}
*/
cursors.Unit = {
/** A single character within accessible name or value. */
CHARACTER: 'character',
/** A range of characters (given by attributes on automation nodes). */
WORD: 'word',
/** A text node. */
TEXT: 'text',
/** A leaf node. */
NODE: 'node',
/**
* A node or in line textbox that immediately precedes or follows a visual
* line break.
*/
LINE: 'line'
};
/**
* Represents the ways in which cursors can move given a cursor unit.
* @enum {string}
*/
cursors.Movement = {
/** Move to the beginning or end of the current unit. */
BOUND: 'bound',
/** Move to the next unit in a particular direction. */
DIRECTIONAL: 'directional'
};
goog.scope(function() {
var AutomationNode = chrome.automation.AutomationNode;
var Dir = constants.Dir;
var Movement = cursors.Movement;
var RoleType = chrome.automation.RoleType;
var Unit = cursors.Unit;
/**
* Represents a position within the automation tree.
* @constructor
* @param {!AutomationNode} node
* @param {number} index A 0-based index into this cursor node's primary
* accessible name. An index of |cursors.NODE_INDEX| means the node as a whole
* is pointed to and covers the case where the accessible text is empty.
*/
cursors.Cursor = function(node, index) {
// Compensate for specific issues in Blink.
// TODO(dtseng): Pass through affinity; if upstream, skip below.
if (node.role == RoleType.STATIC_TEXT && node.name.length == index) {
// Re-interpret this case as the beginning of the next node.
var nextNode = AutomationUtil.findNextNode(
node, Dir.FORWARD, AutomationPredicate.leafOrStaticText);
// The exception is when a user types at the end of a line. In that case,
// staying on the current node is appropriate.
if (node && node.nextOnLine && node.nextOnLine.role && nextNode) {
node = nextNode;
index = 0;
}
} else if (
node.role == RoleType.GENERIC_CONTAINER && node.state.richlyEditable &&
(node.firstChild &&
(node.firstChild.role == RoleType.LINE_BREAK ||
node.firstChild.role == RoleType.STATIC_TEXT))) {
// Re-interpret this case as pointing to the text under the div.
node = node.find({role: RoleType.INLINE_TEXT_BOX}) || node;
}
/** @type {number} @private */
this.index_ = index;
/** @type {RecoveryStrategy} */
this.recovery_ = new AncestryRecoveryStrategy(node);
};
/**
* Convenience method to construct a Cursor from a node.
* @param {!AutomationNode} node
* @return {!cursors.Cursor}
*/
cursors.Cursor.fromNode = function(node) {
return new cursors.Cursor(node, cursors.NODE_INDEX);
};
cursors.Cursor.prototype = {
/**
* Returns true if |rhs| is equal to this cursor.
* Use this for strict equality between cursors.
* @param {!cursors.Cursor} rhs
* @return {boolean}
*/
equals: function(rhs) {
return this.node === rhs.node && this.index === rhs.index;
},
/**
* Returns true if |rhs| is equal to this cursor.
* Use this for loose equality between cursors where specific character-based
* indicies do not matter such as when processing node-targeted events.
* @param {!cursors.Cursor} rhs
* @return {boolean}
*/
contentEquals: function(rhs) {
// First, normalize the two nodes so they both point to the first non-text
// node.
var lNode = this.node;
var rNode = rhs.node;
while (lNode &&
(lNode.role == RoleType.INLINE_TEXT_BOX ||
lNode.role == RoleType.STATIC_TEXT))
lNode = lNode.parent;
while (rNode &&
(rNode.role == RoleType.INLINE_TEXT_BOX ||
rNode.role == RoleType.STATIC_TEXT))
rNode = rNode.parent;
// Ignore indicies for now.
return lNode === rNode && lNode != undefined;
},
/**
* Compares this cursor with |rhs|.
* @param {cursors.Cursor} rhs
* @return Dir.BACKWARD if |rhs| comes before this cursor in
* document order. Forward otherwise.
*/
compare: function(rhs) {
if (!this.node || !rhs.node)
return Dir.FORWARD;
if (rhs.node == this.node)
return rhs.index < this.index ? Dir.BACKWARD : Dir.FORWARD;
return AutomationUtil.getDirection(this.node, rhs.node);
},
/**
* Returns the node. If the node is invalid since the last time it
* was accessed, moves the cursor to the nearest valid ancestor first.
* @return {AutomationNode}
*/
get node() {
if (this.recovery_.requiresRecovery()) {
// If we need to recover, the index is no longer valid.
this.index_ = cursors.NODE_INDEX;
}
return this.recovery_.node;
},
/**
* @return {number}
*/
get index() {
return this.index_;
},
/**
* A node appropriate for making selections.
* @return {AutomationNode}
* @private
*/
get selectionNode_() {
var adjustedNode = this.node;
if (!adjustedNode)
return null;
// Make no adjustments if we're within editable content.
if (adjustedNode.state.editable)
return adjustedNode;
// Selections over line break nodes are broken.
var parent = adjustedNode.parent;
var grandparent = parent && parent.parent;
if (parent && parent.role == RoleType.LINE_BREAK) {
adjustedNode = grandparent;
} else if (grandparent && grandparent.role == RoleType.LINE_BREAK) {
adjustedNode = grandparent.parent;
} else if (
this.index_ == cursors.NODE_INDEX ||
adjustedNode.role == RoleType.INLINE_TEXT_BOX ||
adjustedNode.nameFrom != chrome.automation.NameFromType.CONTENTS) {
// A node offset or unselectable character offset.
adjustedNode = parent;
} else {
// A character offset into content.
adjustedNode =
adjustedNode.find({role: RoleType.STATIC_TEXT}) || adjustedNode;
}
return adjustedNode || null;
},
/**
* An index appropriate for making selections. If this cursor has a
* cursors.NODE_INDEX index, the selection index is a node offset e.g. the
* index in parent. If not, the index is a character offset.
* @return {number}
* @private
*/
get selectionIndex_() {
var adjustedIndex = this.index_;
if (!this.node)
return -1;
if (this.node.state.editable) {
return this.index_ == cursors.NODE_INDEX ? 0 : this.index_;
} else if (
this.node.role == RoleType.INLINE_TEXT_BOX &&
// Selections under a line break are broken.
this.node.parent && this.node.parent.role != RoleType.LINE_BREAK) {
if (adjustedIndex == cursors.NODE_INDEX)
adjustedIndex = 0;
var sibling = this.node.previousSibling;
while (sibling) {
adjustedIndex += sibling.name.length;
sibling = sibling.previousSibling;
}
} else if (
this.index_ == cursors.NODE_INDEX ||
this.node.nameFrom != chrome.automation.NameFromType.CONTENTS) {
// A node offset or unselectable character offset.
// The selected node could have been adjusted upwards in the tree.
var childOfSelection = this.node;
do {
adjustedIndex = childOfSelection.indexInParent || 0;
childOfSelection = childOfSelection.parent;
} while (childOfSelection && childOfSelection != this.selectionNode_);
}
// A character offset into content is the remaining case. It requires no
// adjustment.
return adjustedIndex;
},
/**
* Gets the accessible text of the node associated with this cursor.
* @return {string}
*/
getText: function() {
return AutomationUtil.getText(this.node);
},
/**
* Makes a Cursor which has been moved from this cursor by the unit in the
* given direction using the given movement type.
* @param {Unit} unit
* @param {Movement} movement
* @param {Dir} dir
* @return {!cursors.Cursor} The moved cursor.
*/
move: function(unit, movement, dir) {
var originalNode = this.node;
if (!originalNode)
return this;
var newNode = originalNode;
var newIndex = this.index_;
switch (unit) {
case Unit.CHARACTER:
if (newIndex === cursors.NODE_INDEX)
newIndex = 0;
// BOUND and DIRECTIONAL are the same for characters.
var text = this.getText();
newIndex = dir == Dir.FORWARD ?
StringUtil.nextCodePointOffset(text, newIndex) :
StringUtil.previousCodePointOffset(text, newIndex);
if (newIndex < 0 || newIndex >= text.length) {
newNode = AutomationUtil.findNextNode(
newNode, dir, AutomationPredicate.leafWithText);
if (newNode) {
var newText = AutomationUtil.getText(newNode);
newIndex = dir == Dir.FORWARD ?
0 :
StringUtil.previousCodePointOffset(newText, newText.length);
newIndex = Math.max(newIndex, 0);
} else {
newIndex = this.index_;
}
}
break;
case Unit.WORD:
// If we're not already on a node with word stops, find the next one.
if (!AutomationPredicate.leafWithWordStop(newNode)) {
newNode =
AutomationUtil.findNextNode(
newNode, Dir.FORWARD, AutomationPredicate.leafWithWordStop,
{skipInitialSubtree: false}) ||
newNode;
}
// Ensure start position is on or after first word.
var firstWordStart = (newNode.wordStarts && newNode.wordStarts.length) ?
newNode.wordStarts[0] :
0;
if (newIndex < firstWordStart) // Also catches cursors.NODE_INDEX case.
newIndex = firstWordStart;
switch (movement) {
case Movement.BOUND:
if (newNode.role == RoleType.INLINE_TEXT_BOX) {
var start, end;
for (var i = 0; i < newNode.wordStarts.length; i++) {
if (newIndex >= newNode.wordStarts[i] &&
newIndex <= newNode.wordEnds[i]) {
start = newNode.wordStarts[i];
end = newNode.wordEnds[i];
break;
}
}
if (goog.isDef(start) && goog.isDef(end))
newIndex = dir == Dir.FORWARD ? end : start;
} else {
newIndex = cursors.NODE_INDEX;
}
break;
case Movement.DIRECTIONAL:
var start;
if (newNode.role == RoleType.INLINE_TEXT_BOX) {
// Go to the next word stop in the same piece of text.
for (var i = 0; i < newNode.wordStarts.length; i++) {
if (newIndex >= newNode.wordStarts[i] &&
newIndex <= newNode.wordEnds[i]) {
var nextIndex = dir == Dir.FORWARD ? i + 1 : i - 1;
start = newNode.wordStarts[nextIndex];
break;
}
}
}
if (goog.isDef(start)) {
// Succesfully found the next word stop within the same text node.
newIndex = start;
} else {
// Use adjacent word in adjacent next node in direction |dir|.
if (dir == Dir.BACKWARD && newIndex > firstWordStart) {
// The backward case is special at the beginning of nodes.
newIndex = firstWordStart;
} else {
newNode = AutomationUtil.findNextNode(
newNode, dir, AutomationPredicate.leafWithWordStop);
if (newNode) {
if (newNode.role == RoleType.INLINE_TEXT_BOX) {
var starts = newNode.wordStarts;
if (starts.length) {
newIndex = dir == Dir.BACKWARD ?
starts[starts.length - 1] :
starts[0];
}
} else {
// For non-text nodes, move by word = by object.
newIndex = cursors.NODE_INDEX;
}
}
}
}
}
break;
case Unit.TEXT:
case Unit.NODE:
switch (movement) {
case Movement.BOUND:
newIndex = dir == Dir.FORWARD ? this.getText().length - 1 : 0;
break;
case Movement.DIRECTIONAL:
var pred = unit == Unit.TEXT ? AutomationPredicate.leaf :
AutomationPredicate.object;
newNode =
AutomationUtil.findNextNode(newNode, dir, pred) || originalNode;
newIndex = cursors.NODE_INDEX;
break;
}
break;
case Unit.LINE:
var deepEquivalent = this.deepEquivalent;
newNode = deepEquivalent.node || newNode;
newIndex = deepEquivalent.index || 0;
switch (movement) {
case Movement.BOUND:
newNode = AutomationUtil.findNodeUntil(
newNode, dir, AutomationPredicate.linebreak, true);
newNode = newNode || originalNode;
newIndex =
dir == Dir.FORWARD ? AutomationUtil.getText(newNode).length : 0;
break;
case Movement.DIRECTIONAL:
newNode = AutomationUtil.findNodeUntil(
newNode, dir, AutomationPredicate.linebreak);
break;
}
break;
default:
throw Error('Unrecognized unit: ' + unit);
}
newNode = newNode || originalNode;
newIndex = goog.isDef(newIndex) ? newIndex : this.index_;
return new cursors.Cursor(newNode, newIndex);
},
/**
* Returns the deepest equivalent cursor.
* @return {cursors.Cursor}
*/
get deepEquivalent() {
var newNode = this.node;
var newIndex = this.index_;
while (newNode.firstChild) {
if (newNode.role == RoleType.STATIC_TEXT) {
// Text offset.
// Re-interpret the index as an offset into an inlineTextBox.
var target = newNode.firstChild;
var length = 0;
while (target && length < newIndex) {
var newLength = length + target.name.length;
// Either |newIndex| falls between target's text or |newIndex| is the
// total length of all sibling text content.
if ((length <= newIndex && newIndex < newLength) ||
(newIndex == newLength && !target.nextSibling))
break;
length = newLength;
target = target.nextSibling;
}
if (target) {
newNode = target;
newIndex = newIndex - length;
}
break;
} else if (
newNode.role != RoleType.INLINE_TEXT_BOX &&
newNode.children[newIndex]) {
// Valid node offset.
newNode = newNode.children[newIndex];
newIndex = 0;
} else {
// Invalid offset.
break;
}
}
return new cursors.Cursor(newNode, newIndex);
},
/**
* Returns whether this cursor points to a valid position.
* @return {boolean}
*/
isValid: function() {
return this.node != null;
}
};
/**
* A cursors.Cursor that wraps from beginning to end and vice versa when moved.
* @constructor
* @param {!AutomationNode} node
* @param {number} index A 0-based index into this cursor node's primary
* accessible name. An index of |cursors.NODE_INDEX| means the node as a whole
* is pointed to and covers the case where the accessible text is empty.
* @extends {cursors.Cursor}
*/
cursors.WrappingCursor = function(node, index) {
cursors.Cursor.call(this, node, index);
};
/**
* Convenience method to construct a Cursor from a node.
* @param {!AutomationNode} node
* @return {!cursors.WrappingCursor}
*/
cursors.WrappingCursor.fromNode = function(node) {
return new cursors.WrappingCursor(node, cursors.NODE_INDEX);
};
cursors.WrappingCursor.prototype = {
__proto__: cursors.Cursor.prototype,
/** @override */
move: function(unit, movement, dir) {
var result = this;
if (!result.node)
return this;
// Regular movement.
if (!AutomationPredicate.root(this.node) || dir == Dir.FORWARD ||
movement == Movement.BOUND)
result = cursors.Cursor.prototype.move.call(this, unit, movement, dir);
// Moving to the bounds of a unit never wraps.
if (movement == Movement.BOUND)
return new cursors.WrappingCursor(result.node, result.index);
// There are two cases for wrapping:
// 1. moving forwards from the last element.
// 2. moving backwards from the first element.
// Both result in |move| returning the same cursor.
// For 1, simply place the new cursor on the document node.
// For 2, place range on the root (if not already there). If at root,
// try to descend to the first leaf-like object.
if (movement == Movement.DIRECTIONAL && result.equals(this)) {
var pred = unit == Unit.NODE ? AutomationPredicate.object :
AutomationPredicate.leaf;
var endpoint = this.node;
if (!endpoint)
return this;
// Case 1: forwards (find the root-like node).
while (!AutomationPredicate.root(endpoint) && endpoint.parent)
endpoint = endpoint.parent;
// Always play a wrap earcon when moving forward.
var playEarcon = dir == Dir.FORWARD;
// Case 2: backward (sync downwards to a leaf), if already on the root.
if (dir == Dir.BACKWARD && endpoint == this.node) {
playEarcon = true;
endpoint = AutomationUtil.findNodePre(endpoint, dir, function(n) {
return pred(n) && !AutomationPredicate.shouldIgnoreNode(n);
}) || endpoint;
}
if (playEarcon)
cvox.ChromeVox.earcons.playEarcon(cvox.Earcon.WRAP);
return new cursors.WrappingCursor(endpoint, cursors.NODE_INDEX);
}
return new cursors.WrappingCursor(result.node, result.index);
}
};
/**
* Represents a range in the automation tree. There is no visible selection on
* the page caused by usage of this object.
* It is assumed that the caller provides |start| and |end| in document order.
* @param {!cursors.Cursor} start
* @param {!cursors.Cursor} end
* @constructor
*/
cursors.Range = function(start, end) {
/** @type {!cursors.Cursor} @private */
this.start_ = start;
/** @type {!cursors.Cursor} @private */
this.end_ = end;
};
/**
* Convenience method to construct a Range surrounding one node.
* @param {!AutomationNode} node
* @return {!cursors.Range}
*/
cursors.Range.fromNode = function(node) {
var cursor = cursors.WrappingCursor.fromNode(node);
return new cursors.Range(cursor, cursor);
};
/**
* Given |rangeA| and |rangeB| in order, determine which |Dir|
* relates them.
* @param {!cursors.Range} rangeA
* @param {!cursors.Range} rangeB
* @return {Dir}
*/
cursors.Range.getDirection = function(rangeA, rangeB) {
if (!rangeA || !rangeB)
return Dir.FORWARD;
if (!rangeA.start.node || !rangeA.end.node || !rangeB.start.node ||
!rangeB.end.node)
return Dir.FORWARD;
// They are the same range.
if (rangeA.start.node === rangeB.start.node &&
rangeB.end.node === rangeA.end.node)
return Dir.FORWARD;
var testDirA =
AutomationUtil.getDirection(rangeA.start.node, rangeB.end.node);
var testDirB =
AutomationUtil.getDirection(rangeB.start.node, rangeA.end.node);
// The two ranges are either partly overlapping or non overlapping.
if (testDirA == Dir.FORWARD && testDirB == Dir.BACKWARD)
return Dir.FORWARD;
else if (testDirA == Dir.BACKWARD && testDirB == Dir.FORWARD)
return Dir.BACKWARD;
else
return testDirA;
};
cursors.Range.prototype = {
/**
* Returns true if |rhs| is equal to this range.
* Use this for strict equality between ranges.
* @param {!cursors.Range} rhs
* @return {boolean}
*/
equals: function(rhs) {
return this.start_.equals(rhs.start) && this.end_.equals(rhs.end);
},
/**
* Returns true if |rhs| is equal to this range.
* Use this for loose equality between ranges.
* @param {!cursors.Range} rhs
* @return {boolean}
*/
contentEquals: function(rhs) {
return this.start_.contentEquals(rhs.start) &&
this.end_.contentEquals(rhs.end);
},
/**
* Gets the directed end cursor of this range.
* @param {Dir} dir Which endpoint cursor to return; Dir.FORWARD for end,
* Dir.BACKWARD for start.
* @return {!cursors.Cursor}
*/
getBound: function(dir) {
return dir == Dir.FORWARD ? this.end_ : this.start_;
},
/**
* @return {!cursors.Cursor}
*/
get start() {
return this.start_;
},
/**
* @return {!cursors.Cursor}
*/
get end() {
return this.end_;
},
/**
* Returns true if this range covers a single node's text content or less.
* @return {boolean}
*/
isSubNode: function() {
return this.start.node === this.end.node && this.start.index > -1 &&
this.end.index > -1;
},
/**
* Returns true if this range covers inline text (i.e. each end points to an
* inlineTextBox).
* @return {boolean?}
*/
isInlineText: function() {
return this.start.node && this.end.node &&
this.start.node.role == this.end.node.role &&
this.start.node.role == RoleType.INLINE_TEXT_BOX;
},
/**
* Makes a Range which has been moved from this range by the given unit and
* direction.
* @param {Unit} unit
* @param {Dir} dir
* @return {cursors.Range}
*/
move: function(unit, dir) {
var newStart = this.start_;
if (!newStart.node)
return this;
var newEnd;
switch (unit) {
case Unit.CHARACTER:
newStart = newStart.move(unit, Movement.DIRECTIONAL, dir);
newEnd = newStart.move(unit, Movement.DIRECTIONAL, Dir.FORWARD);
// Character crossed a node; collapses to the end of the node.
if (newStart.node !== newEnd.node)
newEnd = new cursors.Cursor(newStart.node, newStart.index + 1);
break;
case Unit.WORD:
case Unit.LINE:
newStart = newStart.move(unit, Movement.DIRECTIONAL, dir);
newStart = newStart.move(unit, Movement.BOUND, Dir.BACKWARD);
newEnd = newStart.move(unit, Movement.BOUND, Dir.FORWARD);
break;
case Unit.NODE:
newStart = newStart.move(unit, Movement.DIRECTIONAL, dir);
newEnd = newStart;
break;
default:
throw Error('Invalid unit: ' + unit);
}
return new cursors.Range(newStart, newEnd);
},
/**
* Select the text contained within this range.
*/
select: function() {
var startNode = this.start.selectionNode_;
var endNode = this.end.selectionNode_;
if (!startNode || !endNode)
return;
// Only allow selections within the same web tree.
if (startNode.root && startNode.root.role == RoleType.ROOT_WEB_AREA &&
startNode.root == endNode.root) {
// We want to adjust to select the entire node for node offsets;
// otherwise, use the plain character offset.
var startIndex = this.start.selectionIndex_;
var endIndex = this.end.index_ == cursors.NODE_INDEX ?
this.end.selectionIndex_ + 1 :
this.end.selectionIndex_;
chrome.automation.setDocumentSelection({
anchorObject: startNode,
anchorOffset: startIndex,
focusObject: endNode,
focusOffset: endIndex
});
}
},
/**
* Returns true if this range has either cursor end on web content.
* @return {boolean}
*/
isWebRange: function() {
return this.isValid() &&
(this.start.node.root.role != RoleType.DESKTOP ||
this.end.node.root.role != RoleType.DESKTOP);
},
/**
* Returns whether this range has valid start and end cursors.
* @return {boolean}
*/
isValid: function() {
return this.start.isValid() && this.end.isValid();
}
};
}); // goog.scope