chromium/chrome/browser/resources/chromeos/accessibility/chromevox/background/braille/braille_command_handler.ts

// Copyright 2016 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

/**
 * @fileoverview ChromeVox braille commands.
 */
import {AutomationPredicate} from '/common/automation_predicate.js';
import {AutomationUtil} from '/common/automation_util.js';
import {EventGenerator} from '/common/event_generator.js';
import {KeyCode} from '/common/key_code.js';
import {TestImportManager} from '/common/testing/test_import_manager.js';

import {BrailleCommandData} from '../../common/braille/braille_command_data.js';
import {BrailleKeyCommand, BrailleKeyEvent} from '../../common/braille/braille_key_types.js';
import {NavBraille} from '../../common/braille/nav_braille.js';
import {Command} from '../../common/command.js';
import {EventSourceType} from '../../common/event_source_type.js';
import {Spannable} from '../../common/spannable.js';
import {QueueMode} from '../../common/tts_types.js';
import {ChromeVoxRange} from '../chromevox_range.js';
import {DesktopAutomationInterface} from '../event/desktop_automation_interface.js';
import {EventSource} from '../event_source.js';
import {CommandHandlerInterface} from '../input/command_handler_interface.js';
import {Output} from '../output/output.js';
import {OutputNodeSpan, OutputSelectionSpan} from '../output/output_types.js';
import {ChromeVoxPrefs} from '../prefs.js';

const IntentCommandType = chrome.automation.IntentCommandType;
const IntentMoveDirectionType = chrome.automation.IntentMoveDirectionType;
const IntentTextBoundaryType = chrome.automation.IntentTextBoundaryType;
const RoleType = chrome.automation.RoleType;
const StateType = chrome.automation.StateType;

export class BrailleCommandHandler {
  static instance: BrailleCommandHandler;
  private bypassed_ = false;

  private constructor() {}

  static init(): void {
    if (BrailleCommandHandler.instance) {
      throw new Error(
          'BrailleCommandHandler cannot be instantiated more than once.');
    }
    BrailleCommandHandler.instance = new BrailleCommandHandler();
  }

  /**
   * Global setting for bypassing this handler.
   *
   * Used by LearnMode to capture the events and prevent the standard behavior,
   * in favor of reporting what that behavior would be.
   */
  static setBypass(state: boolean): void {
    BrailleCommandHandler.instance.bypassed_ = state;
  }

  /**
   * Handles a braille command.
   * @return True if evt was processed.
   */
  static onBrailleKeyEvent(evt: BrailleKeyEvent, content: NavBraille): boolean {
    if (BrailleCommandHandler.instance.bypassed_) {
      // Prevent any handling of the event when bypassed.
      return true;
    }

    EventSource.set(EventSourceType.BRAILLE_KEYBOARD);

    // Try to restore to the last valid range.
    ChromeVoxRange.restoreLastValidRangeIfNeeded();

    // Note: panning within content occurs earlier in event dispatch.
    Output.forceModeForNextSpeechUtterance(QueueMode.FLUSH);
    switch (evt.command) {
      case BrailleKeyCommand.PAN_LEFT:
        CommandHandlerInterface.instance.onCommand(Command.PREVIOUS_OBJECT);
        break;
      case BrailleKeyCommand.PAN_RIGHT:
        CommandHandlerInterface.instance.onCommand(Command.NEXT_OBJECT);
        break;
      case BrailleKeyCommand.LINE_UP:
        CommandHandlerInterface.instance.onCommand(Command.PREVIOUS_LINE);
        break;
      case BrailleKeyCommand.LINE_DOWN:
        CommandHandlerInterface.instance.onCommand(Command.NEXT_LINE);
        break;
      case BrailleKeyCommand.TOP:
        CommandHandlerInterface.instance.onCommand(Command.JUMP_TO_TOP);
        break;
      case BrailleKeyCommand.BOTTOM:
        CommandHandlerInterface.instance.onCommand(Command.JUMP_TO_BOTTOM);
        break;
      case BrailleKeyCommand.ROUTING:
        // TODO(b/314203187): Not null asserted, check that this is correct.
        const textEditHandler =
            DesktopAutomationInterface.instance!.textEditHandler;
        textEditHandler?.injectInferredIntents([{
          command: IntentCommandType.MOVE_SELECTION,
          textBoundary: IntentTextBoundaryType.CHARACTER,
          moveDirection: IntentMoveDirectionType.FORWARD,
        }]);
        BrailleCommandHandler.onRoutingCommand_(
            content.text,
            // Cast ok since displayPosition is always defined in this case.
            evt.displayPosition as number);
        break;
      case BrailleKeyCommand.CHORD:
        if (!evt.brailleDots) {
          return false;
        }

        const command = BrailleCommandData.getCommand(evt.brailleDots);
        if (command) {
          if (BrailleCommandHandler.onEditCommand_(command)) {
            CommandHandlerInterface.instance.onCommand(command);
          }
        }
        break;
      default:
        return false;
    }
    return true;
  }

