chromium/chrome/browser/resources/chromeos/accessibility/chromevox/background/panel/panel_background.ts

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

/**
 * @fileoverview Handles logic for the ChromeVox panel that requires state from
 * the background context.
 */
import {AsyncUtil} from '/common/async_util.js';
import {BridgeHelper} from '/common/bridge_helper.js';
import {constants} from '/common/constants.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 {PanelBridge} from '../../common/panel_bridge.js';
import {ALL_PANEL_MENU_NODE_DATA} from '../../common/panel_menu_data.js';
import {QueueMode} from '../../common/tts_types.js';
import {ChromeVox} from '../chromevox.js';
import {ChromeVoxRange, ChromeVoxRangeObserver} from '../chromevox_range.js';
import {Output} from '../output/output.js';
import {OutputCustomEvent} from '../output/output_types.js';

import {ISearch} from './i_search.js';
import {ISearchHandler} from './i_search_handler.js';
import {PanelNodeMenuBackground} from './panel_node_menu_background.js';

type ActionType = chrome.automation.ActionType;
type AutomationEvent = chrome.automation.AutomationEvent;
type AutomationNode = chrome.automation.AutomationNode;
type CustomAction = chrome.automation.CustomAction;
type Dir = constants.Dir;
const EventType = chrome.automation.EventType;
const TARGET = BridgeConstants.PanelBackground.TARGET;
const Action = BridgeConstants.PanelBackground.Action;

type VoidPromise = Promise<void>;

interface Actions {
  standardActions: ActionType[];
  customActions: CustomAction[];
}

export class PanelBackground implements ISearchHandler {
  private iSearch_: ISearch | null = null;
  private menusLoaded_: VoidPromise = Promise.resolve();
  private savedNode_?: AutomationNode | null;
  private resolvePanelCollapsed_?: VoidPromise;

  tutorialReadyCallback?: VoidFunction;
  tutorialReadyForTesting = false;

  static instance: PanelBackground;
  private static rangeObserver_: PanelRangeObserver;

  private constructor() {}

  static init(): void {
    if (PanelBackground.instance) {
      throw 'Trying to create two copies of singleton PanelBackground';
    }
    PanelBackground.instance = new PanelBackground();
    PanelBackground.rangeObserver_ = new PanelRangeObserver();
    ChromeVoxRange.addObserver(PanelBackground.rangeObserver_);

    BridgeHelper.registerHandler(
        TARGET, Action.CLEAR_SAVED_NODE,
        () => PanelBackground.instance.clearSavedNode_());
    BridgeHelper.registerHandler(
        TARGET, Action.CREATE_ALL_NODE_MENU_BACKGROUNDS,
        (activateMenuTitle?: string) =>
            PanelBackground.instance.createAllNodeMenuBackgrounds_(
                activateMenuTitle));
    BridgeHelper.registerHandler(
        TARGET, Action.CREATE_NEW_I_SEARCH,
        () => PanelBackground.instance.createNewISearch_());
    BridgeHelper.registerHandler(
        TARGET, Action.DESTROY_I_SEARCH,
        () => PanelBackground.instance.destroyISearch_());
    BridgeHelper.registerHandler(
        TARGET, Action.GET_ACTIONS_FOR_CURRENT_NODE,
        () => PanelBackground.instance.getActionsForCurrentNode_());
    BridgeHelper.registerHandler(
        TARGET, Action.INCREMENTAL_SEARCH,
        (searchStr: string, dir: Dir, nextObject?: boolean) =>
            PanelBackground.instance.incrementalSearch_(
                searchStr, dir, nextObject));
    BridgeHelper.registerHandler(
        TARGET, Action.ON_TUTORIAL_READY,
        () => PanelBackground.instance.onTutorialReady_());
    BridgeHelper.registerHandler(
        TARGET, Action.PERFORM_CUSTOM_ACTION_ON_CURRENT_NODE,
        (actionId: number) =>
            PanelBackground.instance.performCustomActionOnCurrentNode_(
                actionId));
    BridgeHelper.registerHandler(
        TARGET, Action.PERFORM_STANDARD_ACTION_ON_CURRENT_NODE,
        (action: ActionType) =>
            PanelBackground.instance.performStandardActionOnCurrentNode_(
                action));
    BridgeHelper.registerHandler(
        TARGET, Action.SAVE_CURRENT_NODE,
        () => PanelBackground.instance.saveCurrentNode_());
    BridgeHelper.registerHandler(
        TARGET, Action.SET_PANEL_COLLAPSE_WATCHER,
        () => PanelBackground.instance.setPanelCollapseWatcher_());
    BridgeHelper.registerHandler(
        TARGET, Action.SET_RANGE_TO_I_SEARCH_NODE,
        () => PanelBackground.instance.setRangeToISearchNode_());
    BridgeHelper.registerHandler(
        TARGET, Action.WAIT_FOR_PANEL_COLLAPSE,
        () => PanelBackground.instance.waitForPanelCollapse_());
  }

