| // 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; |
| } |
| } |