chromium/chrome/browser/resources/lens/overlay/lens_overlay_app.ts

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

import './cursor_tooltip.js';
import './initial_gradient.js';
import './selection_overlay.js';
import './translate_button.js';
import '//resources/cr_elements/cr_icon_button/cr_icon_button.js';
import '//resources/cr_elements/icons.html.js';

import type {CrIconButtonElement} from '//resources/cr_elements/cr_icon_button/cr_icon_button.js';
import type {CrToastElement} from '//resources/cr_elements/cr_toast/cr_toast.js';
import {assert} from '//resources/js/assert.js';
import {skColorToHexColor} from '//resources/js/color_utils.js';
import {EventTracker} from '//resources/js/event_tracker.js';
import {loadTimeData} from '//resources/js/load_time_data.js';
import type {SkColor} from '//resources/mojo/skia/public/mojom/skcolor.mojom-webui.js';
import {PolymerElement} from '//resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {BrowserProxyImpl} from './browser_proxy.js';
import type {BrowserProxy} from './browser_proxy.js';
import {getFallbackTheme} from './color_utils.js';
import type {CursorTooltipData, CursorTooltipElement} from './cursor_tooltip.js';
import {CursorTooltipType} from './cursor_tooltip.js';
import type {InitialGradientElement} from './initial_gradient.js';
import type {OverlayTheme} from './lens.mojom-webui.js';
import {UserAction} from './lens.mojom-webui.js';
import {getTemplate} from './lens_overlay_app.html.js';
import {recordLensOverlayInteraction, recordTimeToWebUIReady} from './metrics_utils.js';
import type {SelectionOverlayElement} from './selection_overlay.js';

export let INVOCATION_SOURCE: string = 'Unknown';

export interface LensOverlayAppElement {
  $: {
    backgroundScrim: HTMLElement,
    closeButton: CrIconButtonElement,
    copyToast: CrToastElement,
    cursorTooltip: CursorTooltipElement,
    initialGradient: InitialGradientElement,
    moreOptionsButton: CrIconButtonElement,
    moreOptionsMenu: HTMLElement,
    selectionOverlay: SelectionOverlayElement,
  };
}

export class LensOverlayAppElement extends PolymerElement {
  static get is() {
    return 'lens-overlay-app';
  }

  static get template() {
    return getTemplate();
  }

  static get properties() {
    return {
      isImageRendered: {
        type: Boolean,
        reflectToAttribute: true,
      },
      initialFlashAnimationHasEnded: {
        type: Boolean,
        reflectToAttribute: true,
      },
      closeButtonHidden: {
        type: Boolean,
        reflectToAttribute: true,
      },
      isClosing: {
        type: Boolean,
        reflectToAttribute: true,
      },
      moreOptionsMenuVisible: {
        type: Boolean,
        reflectToAttribute: true,
      },
      isTranslateButtonVisible: {
        type: Boolean,
        value: loadTimeData.getBoolean('enableOverlayTranslateButton'),
        readOnly: true,
      },
      theme: {
        type: Object,
        value: getFallbackTheme,
      },
    };
  }

  // Whether the image has finished rendering.
  private isImageRendered: boolean = false;
  // Whether the initial flash animation has ended on the selection overlay.
  private initialFlashAnimationHasEnded: boolean = false;
  // Whether the close button should be hidden.
  private closeButtonHidden: boolean = false;
  // Whether the overlay is being shut down.
  private isClosing: boolean = false;
  // Whether more options menu should be shown.
  private moreOptionsMenuVisible: boolean = false;
  // The overlay theme.
  private theme: OverlayTheme;

  private eventTracker_: EventTracker = new EventTracker();

  private browserProxy: BrowserProxy = BrowserProxyImpl.getInstance();
  private listenerIds: number[];
  private invocationTime: number = loadTimeData.getValue('invocationTime');

  // The ID returned by requestAnimationFrame for the updateCursorPosition
  // function.
  private updateCursorPositionRequestId?: number;

  constructor() {
    super();

    this.browserProxy.handler.getOverlayInvocationSource().then(
        ({invocationSource}) => {
          INVOCATION_SOURCE = invocationSource;
        });
  }

  override connectedCallback() {
    super.connectedCallback();

    const callbackRouter = this.browserProxy.callbackRouter;
    this.listenerIds = [
      callbackRouter.themeReceived.addListener(this.themeReceived.bind(this)),
      callbackRouter.notifyResultsPanelOpened.addListener(
          this.onNotifyResultsPanelOpened.bind(this)),
      callbackRouter.notifyOverlayClosing.addListener(() => {
        this.isClosing = true;
      }),
    ];
    this.eventTracker_.add(
        document, 'set-cursor-tooltip', (e: CustomEvent<CursorTooltipData>) => {
          this.$.cursorTooltip.setTooltip(e.detail.tooltipType);
        });
  }

  override disconnectedCallback() {
    super.disconnectedCallback();
    this.listenerIds.forEach(
        id => assert(this.browserProxy.callbackRouter.removeListener(id)));
    this.listenerIds = [];
    this.eventTracker_.removeAll();
  }

  override ready() {
    super.ready();
    this.addEventListener('pointermove', this.updateCursorPosition.bind(this));
    recordTimeToWebUIReady(Number(Date.now() - this.invocationTime));
  }

