chromium/chrome/browser/resources/chromeos/accessibility/accessibility_common/autoclick/autoclick.ts

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

import {EventHandler} from '/common/event_handler.js';

/**
 * The hex color for the focus rings.
 */
const AUTOCLICK_FOCUS_RING_COLOR = '#aac9fa';

/**
 * The amount of time to wait before hiding the focus rings from the display.
 */
const AUTOCLICK_FOCUS_RING_DISPLAY_TIME_MS = 250;

/**
 * Class to manage Automatic Clicks' interaction with the accessibility tree.
 */
export class Autoclick {
  /**
   * Whether to blink the focus rings. Disabled during tests due to
   * complications with callbacks.
   */
  private blinkFocusRings_: boolean = true;

  private desktop_: chrome.automation.AutomationNode|null = null;

  private scrollableBoundsListener_:
      ((x: number, y: number) => void)|null = null;

  private hitTestHandler_: EventHandler;

  private onLoadDesktopCallbackForTest_: Function|null = null;


  constructor() {
    this.hitTestHandler_ = new EventHandler(
        [], chrome.automation.EventType.MOUSE_PRESSED,
        event => this.onAutomationHitTestResult_(event), {
          capture: true,
          exactMatch: false,
          listenOnce: false,
          predicate: undefined,
        });

    this.init_();
  }

  setNoBlinkFocusRingsForTest(): void {
    this.blinkFocusRings_ = false;
  }

  /**
   * Destructor to remove any listeners.
   */
  onAutoclickDisabled(): void {
    if (this.scrollableBoundsListener_) {
      chrome.accessibilityPrivate.onScrollableBoundsForPointRequested
          .removeListener(this.scrollableBoundsListener_);
      this.scrollableBoundsListener_ = null;
    }

    this.hitTestHandler_.stop();
  }

  /**
   * Initializes Autoclick.
   */
  private init_(): void {
    this.scrollableBoundsListener_ = (x: number, y: number) =>
        this.findScrollingContainerForPoint_(x, y);

    chrome.automation.getDesktop(desktop => {
      this.desktop_ = desktop;

      // We use a hit test at a point to determine what automation node is
      // at that point, in order to find the scrollable area.
      this.hitTestHandler_.setNodes(this.desktop_);
      this.hitTestHandler_.start();

      if (this.onLoadDesktopCallbackForTest_) {
        this.onLoadDesktopCallbackForTest_();
        this.onLoadDesktopCallbackForTest_ = null;
      }
    });

    chrome.accessibilityPrivate.onScrollableBoundsForPointRequested.addListener(
        this.scrollableBoundsListener_);
  }

  /**
   * Sets the focus ring to |rects|.
   */
  private setFocusRings_(rects: chrome.accessibilityPrivate.ScreenRect[]):
      void {
    // TODO(katie): Add a property to FocusRingInfo to set FocusRingBehavior
    // to fade out.
    chrome.accessibilityPrivate.setFocusRings(
        [{
          rects,
          type: chrome.accessibilityPrivate.FocusType.SOLID,
          color: AUTOCLICK_FOCUS_RING_COLOR,
          secondaryColor: AUTOCLICK_FOCUS_RING_COLOR,
        }],
        chrome.accessibilityPrivate.AssistiveTechnologyType.AUTO_CLICK);
  }

  /**
   * Calculates whether a node should be highlighted as scrollable.
   * Returns True  if the node should be highlighted as scrollable.
   */
  private shouldHighlightAsScrollable_(node: chrome.automation.AutomationNode):
      boolean {
    if (node.scrollable === undefined || !node.scrollable) {
      return false;
    }

    // Check that the size of the scrollable area is larger than the node's
    // location. If it is not larger, then scrollbars are not shown.
    return node.scrollXMax! - node.scrollXMin! > node.location.width ||
        node.scrollYMax! - node?.scrollYMin! > node.location.height;
  }

  /**
   * Processes an automation hit test result.
   */
  private onAutomationHitTestResult_(event: chrome.automation.AutomationEvent):
      void {
    // Walk up to the nearest scrollale area containing the point.
    let node = event.target;
    while (node.parent && node.role !== chrome.automation.RoleType.WINDOW &&
           node.role !== chrome.automation.RoleType.ROOT_WEB_AREA &&
           node.role !== chrome.automation.RoleType.DESKTOP &&
           node.role !== chrome.automation.RoleType.DIALOG &&
           node.role !== chrome.automation.RoleType.ALERT_DIALOG &&
           node.role !== chrome.automation.RoleType.TOOLBAR) {
      if (this.shouldHighlightAsScrollable_(node)) {
        break;
      }
      node = node.parent;
    }
    if (!node.location) {
      return;
    }
    const bounds = node.location;
    this.setFocusRings_([bounds]);
    if (this.blinkFocusRings_) {
      // Blink the focus ring briefly per UX spec, using timeouts to turn it
      // off, on, and off again. The focus ring is only used to show the user
      // where the scroll might occur, but is not persisted after the blink.
      // Turn off after 500 ms.
      setTimeout(() => {
        this.setFocusRings_([]);
      }, AUTOCLICK_FOCUS_RING_DISPLAY_TIME_MS * 2);
      // Back on after an additional 250 ms.
      setTimeout(() => {
        this.setFocusRings_([bounds]);
      }, AUTOCLICK_FOCUS_RING_DISPLAY_TIME_MS * 3);
      // And off after another 500 ms.
      setTimeout(() => {
        this.setFocusRings_([]);
      }, AUTOCLICK_FOCUS_RING_DISPLAY_TIME_MS * 5);
    }
    chrome.accessibilityPrivate.handleScrollableBoundsForPointFound(bounds);
  }

  /**
   * Initiates finding the nearest scrolling container for the given point.
   */
  private findScrollingContainerForPoint_(x: number, y: number): void {
    // The hit test will come back through onAutomationHitTestResult_,
    // which will do the logic for finding the scrolling container.
    this.desktop_!.hitTest(x, y, chrome.automation.EventType.MOUSE_PRESSED);
  }

  /**
   * Used by C++ tests to ensure Autoclick JS load is completed.
   * `callback` Callback for when desktop is loaded from
   * automation.
   */
  setOnLoadDesktopCallbackForTest(callback: Function): void {
    if (!this.desktop_) {
      this.onLoadDesktopCallbackForTest_ = callback;
      return;
    }
    // Desktop already loaded.
    callback();
  }
}