  /**
   * Waits for menus that have already started loading to finish.
   * If menus have not started loading, resolves immediately.
   */
  static waitForMenusLoaded(): Promise<void> {
    return PanelBackground.instance?.menusLoaded_ ?? Promise.resolve();
  }

  private clearSavedNode_(): void {
    this.savedNode_ = null;
  }

  /**
   * @param activateMenuTitleId Optional string specifying the activated menu.
   */
  private createAllNodeMenuBackgrounds_(activateMenuTitleId?: string): void {
    if (!this.savedNode_) {
      return;
    }
    const promises: VoidPromise[] = [];
    for (const data of ALL_PANEL_MENU_NODE_DATA) {
      const isActivatedMenu = activateMenuTitleId === data.titleId;
      const menuBackground =
          new PanelNodeMenuBackground(data, this.savedNode_, isActivatedMenu);
      menuBackground.populate();
      promises.push(menuBackground.waitForFinish());
    }
    this.menusLoaded_ = Promise.all(promises) as Promise<any>;
  }

  /**
   * Creates a new ISearch object, ready to search starting from the current
   * ChromeVox focus.
   */
  private createNewISearch_(): void {
    if (this.iSearch_) {
      this.iSearch_.clear();
    }
    // TODO(accessibility): not sure if this actually works anymore since all
    // the refactoring.
    if (!ChromeVoxRange.current || !ChromeVoxRange.current.start) {
      return;
    }
    this.iSearch_ = new ISearch(ChromeVoxRange.current.start);
    this.iSearch_.handler = this;
  }

  /** Destroy the ISearch object so it can be garbage collected. */
  private destroyISearch_(): void {
    // TODO(b/314203187): Not null asserted, check that this is correct.
    this.iSearch_!.handler = null;
    this.iSearch_ = null;
  }

  private getActionsForCurrentNode_(): Actions {
    const result: Actions = {
      standardActions: [],
      customActions: [],
    };
    if (!this.savedNode_) {
      return result;
    }
    if (this.savedNode_.standardActions) {
      result.standardActions = this.savedNode_.standardActions;
    }
    if (this.savedNode_.customActions) {
      result.customActions = this.savedNode_.customActions;
    }
    return result;
  }

  private incrementalSearch_(
      searchStr: string, dir: Dir, nextObject?: boolean): void {
    if (!this.iSearch_) {
      console.error('Trying to search when no ISearch has been created');
      return;
    }

    this.iSearch_.search(searchStr, dir, nextObject);
  }

  private onTutorialReady_(): void {
    this.tutorialReadyForTesting = true;
    if (this.tutorialReadyCallback) {
      this.tutorialReadyCallback();
    }
  }

  private performCustomActionOnCurrentNode_(actionId: number): void {
    if (this.savedNode_) {
      this.savedNode_.performCustomAction(actionId);
    }
  }