  private handlePointerEnter() {
    this.$.cursorTooltip.markPointerEnteredContentArea();
  }

  private handlePointerLeave() {
    this.$.cursorTooltip.markPointerLeftContentArea();
  }

  private handlePointerEnterBackgroundScrim() {
    this.$.cursorTooltip.setTooltip(CursorTooltipType.LIVE_PAGE);
    this.$.cursorTooltip.unhideTooltip();
  }

  private handlePointerLeaveBackgroundScrim() {
    this.$.cursorTooltip.hideTooltip();
  }

  private handlePointerEnterSelectionOverlay() {
    this.$.cursorTooltip.unhideTooltip();
  }

  private handlePointerLeaveSelectionOverlay() {
    this.$.cursorTooltip.hideTooltip();
  }

  private onBackgroundScrimClicked() {
    this.browserProxy.handler.closeRequestedByOverlayBackgroundClick();
  }

  private onCloseButtonClick() {
    this.browserProxy.handler.closeRequestedByOverlayCloseButton();
  }

  private onFeedbackClick(event: MouseEvent|KeyboardEvent) {
    if (event instanceof KeyboardEvent &&
        !(event.key === 'Enter' || event.key === ' ')) {
      return;
    }
    this.browserProxy.handler.feedbackRequestedByOverlay();
    this.moreOptionsMenuVisible = false;
    recordLensOverlayInteraction(INVOCATION_SOURCE, UserAction.kSendFeedback);
  }

  private onLearnMoreClick(event: MouseEvent|KeyboardEvent) {
    if (event instanceof KeyboardEvent &&
        !(event.key === 'Enter' || event.key === ' ')) {
      return;
    }
    this.browserProxy.handler.infoRequestedByOverlay({
      middleButton: (event as MouseEvent).button === 1,
      altKey: event.altKey,
      ctrlKey: event.ctrlKey,
      metaKey: event.metaKey,
      shiftKey: event.shiftKey,
    });
    this.moreOptionsMenuVisible = false;
    recordLensOverlayInteraction(INVOCATION_SOURCE, UserAction.kLearnMore);
  }

  private onMoreOptionsButtonClick() {
    this.moreOptionsMenuVisible = !this.moreOptionsMenuVisible;
  }

  private onMyActivityClick(event: MouseEvent|KeyboardEvent) {
    if (event instanceof KeyboardEvent &&
        !(event.key === 'Enter' || event.key === ' ')) {
      return;
    }
    this.browserProxy.handler.activityRequestedByOverlay({
      middleButton: (event as MouseEvent).button === 1,
      altKey: event.altKey,
      ctrlKey: event.ctrlKey,
      metaKey: event.metaKey,
      shiftKey: event.shiftKey,
    });
    this.moreOptionsMenuVisible = false;
    recordLensOverlayInteraction(INVOCATION_SOURCE, UserAction.kMyActivity);
  }

  private onNotifyResultsPanelOpened() {
    this.closeButtonHidden = true;
  }

  private themeReceived(theme: OverlayTheme) {
    this.theme = theme;
  }

  private handleSelectionOverlayClicked() {
    this.$.cursorTooltip.setPauseTooltipChanges(true);
  }

  private handlePointerReleased() {
    this.$.initialGradient.triggerHideScrimAnimation();
    this.$.cursorTooltip.setPauseTooltipChanges(false);
  }

  private onScreenshotRendered() {
    this.isImageRendered = true;
  }

  private onInitialFlashAnimationEnd() {
    this.initialFlashAnimationHasEnded = true;
    this.$.initialGradient.setScrimVisible();
  }

  private async showTextCopiedToast() {
    if (this.$.copyToast.open) {
      // If toast already open, wait after hiding so that animation is
      // smoother.
      await this.$.copyToast.hide();
      setTimeout(() => {
        this.$.copyToast.show();
      }, 100);
    } else {
      this.$.copyToast.show();
    }
  }

  private onHideToastClick() {
    this.$.copyToast.hide();
  }



  private updateCursorPosition(event: PointerEvent) {
    // Cancel the previous animation frame to prevent the code from running more
    // than once a frame.
    if (this.updateCursorPositionRequestId) {
      cancelAnimationFrame(this.updateCursorPositionRequestId);
    }

    // Exit early if the tooltip is not visible.
    if (!this.$.cursorTooltip.isTooltipVisible()) {
      return;
    }

    this.updateCursorPositionRequestId = requestAnimationFrame(() => {
      this.$.cursorTooltip.style.transform =
          `translate3d(${event.clientX}px, ${event.clientY}px, 0)`;
      this.updateCursorPositionRequestId = undefined;
    });
  }

  private skColorToHex_(skColor: SkColor): string {
    return skColorToHexColor(skColor);
  }

  private skColorToRgb_(skColor: SkColor): string {
    const hex = skColorToHexColor(skColor);
    assert(/^#[0-9a-fA-F]{6}$/.test(hex));
    const r = parseInt(hex.substring(1, 3), 16);
    const g = parseInt(hex.substring(3, 5), 16);
    const b = parseInt(hex.substring(5, 7), 16);
    return `${r}, ${g}, ${b}`;
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'lens-overlay-app': LensOverlayAppElement;
  }
}

customElements.define(LensOverlayAppElement.is, LensOverlayAppElement);