// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* @fileoverview Classes that handle the ChromeVox range.
*/
import {AutomationPredicate} from '/common/automation_predicate.js';
import {AutomationUtil} from '/common/automation_util.js';
import {BridgeHelper} from '/common/bridge_helper.js';
import {constants} from '/common/constants.js';
import {Cursor} from '/common/cursors/cursor.js';
import {CursorRange} from '/common/cursors/range.js';
import {TestImportManager} from '/common/testing/test_import_manager.js';
import {BridgeConstants} from '../common/bridge_constants.js';
import {EarconId} from '../common/earcon_id.js';
import {TtsSpeechProperties} from '../common/tts_types.js';
import {ChromeVox} from './chromevox.js';
import {ChromeVoxState} from './chromevox_state.js';
import {DesktopAutomationInterface} from './event/desktop_automation_interface.js';
import {FocusBounds} from './focus_bounds.js';
import {MathHandler} from './math_handler.js';
import {Output} from './output/output.js';
import {OutputCustomEvent} from './output/output_types.js';
type AutomationNode = chrome.automation.AutomationNode;
const Dir = constants.Dir;
const RoleType = chrome.automation.RoleType;
const StateType = chrome.automation.StateType;
const Action = BridgeConstants.ChromeVoxRange.Action;
const TARGET = BridgeConstants.ChromeVoxRange.TARGET;
interface Point {
x: number;
y: number;
}
/**
* An interface implemented by objects to observe ChromeVox range changes.
* TODO(b/346347267): Convert to an interface post-TypeScript migration.
*/
export abstract class ChromeVoxRangeObserver {
/** @param range The new range. */
abstract onCurrentRangeChanged(
range: CursorRange | null, fromEditing?: boolean): void;
}
/** Handles tracking of and changes to the ChromeVox range. */
export class ChromeVoxRange {
private current_: CursorRange | null = null;
private pageSel_: CursorRange | null = null;
private previous_: CursorRange | null = null;
private static observers_: ChromeVoxRangeObserver[] = [];
static instance: ChromeVoxRange;
private constructor() {}
static init(): void {
if (ChromeVoxRange.instance) {
throw new Error('Cannot create more than one ChromeVoxRange');
}
ChromeVoxRange.instance = new ChromeVoxRange();
BridgeHelper.registerHandler(
TARGET, Action.CLEAR_CURRENT_RANGE, () => ChromeVoxRange.set(null));
}
static get current(): CursorRange | null {
if (ChromeVoxRange.instance.current_?.isValid()) {
return ChromeVoxRange.instance.current_;
}
return null;
}
static clearSelection(): void {
ChromeVoxRange.instance.pageSel_ = null;
}
/** Return the current range, but focus recovery is not applied to it. */
static getCurrentRangeWithoutRecovery(): CursorRange | null {
return ChromeVoxRange.instance.current_;
}
/**
* Check for loss of focus which results in us invalidating our current range.
*/
static maybeResetFromFocus(): void {
ChromeVoxRange.instance.maybeResetFromFocus_();
}
/**
* Navigate to the given range - it both sets the range and outputs it.
* @param {!CursorRange} range The new range.
* @param {boolean=} opt_focus Focus the range; defaults to true.
* @param {TtsSpeechProperties=} opt_speechProps Speech properties.
* @param {boolean=} opt_skipSettingSelection If true, does not set
* the selection, otherwise it does by default.
*/
static navigateTo(
range: CursorRange, focus?: boolean, speechProps?: TtsSpeechProperties,
skipSettingSelection?: boolean): void {
ChromeVoxRange.instance.navigateTo_(
range, focus, speechProps, skipSettingSelection);
}
/** Restores the last valid ChromeVox range. */
static restoreLastValidRangeIfNeeded(): void {
ChromeVoxRange.instance.restoreLastValidRangeIfNeeded_();
}
static set(newRange: CursorRange | null, fromEditing?: boolean): void {
ChromeVoxRange.instance.set_(newRange, fromEditing);
}
/**
* @return true if the selection is toggled on, false if it is toggled off.
*/
static toggleSelection(): boolean {
return ChromeVoxRange.instance.toggleSelection_();
}
// ================= Observer Functions =================
static addObserver(observer: ChromeVoxRangeObserver): void {
ChromeVoxRange.observers_.push(observer);
}
static removeObserver(observer: ChromeVoxRangeObserver): void {
const index = ChromeVoxRange.observers_.indexOf(observer);
if (index > -1) {
ChromeVoxRange.observers_.splice(index, 1);
}
}
// ================= Private Methods =================
/**
* Check for loss of focus which results in us invalidating our current
* range. Note the getFocus() callback is synchronous, so the focus will be
* updated when this function returns (despite being technicallly a separate
* function call). Note: do not convert this method to async, as it would
* change the execution order described above.
*/
private maybeResetFromFocus_(): void {
chrome.automation.getFocus((focus: AutomationNode | undefined) => {
const cur = ChromeVoxRange.current;
// If the current node is not valid and there's a current focus:
if (cur && !cur.isValid() && focus) {
ChromeVoxRange.set(CursorRange.fromNode(focus));
}
// If there's no focused node:
if (!focus) {
ChromeVoxRange.set(null);
return;
}
// This case detects when TalkBack (in ARC++) is enabled (which also
// covers when the ARC++ window is active). Clear the ChromeVox range
// so keys get passed through for ChromeVox commands.
// TODO(b/314203187): Not null asserted, check that this is correct.
if (ChromeVoxState.instance!.talkBackEnabled &&
// This additional check is not strictly necessary, but we use it to
// ensure we are never inadvertently losing focus. ARC++ windows set
// "focus" on a root view.
focus.role === RoleType.CLIENT) {
ChromeVoxRange.set(null);
}
});
}
/**
* Navigate to the given range - it both sets the range and outputs it.
* @param focus Focus the range; defaults to true.
*/
private navigateTo_(
range: CursorRange, focus?: boolean, speechProps?: TtsSpeechProperties,
skipSettingSelection?: boolean): void {
focus = focus ?? true;
speechProps = speechProps ?? new TtsSpeechProperties();
skipSettingSelection = skipSettingSelection ?? false;
const prevRange = ChromeVoxRange.getCurrentRangeWithoutRecovery();
// Specialization for math output.
let skipOutput = false;
if (MathHandler.init(range)) {
// TODO(b/314203187): Not null asserted, check that this is correct.
skipOutput = MathHandler.instance!.speak();
focus = false;
}
if (focus) {
this.setFocusToRange_(range, prevRange);
}
ChromeVoxRange.set(range);
const o = new Output();
let selectedRange;
let msg;
if (this.pageSel_?.isValid() && range.isValid()) {
// Suppress hints.
o.withoutHints();
// Selection across roots isn't supported.
const pageRootStart = this.pageSel_.start.node.root;
const pageRootEnd = this.pageSel_.end.node.root;
const curRootStart = range.start.node.root;
const curRootEnd = range.end.node.root;
// Deny crossing over the start of the page selection and roots.
if (pageRootStart !== pageRootEnd || pageRootStart !== curRootStart ||
pageRootEnd !== curRootEnd) {
o.format('@end_selection');
// TODO(b/314203187): Not null asserted, check that this is correct.
DesktopAutomationInterface.instance!.ignoreDocumentSelectionFromAction(
false);
this.pageSel_ = null;
} else {
// Expand or shrink requires different feedback.
// Page sel is the only place in ChromeVox where we used directed
// selections. It is important to keep track of the directedness in
// places, but when comparing to other ranges, take the undirected
// range.
const dir = this.pageSel_.normalize().compare(range);
if (dir) {
// Directed expansion.
msg = '@selected';
} else {
// Directed shrink.
msg = '@unselected';
selectedRange = prevRange;
}
const wasBackwardSel =
this.pageSel_.start.compare(this.pageSel_.end) === Dir.BACKWARD ||
dir === Dir.BACKWARD;
this.pageSel_ = new CursorRange(
this.pageSel_.start, wasBackwardSel ? range.start : range.end);
this.pageSel_.select();
}
} else if (!skipSettingSelection) {
// Ensure we don't select the editable when we first encounter it.
let lca: AutomationNode | null | undefined = null;
if (range.start.node && prevRange?.start.node) {
lca = AutomationUtil.getLeastCommonAncestor(
prevRange!.start.node, range.start.node);
}
// TODO(b/314203187): Not null asserted, check that this is correct.
if (!lca || lca.state![StateType.EDITABLE] ||
!range.start.node.state![StateType.EDITABLE]) {
range.select();
}
}
o.withRichSpeechAndBraille(
selectedRange ?? range, prevRange ?? undefined,
OutputCustomEvent.NAVIGATE)
.withInitialSpeechProperties(speechProps);
if (msg) {
o.format(msg);
}
if (!skipOutput) {
o.go();
}
}
private notifyObservers_(range: CursorRange | null, fromEditing?: boolean)
: void {
for (const observer of ChromeVoxRange.observers_) {
observer.onCurrentRangeChanged(range, fromEditing);
}
}
private restoreLastValidRangeIfNeeded_(): void {
// Never restore range when TalkBack is enabled as commands such as
// Search+Left, go directly to TalkBack.
// TODO(b/314203187): Not null asserted, check that this is correct.
if (ChromeVoxState.instance!.talkBackEnabled) {
return;
}
if (!this.current_?.isValid()) {
this.current_ = this.previous_;
}
}
private set_(newRange: CursorRange | null, fromEditing?: boolean): void {
// Clear anything that was frozen on the braille display whenever
// the user navigates.
ChromeVox.braille.thaw();
// There's nothing to be updated in this case.
if ((!newRange && !this.current_) || (newRange && !newRange.isValid())) {
FocusBounds.set([]);
return;
}
this.previous_ = this.current_;
this.current_ = newRange;
this.notifyObservers_(newRange, fromEditing);
if (!this.current_) {
FocusBounds.set([]);
return;
}
const start = this.current_.start.node;
start.makeVisible();
chrome.metricsPrivate.recordBoolean(
'Accessibility.ScreenReader.ScrollToImage',
start.role === RoleType.IMAGE);
start.setAccessibilityFocus();
const root = AutomationUtil.getTopLevelRoot(start);
if (!root || root.role === RoleType.DESKTOP || root === start) {
return;
}
// TODO(b/314203187): Not null asserted, check that this is correct.
const loc = start.unclippedLocation!;
const x = loc.left + loc.width / 2;
const y = loc.top + loc.height / 2;
const position: Point = {x, y};
let url = root.docUrl!;
url = url.substring(0, url.indexOf('#')) || url;
ChromeVoxState.position[url] = position;
}
private setFocusToRange_(range: CursorRange, prevRange: CursorRange | null)
: void {
const start = range.start.node;
const end = range.end.node;
// First, see if we've crossed a root. Remove once webview handles focus
// correctly.
if (prevRange && prevRange.start.node && start) {
const entered =
AutomationUtil.getUniqueAncestors(prevRange.start.node, start);
const isPluginOrIframe =
AutomationPredicate.roles([RoleType.PLUGIN_OBJECT, RoleType.IFRAME]);
entered.filter(isPluginOrIframe).forEach((container: AutomationNode) => {
// TODO(b/314203187): Not null asserted, check that this is correct.
if (!container.state![StateType.FOCUSED]) {
container.focus();
}
});
}
// TODO(b/314203187): Not null asserted, check that this is correct.
if (start.state![StateType.FOCUSED] || end.state![StateType.FOCUSED]) {
return;
}
// TODO(b/314203187): Not null asserted, check that this is correct.
const isFocusableLinkOrControl = (node: AutomationNode): boolean =>
node.state![StateType.FOCUSABLE] &&
AutomationPredicate.linkOrControl(node);
// Next, try to focus the start or end node.
// TODO(b/314203187): Not null asserted, check that this is correct.
if (!AutomationPredicate.structuralContainer(start) &&
start.state![StateType.FOCUSABLE]) {
if (!start.state![StateType.FOCUSED]) {
start.focus();
}
return;
} else if (
!AutomationPredicate.structuralContainer(end) &&
end.state![StateType.FOCUSABLE]) {
if (!end.state![StateType.FOCUSED]) {
end.focus();
}
return;
}
// If a common ancestor of |start| and |end| is a link, focus that.
let ancestor = AutomationUtil.getLeastCommonAncestor(start, end);
while (ancestor && ancestor.root === start.root) {
if (isFocusableLinkOrControl(ancestor)) {
// TODO(b/314203187): Not null asserted, check that this is correct.
if (!ancestor.state![StateType.FOCUSED]) {
ancestor.focus();
}
return;
}
ancestor = ancestor.parent;
}
// If nothing is focusable, set the sequential focus navigation starting
// point, which ensures that the next time you press Tab, you'll reach
// the next or previous focusable node from |start|.
// TODO(b/314203187): Not null asserted, check that this is correct.
if (!start.state![StateType.OFFSCREEN]) {
start.setSequentialFocusNavigationStartingPoint();
}
}
/** @return true if the selection is toggled on, false if toggled off. */
private toggleSelection_(): boolean {
if (!this.pageSel_) {
ChromeVox.earcons.playEarcon(EarconId.SELECTION);
this.pageSel_ = ChromeVoxRange.current;
// TODO(b/314203187): Not null asserted, check that this is correct.
DesktopAutomationInterface.instance!.ignoreDocumentSelectionFromAction(
true);
return true;
} else {
// TODO(b/314203187): Not null asserted, check that this is correct.
const root = this.current_!.start.node.root;
if (root && root.selectionStartObject && root.selectionEndObject &&
!isNaN(Number(root.selectionStartOffset)) &&
!isNaN(Number(root.selectionEndOffset))) {
ChromeVox.earcons.playEarcon(EarconId.SELECTION_REVERSE);
// TODO(b/314203187): Not null asserted, check that this is correct.
const sel = new CursorRange(
new Cursor(root.selectionStartObject, root.selectionStartOffset!),
new Cursor(root.selectionEndObject, root.selectionEndOffset!));
new Output()
.format('@end_selection')
.withSpeechAndBraille(sel, sel, OutputCustomEvent.NAVIGATE)
.go();
// TODO(b/314203187): Not null asserted, check that this is correct.
DesktopAutomationInterface.instance!.ignoreDocumentSelectionFromAction(
false);
}
this.pageSel_ = null;
return false;
}
}
}
TestImportManager.exportForTesting(ChromeVoxRange, ChromeVoxRangeObserver);