  private performStandardActionOnCurrentNode_(action: ActionType): void {
    if (this.savedNode_) {
      this.savedNode_.performStandardAction(action);
    }
  }

  /** Sets the current ChromeVox focus to the current ISearch node. */
  private setRangeToISearchNode_(): void {
    if (!this.iSearch_) {
      console.error(
          'Setting range to ISearch node when no ISearch in progress');
      return;
    }

    const node = this.iSearch_.cursor.node;
    if (!node) {
      return;
    }
    ChromeVoxRange.navigateTo(CursorRange.fromNode(node));
  }

  /** ISearchHandler implementation */
  onSearchReachedBoundary(boundaryNode: AutomationNode): void {
    this.iSearchOutput_(boundaryNode);
    ChromeVox.earcons.playEarcon(EarconId.WRAP);
  }

  /** ISearchHandler implementation */
  onSearchResultChanged(
      node: AutomationNode, start: number, end: number): void {
    this.iSearchOutput_(node, start, end);
  }

  private iSearchOutput_(node: AutomationNode, start?: number, end?: number)
      : void {
    Output.forceModeForNextSpeechUtterance(QueueMode.FLUSH);
    const o = new Output();
    if (start && end) {
      // TODO(b/314203187): Not null asserted, check that this is correct.
      o.withString([
        node.name!.substr(0, start),
        node.name!.substr(start, end - start),
        node.name!.substr(end),
      ].join(', '));
      o.format('$role', node);
    } else {
      o.withRichSpeechAndBraille(
          CursorRange.fromNode(node), undefined, OutputCustomEvent.NAVIGATE);
    }
    o.go();

    ChromeVoxRange.set(CursorRange.fromNode(node));
  }

  /** Adds an event listener to detect panel collapse. */
  private async setPanelCollapseWatcher_(): Promise<void> {
    const desktop = await AsyncUtil.getDesktop();
    let notifyPanelCollapsed: VoidFunction;
    this.resolvePanelCollapsed_ = new Promise<void>(resolve => {
      notifyPanelCollapsed = resolve;
    });
    const onFocus = (event: AutomationEvent): void => {
      if (event.target!.docUrl &&
          event.target!.docUrl.includes('chromevox/panel')) {
        return;
      }

      desktop.removeEventListener(EventType.FOCUS, onFocus, true);
      desktop.removeEventListener(EventType.BLUR, onBlur, true);

      // Clears focus on the page by focusing the root explicitly. This makes
      // sure we don't get future focus events as a result of giving this
      // entire page focus (which would interfere with our desired range).
      if (event.target.root) {
        event.target.root.focus();
      }

      notifyPanelCollapsed();
    };

    const onBlur = (event: AutomationEvent): void => {
      if (!event.target.docUrl ||
          !event.target.docUrl.includes('chromevox/panel')) {
        return;
      }

      desktop.removeEventListener(EventType.BLUR, onBlur, true);
      desktop.removeEventListener(EventType.FOCUS, onFocus, true);

      notifyPanelCollapsed();
    };


    desktop.addEventListener(EventType.BLUR, onBlur, true);
    desktop.addEventListener(EventType.FOCUS, onFocus, true);
  }

  private saveCurrentNode_(): void {
    if (ChromeVoxRange.current) {
      this.savedNode_ = ChromeVoxRange.current.start.node;
    }
  }

  /** Wait for the promise to notify panel collapse to resolved. */
  private async waitForPanelCollapse_(): Promise<void> {
    return this.resolvePanelCollapsed_;
  }
}


class PanelRangeObserver implements ChromeVoxRangeObserver {
  /** ChromeVoxRangeObserver implementation */
  onCurrentRangeChanged(_range: CursorRange, _fromEditing?: boolean): void {
    PanelBridge.onCurrentRangeChanged();
  }
}

TestImportManager.exportForTesting(PanelBackground);