  private static onRoutingCommand_(text: Spannable, position: number): void {
    let actionNodeSpan = null;
    let selectionSpan = null;
    const selSpans = text.getSpansInstanceOf(OutputSelectionSpan);
    const nodeSpans = text.getSpansInstanceOf(OutputNodeSpan);
    for (const selSpan of selSpans) {
      if (text.getSpanStart(selSpan) <= position &&
          position < text.getSpanEnd(selSpan)) {
        selectionSpan = selSpan;
        break;
      }
    }

    let interval;
    for (const nodeSpan of nodeSpans) {
      const intervals = text.getSpanIntervals(nodeSpan);
      const tempInterval = intervals.find(
          inner => inner.start <= position && position <= inner.end);
      if (tempInterval) {
        actionNodeSpan = nodeSpan;
        interval = tempInterval;
      }
    }

    if (!actionNodeSpan) {
      return;
    }

    let actionNode = actionNodeSpan.node;
    const offset = actionNodeSpan.offset;
    if (actionNode.role === RoleType.INLINE_TEXT_BOX) {
      actionNode = actionNode.parent;
    }
    actionNode.doDefault();

    if (actionNode.role !== RoleType.STATIC_TEXT &&
        !actionNode.state[StateType.EDITABLE]) {
      return;
    }

    if (!selectionSpan) {
      selectionSpan = actionNodeSpan;
    }

    if (actionNode.state.richlyEditable) {
      const start = interval?.start ?? text.getSpanStart(selectionSpan);
      const targetPosition = position - start + offset;
      chrome.automation.setDocumentSelection({
        anchorObject: actionNode,
        anchorOffset: targetPosition,
        focusObject: actionNode,
        focusOffset: targetPosition,
      });
    } else {
      const start = text.getSpanStart(selectionSpan);
      const targetPosition = position - start + offset;
      actionNode.setSelection(targetPosition, targetPosition);
    }
  }

  /**
   * Customizes ChromeVox commands when issued from a braille display while
   * within editable text.
   * @return True if the command should propagate.
   */
  private static onEditCommand_(command: string): boolean {
    const current = ChromeVoxRange.current;
    const state = current?.start?.node?.state ?? {};
    if (ChromeVoxPrefs.isStickyModeOn() || !state[StateType.EDITABLE]) {
      return true;
    }

    // TODO(b/314203187): Not null asserted, check that this is correct.
    const textEditHandler =
        DesktopAutomationInterface.instance!.textEditHandler!;
    const editable = AutomationUtil.getEditableRoot(current!.start.node);
    if (!editable || editable !== textEditHandler?.node) {
      return true;
    }

    const isMultiline = AutomationPredicate.multiline(editable);
    switch (command) {
      case 'forceClickOnCurrentItem':
        EventGenerator.sendKeyPress(KeyCode.RETURN);
        break;
      case 'previousCharacter':
        EventGenerator.sendKeyPress(KeyCode.LEFT);
        break;
      case 'nextCharacter':
        EventGenerator.sendKeyPress(KeyCode.RIGHT);
        break;
      case 'previousWord':
        EventGenerator.sendKeyPress(KeyCode.LEFT, {ctrl: true});
        break;
      case 'nextWord':
        EventGenerator.sendKeyPress(KeyCode.RIGHT, {ctrl: true});
        break;
      case 'previousObject':
      case 'previousLine':
        if (!isMultiline || textEditHandler.isSelectionOnFirstLine()) {
          return true;
        }
        EventGenerator.sendKeyPress(KeyCode.UP);
        break;
      case 'nextObject':
      case 'nextLine':
        if (!isMultiline) {
          return true;
        }

        if (textEditHandler.isSelectionOnLastLine()) {
          textEditHandler.moveToAfterEditText();
          return false;
        }

        EventGenerator.sendKeyPress(KeyCode.DOWN);
        break;
      case 'previousGroup':
        EventGenerator.sendKeyPress(KeyCode.UP, {ctrl: true});
        break;
      case 'nextGroup':
        EventGenerator.sendKeyPress(KeyCode.DOWN, {ctrl: true});
        break;
      default:
        return true;
    }
    return false;
  }
}

TestImportManager.exportForTesting(BrailleCommandHandler);