chromium/ui/webui/resources/cr_components/history_clusters/url_visit.ts

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

import './page_favicon.js';
import '//resources/cr_elements/cr_action_menu/cr_action_menu.js';
import '//resources/cr_elements/cr_icon_button/cr_icon_button.js';

import {I18nMixinLit} from '//resources/cr_elements/i18n_mixin_lit.js';
import {assert} from '//resources/js/assert.js';
import {loadTimeData} from '//resources/js/load_time_data.js';
import {CrLitElement} from '//resources/lit/v3_0/lit.rollup.js';
import type {PropertyValues} from '//resources/lit/v3_0/lit.rollup.js';

import {BrowserProxyImpl} from './browser_proxy.js';
import type {URLVisit} from './history_cluster_types.mojom-webui.js';
import {Annotation} from './history_cluster_types.mojom-webui.js';
import {getCss} from './url_visit.css.js';
import {getHtml} from './url_visit.html.js';
import {insertHighlightedTextWithMatchesIntoElement} from './utils.js';

/**
 * @fileoverview This file provides a custom element displaying a visit to a
 * page within a cluster. A visit features the page favicon, title, a timestamp,
 * as well as an action menu.
 */

/**
 * Maps supported annotations to localized string identifiers.
 */
const annotationToStringId: Map<number, string> = new Map([
  [Annotation.kBookmarked, 'bookmarked'],
]);

declare global {
  interface HTMLElementTagNameMap {
    'url-visit': UrlVisitElement;
  }
}

const ClusterMenuElementBase = I18nMixinLit(CrLitElement);

export interface UrlVisitElement {
  $: {
    actionMenuButton: HTMLElement,
    title: HTMLElement,
    url: HTMLElement,
  };
}

export class UrlVisitElement extends ClusterMenuElementBase {
  static get is() {
    return 'url-visit';
  }

  static override get styles() {
    return getCss();
  }

  override render() {
    return getHtml.bind(this)();
  }

  static override get properties() {
    return {
      /**
       * The current query for which related clusters are requested and shown.
       */
      query: {type: String},

      /**
       * The visit to display.
       */
      visit: {type: Object},

      /**
       * Whether this visit is within a persisted cluster.
       */
      fromPersistence: {type: Boolean},

      /**
       * Usually this is true, but this can be false if deleting history is
       * prohibited by Enterprise policy.
       */
      allowDeletingHistory_: {type: Boolean},

      /**
       * Whether the cluster is in the side panel.
       */
      inSidePanel_: {
        type: Boolean,
        reflect: true,
      },

      renderActionMenu_: {type: Boolean},
    };
  }

  //============================================================================
  // Properties
  //============================================================================

  query: string = '';
  visit?: URLVisit;
  fromPersistence: boolean = false;
  protected annotations_: string[] = [];
  protected allowDeletingHistory_: boolean =
      loadTimeData.getBoolean('allowDeletingHistory');
  private inSidePanel_: boolean = loadTimeData.getBoolean('inSidePanel');
  protected renderActionMenu_: boolean = false;

  override updated(changedProperties: PropertyValues<this>) {
    super.updated(changedProperties);

    if (changedProperties.has('visit')) {
      assert(this.visit);
      insertHighlightedTextWithMatchesIntoElement(
          this.$.title, this.visit.pageTitle, this.visit.titleMatchPositions);
      insertHighlightedTextWithMatchesIntoElement(
          this.$.url, this.visit.urlForDisplay,
          this.visit.urlForDisplayMatchPositions);
    }
  }

  //============================================================================
  // Event handlers
  //============================================================================

  private onAuxClick_() {
    // Notify the parent <history-cluster> element of this event.
    this.fire('visit-clicked', this.visit);
  }

  protected onClick_(event: MouseEvent) {
    // Ignore previously handled events.
    if (event.defaultPrevented) {
      return;
    }

    event.preventDefault();  // Prevent default browser action (navigation).

    // To record metrics.
    this.onAuxClick_();

    this.openUrl_(event);
  }

  protected onContextMenu_(event: MouseEvent) {
    // Because WebUI has a Blink-provided context menu that's suitable, and
    // Side Panel always UIs always have a custom context menu.
    if (!loadTimeData.getBoolean('inSidePanel') || !this.visit) {
      return;
    }

    BrowserProxyImpl.getInstance().handler.showContextMenuForURL(
        this.visit.normalizedUrl, {x: event.clientX, y: event.clientY});
  }

  protected onKeydown_(e: KeyboardEvent) {
    // To be consistent with <history-list>, only handle Enter, and not Space.
    if (e.key !== 'Enter') {
      return;
    }

    // To record metrics.
    this.onAuxClick_();

    this.openUrl_(e);
  }

  protected async onActionMenuButtonClick_(event: Event) {
    event.preventDefault();  // Prevent default browser action (navigation).

    if (!this.renderActionMenu_) {
      this.renderActionMenu_ = true;
      await this.updateComplete;
    }
    const menu = this.shadowRoot!.querySelector('cr-action-menu');
    assert(menu);
    menu.showAt(this.$.actionMenuButton);
  }

  protected onHideSelfButtonClick_(event: Event) {
    this.emitMenuButtonClick_(event, 'hide-visit');
  }

  protected onRemoveSelfButtonClick_(event: Event) {
    this.emitMenuButtonClick_(event, 'remove-visit');
  }

  private emitMenuButtonClick_(event: Event, emitEventName: string) {
    event.preventDefault();  // Prevent default browser action (navigation).

    this.fire(emitEventName, this.visit);

    // This can also be triggered from the hide visit icon, in which case the
    // menu may not be rendered.
    if (this.renderActionMenu_) {
      const menu = this.shadowRoot!.querySelector('cr-action-menu');
      assert(menu);
      menu.close();
    }
  }

  //============================================================================
  // Helper methods
  //============================================================================

  protected computeAnnotations_(): string[] {
    // Disabling annotations until more appropriate design for annotations in
    // the side panel is complete.
    if (this.inSidePanel_ || !this.visit) {
      return [];
    }
    return this.visit.annotations
        .map((annotation: number) => annotationToStringId.get(annotation))
        .filter(
            (id: string|undefined):
                id is string => {
                  return !!id;
                })
        .map((id: string) => loadTimeData.getString(id));
  }

  protected computeDebugInfo_(): string {
    if (!loadTimeData.getBoolean('isHistoryClustersDebug') || !this.visit) {
      return '';
    }

    return JSON.stringify(this.visit.debugInfo);
  }

  private openUrl_(event: MouseEvent|KeyboardEvent) {
    assert(this.visit);
    BrowserProxyImpl.getInstance().handler.openHistoryCluster(
        this.visit.normalizedUrl, {
          middleButton: (event as MouseEvent).button === 1,
          altKey: event.altKey,
          ctrlKey: event.ctrlKey,
          metaKey: event.metaKey,
          shiftKey: event.shiftKey,
        });
  }
}

customElements.define(UrlVisitElement.is, UrlVisitElement);