blob: 41eaac3a483c0dd214d68e9bab739be5ea9cfba6 [file] [log] [blame]
// Copyright 2021 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 ranges, which are pairs of cursors over
* the automation tree.
*/
import {AutomationPredicate} from '../automation_predicate.js';
import {AutomationUtil} from '../automation_util.js';
import {Cursor, CURSOR_NODE_INDEX, CursorMovement, CursorUnit, WrappingCursor} from './cursor.js';
const AutomationNode = chrome.automation.AutomationNode;
const RoleType = chrome.automation.RoleType;
const StateType = chrome.automation.StateType;
/**
* 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.
*/
export class CursorRange {
/**
* @param {!Cursor} start
* @param {!Cursor} end
*/
constructor(start, end) {
/** @type {!Cursor} @private */
this.start_ = start;
/** @type {!Cursor} @private */
this.end_ = end;
}
/**
* Convenience method to construct a Range surrounding one node.
* @param {!AutomationNode} node
* @return {!CursorRange}
*/
static fromNode(node) {
const cursor = WrappingCursor.fromNode(node);
return new CursorRange(cursor, cursor);
}
/**
* Given |rangeA| and |rangeB| in order, determine which |constants.Dir|
* relates them.
* @param {!CursorRange} rangeA
* @param {!CursorRange} rangeB
* @return {constants.Dir}
*/
static getDirection(rangeA, rangeB) {
if (!rangeA || !rangeB) {
return constants.Dir.FORWARD;
}
if (!rangeA.start.node || !rangeA.end.node || !rangeB.start.node ||
!rangeB.end.node) {
return constants.Dir.FORWARD;
}
// They are the same range.
if (rangeA.start.node === rangeB.start.node &&
rangeB.end.node === rangeA.end.node) {
return constants.Dir.FORWARD;
}
const testDirA =
AutomationUtil.getDirection(rangeA.start.node, rangeB.end.node);
const testDirB =
AutomationUtil.getDirection(rangeB.start.node, rangeA.end.node);
// The two ranges are either partly overlapping or non overlapping.
if (testDirA === constants.Dir.FORWARD && testDirB === constants.Dir.BACKWARD) {
return constants.Dir.FORWARD;
} else if (testDirA === constants.Dir.BACKWARD && testDirB === constants.Dir.FORWARD) {
return constants.Dir.BACKWARD;
} else {
return testDirA;
}
}
/**
* Returns true if |rhs| is equal to this range.
* Use this for strict equality between ranges.
* @param {!CursorRange} rhs
* @return {boolean}
*/
equals(rhs) {
return this.start_.equals(rhs.start) && this.end_.equals(rhs.end);
}
/**
* Similar to above equals(), but does not trigger recovery in either start or
* end cursor. Use this for strict equality between ranges.
* @param {!CursorRange} rhs
* @return {boolean}
*/
equalsWithoutRecovery(rhs) {
return this.start_.equalsWithoutRecovery(rhs.start) &&
this.end_.equalsWithoutRecovery(rhs.end);
}
/**
* Returns true if |rhs| is equal to this range.
* Use this for loose equality between ranges.
* @param {!CursorRange} rhs
* @return {boolean}
*/
contentEquals(rhs) {
return this.start_.contentEquals(rhs.start) &&
this.end_.contentEquals(rhs.end);
}
/**
* Gets the directed end cursor of this range.
* @param {constants.Dir} dir Which endpoint cursor to return;
* constants.Dir.FORWARD for end,
* constants.Dir.BACKWARD for start.
* @return {!Cursor}
*/
getBound(dir) {
return dir === constants.Dir.FORWARD ? this.end_ : this.start_;
}
/**
* Returns true if either start or end of this range requires recovery.
* @return {boolean}
*/
requiresRecovery() {
return this.start_.requiresRecovery() || this.end_.requiresRecovery();
}
/**
* @return {!Cursor}
*/
get start() {
return this.start_;
}
/**
* @return {!Cursor}
*/
get end() {
return this.end_;
}
/**
* Returns true if this range covers less than a node.
* @return {boolean}
*/
isSubNode() {
const startIndex = this.start.index;
const endIndex = this.end.index;
return this.start.node === this.end.node && startIndex !== -1 &&
endIndex !== -1 && startIndex !== endIndex &&
(startIndex !== 0 || endIndex !== this.start.getText().length);
}
/**
* Returns true if this range covers inline text (i.e. each end points to an
* inlineTextBox).
* @return {boolean?}
*/
isInlineText() {
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 {CursorUnit} unit
* @param {constants.Dir} dir
* @return {CursorRange}
*/
move(unit, dir) {
let newStart = this.start_;
if (!newStart.node) {
return this;
}
let newEnd;
switch (unit) {
case CursorUnit.CHARACTER:
newStart = newStart.move(unit, CursorMovement.DIRECTIONAL, dir);
newEnd = newStart.move(
unit, CursorMovement.DIRECTIONAL, constants.Dir.FORWARD);
// Character crossed a node; collapses to the end of the node.
if (newStart.node !== newEnd.node) {
newEnd = new Cursor(newStart.node, newStart.index + 1);
}
break;
case CursorUnit.WORD:
case CursorUnit.LINE:
newStart = newStart.move(unit, CursorMovement.DIRECTIONAL, dir);
newStart =
newStart.move(unit, CursorMovement.BOUND, constants.Dir.BACKWARD);
newEnd =
newStart.move(unit, CursorMovement.BOUND, constants.Dir.FORWARD);
break;
case CursorUnit.NODE:
case CursorUnit.GESTURE_NODE:
newStart = newStart.move(unit, CursorMovement.DIRECTIONAL, dir);
newEnd = newStart;
break;
default:
throw Error('Invalid unit: ' + unit);
}
return new CursorRange(newStart, newEnd);
}
/**
* Select the text contained within this range.
*/
select() {
let start = this.start_;
let end = this.end_;
if (this.start.compare(this.end) === constants.Dir.BACKWARD) {
start = this.end;
end = this.start;
}
const startNode = start.selectionNode;
const endNode = 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.
const startIndex = start.selectionIndex;
let endIndex = end.index === CURSOR_NODE_INDEX ? end.selectionIndex + 1 :
end.selectionIndex;
// If the range covers more than one node, ends on the node, and is over
// text, then adjust the selection to cover the entire end node.
if (start.node !== end.node && end.index === CURSOR_NODE_INDEX &&
AutomationPredicate.text(end.node)) {
endIndex = end.getText().length;
}
// Richly editables should always set a caret, but not select. This
// makes it possible to navigate through content editables using
// ChromeVox keys and not hear selections as you go.
if (startNode.state[StateType.RICHLY_EDITABLE] ||
endNode.state[StateType.RICHLY_EDITABLE]) {
endIndex = startIndex;
}
chrome.automation.setDocumentSelection({
anchorObject: startNode,
anchorOffset: startIndex,
focusObject: endNode,
focusOffset: endIndex,
});
}
}
/**
* Returns a new range that matches to the given unit and direction in the
* current range. If no matching range is found, then null is returned.
* Note that there is a chance that new range's end spans beyond the current
* end when the given unit is larger than the current range.
* @param {CursorUnit} unit
* @param {constants.Dir} dir
* @return {CursorRange}
*/
sync(unit, dir) {
switch (unit) {
case CursorUnit.CHARACTER:
case CursorUnit.WORD:
let startCursor = this.start;
if (!AutomationPredicate.leafWithWordStop(startCursor.node)) {
let startNode = startCursor.node;
if (dir === constants.Dir.FORWARD) {
startNode = AutomationUtil.findNextNode(
startNode, constants.Dir.FORWARD, AutomationPredicate.leafWithWordStop,
{skipInitialSubtree: false});
} else {
startNode = AutomationUtil.findNodePost(
startNode, dir, AutomationPredicate.leafWithWordStop);
}
if (!startNode) {
return null;
}
startCursor = WrappingCursor.fromNode(startNode);
}
const start = startCursor.move(unit, CursorMovement.SYNC, dir);
if (!start) {
return null;
}
let end = start.move(unit, CursorMovement.BOUND, constants.Dir.FORWARD);
if (start.node !== end.node || start.equals(end)) {
// Character crossed a node or reached the end.
// Collapses to the end of the node.
end = new WrappingCursor(start.node, start.getText().length);
}
return new CursorRange(start, end);
case CursorUnit.LINE:
let newNode;
if (dir === constants.Dir.FORWARD) {
newNode = AutomationUtil.findNodeUntil(
this.start.node, dir, AutomationPredicate.linebreak);
} else {
newNode = AutomationUtil.findLastNode(
this.start.node, AutomationPredicate.leaf);
}
if (!newNode) {
return null;
}
return CursorRange.fromNode(newNode);
case CursorUnit.TEXT:
case CursorUnit.NODE:
case CursorUnit.GESTURE_NODE:
const pred = Cursor.getLeafPredForUnit(unit);
let node;
if (dir === constants.Dir.FORWARD) {
node = AutomationUtil.findNextNode(
this.start.node, constants.Dir.FORWARD, pred, {skipInitialSubtree: false});
} else {
node = AutomationUtil.findNodePost(this.start.node, dir, pred);
}
if (!node) {
return null;
}
return CursorRange.fromNode(node);
default:
throw Error('Invalid unit: ' + unit);
}
}
/**
* Returns true if this range has either cursor end on web content.
* @return {boolean}
*/
isWebRange() {
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() {
return this.start.isValid() && this.end.isValid();
}
/**
* Compares this range with |rhs|.
* @param {CursorRange} rhs
* @return {constants.Dir|undefined} constants.Dir.BACKWARD if |rhs| comes
* before this range in
* document order. constants.Dir.FORWARD if |rhs| comes after this range.
* Undefined otherwise.
*/
compare(rhs) {
const startDir = this.start.compare(rhs.start);
const endDir = this.end.compare(rhs.end);
if (startDir !== endDir) {
return undefined;
}
return startDir;
}
/**
* Returns an undirected version of this range.
* @return {!CursorRange}
*/
normalize() {
if (this.start.compare(this.end) === constants.Dir.BACKWARD) {
return new CursorRange(this.end, this.start);
}
return this;
}
/**
* Returns true if this range was created after wrapping. For example, moving
* from a range at the end of a web contents to [this] range at the beginning
* of the document.
* @return {boolean}
*/
get wrapped() {
return this.start_.wrapped || this.end_.wrapped;
}
}