blob: a06eff62723d53cbe56ae3a5c7e8bba6485b07bb [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 Structures related to cursors that point to and select parts of
* the automation tree.
*/
import {AutomationPredicate} from '../automation_predicate.js';
import {AutomationUtil} from '../automation_util.js';
import {StringUtil} from '../string_util.js';
import {AncestryRecoveryStrategy, RecoveryStrategy} from './recovery_strategy.js';
const AutomationNode = chrome.automation.AutomationNode;
const Dir = constants.Dir;
const RoleType = chrome.automation.RoleType;
const StateType = chrome.automation.StateType;
/**
* The special index that represents a cursor pointing to a node without
* pointing to any part of its accessible text.
*/
export const CURSOR_NODE_INDEX = -1;
/**
* Represents units of CursorMovement.
* @enum {string}
*/
export const CursorUnit = {
/** 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 leaf node for guesture navigation. */
GESTURE_NODE: 'gesture_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}
*/
export const CursorMovement = {
/** Move to the beginning or end of the current unit. */
BOUND: 'bound',
/** Move to the next unit in a particular direction. */
DIRECTIONAL: 'directional',
/**
* Move to the beginning or end of the current cursor. Only supports
* Unit.CHARACTER and Unit.WORD
*/
SYNC: 'sync',
};
/**
* Represents a position within the automation tree.
*/
export class Cursor {
/**
* @param {!AutomationNode} node
* @param {number} index A 0-based index into this cursor node's primary
* accessible name. An index of |CURSOR_NODE_INDEX| means the node as a
* whole is pointed to and covers the case where the accessible text is
* empty.
* @param {{wrapped: (boolean|undefined),
* preferNodeStartEquivalent: (boolean|undefined)}} args
* wrapped: determines whether this cursor wraps when moved beyond a
* document boundary.
* preferNodeStartEquivalent: When true,moves this cursor to the start of
* the next node when it points to the end of the current node.
*/
constructor(node, index, args = {}) {
// 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 &&
!args.preferNodeStartEquivalent) {
// Re-interpret this case as the beginning of the next node.
const nextNode = AutomationUtil.findNextNode(
node, Dir.FORWARD, AutomationPredicate.leafOrStaticText,
{root: r => r === node.root});
// 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;
}
}
/** @type {number} @private */
this.index_ = index;
/** @type {RecoveryStrategy} */
this.recovery_ = new AncestryRecoveryStrategy(node);
/** @private {boolean} */
this.wrapped_ = args.wrapped || false;
}
/**
* Convenience method to construct a Cursor from a node.
* @param {!AutomationNode} node
* @return {!Cursor}
*/
static fromNode(node) {
return new Cursor(node, CURSOR_NODE_INDEX);
}
/**
* Gives a function that returns true for leaf types for |unit| navigations.
* @param {!CursorUnit} unit
* @return {AutomationPredicate.Unary}
*/
static getLeafPredForUnit(unit) {
switch (unit) {
case CursorUnit.TEXT:
return AutomationPredicate.leaf;
case CursorUnit.GESTURE_NODE:
return AutomationPredicate.gestureObject;
default:
return AutomationPredicate.object;
}
}
/**
* Returns true if |rhs| is equal to this cursor.
* Use this for strict equality between cursors.
* @param {!Cursor} rhs
* @return {boolean}
*/
equals(rhs) {
return this.node === rhs.node && this.index === rhs.index;
}
equalsWithoutRecovery(rhs) {
return this.recovery_.equalsWithoutRecovery(rhs.recovery_);
}
/**
* 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 {!Cursor} rhs
* @return {boolean}
*/
contentEquals(rhs) {
// First, normalize the two nodes so they both point to the first non-text
// node.
let lNode = this.node;
let 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 {Cursor} rhs
* @return Dir.BACKWARD if |rhs| comes before this cursor in
* document order. Forward otherwise.
*/
compare(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.requiresRecovery()) {
// If we need to recover, the index is no longer valid.
this.index_ = CURSOR_NODE_INDEX;
}
return this.recovery_.node;
}
/**
* @return {number}
*/
get index() {
return this.index_;
}
/**
* A node appropriate for making selections.
* @return {AutomationNode}
*/
get selectionNode() {
// TODO(accessibility): figure out if we still need the above property.
return this.node;
}
/**
* An index appropriate for making selections. If this cursor has a
* CURSOR_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}
*/
get selectionIndex() {
return this.index_ === CURSOR_NODE_INDEX ? 0 : this.index_;
}
/**
* Gets the accessible text of the node associated with this cursor.
* @return {string}
*/
getText() {
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 {CursorUnit} unit
* @param {CursorMovement} movement
* @param {constants.Dir} dir
* @return {!Cursor} The moved cursor.
*/
move(unit, movement, dir) {
const originalNode = this.node;
if (!originalNode) {
return this;
}
let newNode = originalNode;
let newIndex = this.index_;
switch (unit) {
case CursorUnit.CHARACTER:
const text = this.getText();
switch (movement) {
case CursorMovement.BOUND:
case CursorMovement.DIRECTIONAL:
if (newIndex === CURSOR_NODE_INDEX) {
newIndex = 0;
}
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) {
const 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 CursorMovement.SYNC:
if (newIndex === CURSOR_NODE_INDEX) {
newIndex = dir === Dir.FORWARD ?
0 :
StringUtil.previousCodePointOffset(text, text.length);
} else {
newIndex = dir === Dir.FORWARD ?
StringUtil.nextCodePointOffset(text, newIndex) :
StringUtil.previousCodePointOffset(text, newIndex);
}
if (newIndex < 0 || newIndex >= text.length) {
// unfortunate case. Fallback to return the same one as |this|.
newIndex = this.index_;
}
break;
}
break;
case CursorUnit.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.
const firstWordStart =
(newNode.wordStarts && newNode.wordStarts.length) ?
newNode.wordStarts[0] :
0;
if (newIndex < firstWordStart && movement !== CursorMovement.SYNC) {
// Also catches CURSOR_NODE_INDEX case.
newIndex = firstWordStart;
}
switch (movement) {
case CursorMovement.BOUND: {
let wordStarts;
let wordEnds;
if (newNode.role === RoleType.INLINE_TEXT_BOX) {
wordStarts = newNode.wordStarts;
wordEnds = newNode.wordEnds;
} else {
wordStarts = newNode.nonInlineTextWordStarts;
wordEnds = newNode.nonInlineTextWordEnds;
}
let start;
let end;
for (let i = 0; i < wordStarts.length; i++) {
if (newIndex >= wordStarts[i] && newIndex < wordEnds[i]) {
start = wordStarts[i];
end = wordEnds[i];
break;
}
}
if (goog.isDef(start) && goog.isDef(end)) {
newIndex = dir === Dir.FORWARD ? end : start;
}
} break;
case CursorMovement.SYNC:
if (newIndex === CURSOR_NODE_INDEX) {
newIndex = dir === Dir.FORWARD ? firstWordStart - 1 :
this.getText().length;
}
// fallthrough
case CursorMovement.DIRECTIONAL: {
let wordStarts;
let wordEnds;
let start;
if (newNode.role === RoleType.INLINE_TEXT_BOX) {
wordStarts = newNode.wordStarts;
wordEnds = newNode.wordEnds;
} else {
wordStarts = newNode.nonInlineTextWordStarts;
wordEnds = newNode.nonInlineTextWordEnds;
}
// Go to the next word stop in the same piece of text.
for (let i = 0; i < wordStarts.length; i++) {
if ((dir === Dir.FORWARD && newIndex < wordStarts[i]) ||
(dir === Dir.BACKWARD && newIndex >= wordEnds[i])) {
start = wordStarts[i];
if (dir === Dir.FORWARD) {
break;
}
}
}
if (goog.isDef(start)) {
// Successfully found the next word stop within the same text
// node.
newIndex = start;
} else if (movement === CursorMovement.DIRECTIONAL) {
// 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,
{root: r => r === newNode.root});
if (newNode) {
let starts;
if (newNode.role === RoleType.INLINE_TEXT_BOX) {
starts = newNode.wordStarts;
} else {
starts = newNode.nonInlineTextWordStarts;
}
if (starts.length) {
newIndex = dir === Dir.BACKWARD ?
starts[starts.length - 1] :
starts[0];
}
}
}
}
}
}
break;
case CursorUnit.TEXT:
case CursorUnit.NODE:
case CursorUnit.GESTURE_NODE:
switch (movement) {
case CursorMovement.BOUND:
newIndex = dir === Dir.FORWARD ? this.getText().length - 1 : 0;
break;
case CursorMovement.DIRECTIONAL:
const pred = Cursor.getLeafPredForUnit(unit);
newNode =
AutomationUtil.findNextNode(newNode, dir, pred) || originalNode;
newIndex = CURSOR_NODE_INDEX;
break;
}
break;
case CursorUnit.LINE:
switch (movement) {
case CursorMovement.BOUND:
newNode = AutomationUtil.findNodeUntil(
newNode, dir, AutomationPredicate.linebreak, true);
newNode = newNode || originalNode;
newIndex = dir === Dir.FORWARD ?
AutomationUtil.getText(newNode).length :
0;
break;
case CursorMovement.DIRECTIONAL:
newNode = AutomationUtil.findNodeUntil(
newNode, dir, AutomationPredicate.linebreak);
if (newNode) {
newIndex = 0;
}
break;
}
break;
default:
throw Error('Unrecognized unit: ' + unit);
}
newNode = newNode || originalNode;
newIndex = (newIndex !== undefined) ? newIndex : this.index_;
return new Cursor(newNode, newIndex);
}
/**
* Returns the deepest equivalent cursor.
* @return {!Cursor}
*/
get deepEquivalent() {
let newNode = this.node;
let newIndex = this.index_;
let isTextIndex = false;
while (newNode.firstChild) {
if (AutomationPredicate.editText(newNode) &&
!newNode.state[StateType.MULTILINE]) {
// Do not reinterpret nodes and indices on this node.
break;
} else if (newNode.role === RoleType.STATIC_TEXT) {
// Text offset.
// Re-interpret the index as an offset into an inlineTextBox.
isTextIndex = true;
let target = newNode.firstChild;
let length = 0;
while (target && length < newIndex) {
const 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 &&
// An index inside a content editable or a descendant of a content
// editable should be treated as a child offset.
// However, an index inside a simple editable, such as an input
// element, should be treated as a character offset.
(!newNode.state[StateType.EDITABLE] ||
newNode.state[StateType.RICHLY_EDITABLE]) &&
newIndex <= newNode.children.length) {
// Valid child node offset. Note that there is a special case where
// |newIndex == node.children.length|. In these cases, we actually
// want to position the cursor at the end of the text of
// |node.children[newIndex - 1]|.
// |newIndex| is assumed to be > 0.
if (newIndex === newNode.children.length) {
// Take the last child.
newNode = newNode.lastChild;
// The |newIndex| is either a text offset or a child offset.
if (newNode.role === RoleType.STATIC_TEXT) {
newIndex = newNode.name.length;
isTextIndex = true;
break;
}
// The last valid child index.
newIndex = newNode.children.length;
} else {
newNode = newNode.children[newIndex];
newIndex = 0;
}
} else {
// This offset is a text offset into the descendant visible
// text. Approximate this by indexing into the inline text boxes.
isTextIndex = true;
const lines = this.getAllLeaves_(newNode);
if (!lines.length) {
break;
}
let targetLine;
let targetIndex = 0;
for (let i = 0, line, cur = 0; line = lines[i]; i++) {
const lineLength = line.name ? line.name.length : 1;
cur += lineLength;
if (cur > newIndex) {
targetLine = line;
if (!line.name) {
targetIndex = CURSOR_NODE_INDEX;
} else {
targetIndex = newIndex - (cur - lineLength);
}
break;
}
}
if (!targetLine) {
// If we got here, that means the index is actually beyond the total
// length of text. Just get the last line.
targetLine = lines[lines.length - 1];
targetIndex = targetLine ? targetLine.name.length : CURSOR_NODE_INDEX;
}
newNode = targetLine;
newIndex = targetIndex;
break;
}
}
if (!isTextIndex) {
newIndex = CURSOR_NODE_INDEX;
}
return new this.constructor(newNode, newIndex);
}
/**
* Returns whether this cursor points to a valid position.
* @return {boolean}
*/
isValid() {
return this.node != null;
}
/**
* Returns true if this cursor requires recovery.
* @return {boolean}
*/
requiresRecovery() {
return this.recovery_.requiresRecovery();
}
/**
* @private
* @param {!AutomationNode} node
* @return {!Array<!AutomationNode>}
*/
getAllLeaves_(node) {
let ret = [];
if (!node.firstChild) {
ret.push(node);
return ret;
}
for (let i = 0; i < node.children.length; i++) {
ret = ret.concat(this.getAllLeaves_(node.children[i]));
}
return ret;
}
/**
* Returns true if this cursor was created after wrapping. For example, moving
* from a cursor at the end of a web contents to [this] range at the beginning
* of the document.
* @return {boolean}
*/
get wrapped() {
return this.wrapped_;
}
}
/**
* A Cursor that wraps from beginning to end and vice versa when
* moved.
*/
export class WrappingCursor extends Cursor {
/**
* @param {!AutomationNode} node
* @param {number} index A 0-based index into this cursor node's primary
* accessible name. An index of |CURSOR_NODE_INDEX| means the node as a
* whole is pointed to and covers the case where the accessible text is
* empty.
* @param {{wrapped: (boolean|undefined)}} args
*/
constructor(node, index, args = {}) {
super(node, index, args);
}
/**
* Convenience method to construct a Cursor from a node.
* @param {!AutomationNode} node
* @return {!WrappingCursor}
*/
static fromNode(node) {
return new WrappingCursor(node, CURSOR_NODE_INDEX);
}
/** @override */
move(unit, movement, dir) {
let result = this;
if (!result.node) {
return this;
}
// Regular movement.
if (!AutomationPredicate.root(this.node) || dir === Dir.FORWARD ||
movement === CursorMovement.BOUND) {
result = Cursor.prototype.move.call(this, unit, movement, dir);
}
// Moving to the bounds of a unit never wraps.
if (movement === CursorMovement.BOUND || movement === CursorMovement.SYNC) {
return new 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 === CursorMovement.DIRECTIONAL && result.equals(this)) {
const pred = Cursor.getLeafPredForUnit(unit);
let endpoint = this.node;
if (!endpoint) {
return this;
}
// Finds any explicitly provided focus.
const getDirectedFocus = function(node) {
return dir === Dir.FORWARD ? node.nextFocus : node.previousFocus;
};
// Case 1: forwards (find the root-like node).
let directedFocus;
while (endpoint.parent) {
if (directedFocus = getDirectedFocus(endpoint)) {
break;
}
if (AutomationPredicate.root(endpoint)) {
break;
}
endpoint = endpoint.parent;
}
if (directedFocus) {
directedFocus =
(dir === Dir.FORWARD ?
AutomationUtil.findNodePre(
directedFocus, dir, AutomationPredicate.object) :
AutomationUtil.findLastNode(directedFocus, pred)) ||
directedFocus;
return new WrappingCursor(directedFocus, CURSOR_NODE_INDEX);
}
// Always consider this cursor wrapped when moving forward.
let wrapped = dir === Dir.FORWARD;
// Case 2: backward (sync downwards to a leaf), if already on the root.
if (dir === Dir.BACKWARD && endpoint === this.node) {
wrapped = true;
endpoint = AutomationUtil.findLastNode(endpoint, pred) || endpoint;
}
return new WrappingCursor(endpoint, CURSOR_NODE_INDEX, {wrapped});
}
return new WrappingCursor(result.node, result.index);
}
}