chromium/chrome/browser/resources/chromeos/emoji_picker/emoji_group.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 'chrome://resources/ash/common/cr_elements/cr_icon_button/cr_icon_button.js';
import 'chrome://resources/polymer/v3_0/paper-tooltip/paper-tooltip.js';
import './emoji_variants.js';

import {assertInstanceof} from 'chrome://resources/js/assert.js';
import {PaperTooltipElement} from 'chrome://resources/polymer/v3_0/paper-tooltip/paper-tooltip.js';
import {beforeNextRender, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {VISUAL_CONTENT_WIDTH} from './constants.js';
import {getTemplate} from './emoji_group.html.js';
import {EmojiImageComponent} from './emoji_image.js';
import {EmojiPickerApiProxy} from './emoji_picker_api_proxy.js';
import {createCustomEvent, EMOJI_CLEAR_RECENTS_CLICK, EMOJI_IMG_BUTTON_CLICK, EMOJI_TEXT_BUTTON_CLICK, EMOJI_VARIANTS_SHOWN, EmojiClearRecentClickEvent, EmojiTextButtonClickEvent} from './events.js';
import {CategoryEnum, EmojiVariants, Gender, PreferenceMapping, Tone} from './types.js';

// Note - grid-layout and flex-layout names are used directly in CSS.
export enum EmojiGroupLayoutType {
  GRID_LAYOUT = 'grid-layout',
  FLEX_LAYOUT = 'flex-layout',
  TWO_COLUMN_LAYOUT = 'two-column-layout',
}

enum SideEnum {
  LEFT = 'left',
  RIGHT = 'right',
}

const DEFAULT_CATEGORY_LAYOUTS = {
  [CategoryEnum.EMOJI]: EmojiGroupLayoutType.GRID_LAYOUT,
  [CategoryEnum.EMOTICON]: EmojiGroupLayoutType.FLEX_LAYOUT,
  [CategoryEnum.SYMBOL]: EmojiGroupLayoutType.GRID_LAYOUT,
  [CategoryEnum.GIF]: EmojiGroupLayoutType.TWO_COLUMN_LAYOUT,
};

export interface EmojiGroupComponent {
  $: {
    tooltip: PaperTooltipElement,
  };
}

export class EmojiGroupComponent extends PolymerElement {
  static get is() {
    return 'emoji-group' as const;
  }

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

  static get properties() {
    return {
      data: {type: Array, readonly: true},
      group: {type: String, value: null, readonly: true},
      globalTone: {type: Number, value: null, readonly: true},
      globalGender: {type: Number, value: null, readonly: true},
      preferred: {type: Object, value: () => ({})},
      clearable: {type: Boolean, value: false},
      useGroupedPreference: {type: Boolean, value: false},
      category: {
        type: String,
        value: CategoryEnum.EMOJI,
        readonly: true,
      },
      layoutType: {
        type: String,
        value: null,
      },
      showClearRecents: {type: Boolean, value: false},
      focusedEmoji: {type: Object, value: null},
      shownEmojiVariantIndex: {type: Number, value: null},
      isLangEnglish: {type: Boolean, value: false},
      gifSupport: {type: Boolean, value: false},
    };
  }
  data: EmojiVariants[];
  group: string|null;
  private globalTone: Tone|null = null;
  private globalGender: Gender|null = null;
  preferred: PreferenceMapping;
  clearable: boolean;
  useGroupedPreference: boolean;
  category: CategoryEnum;
  layoutType: string|null;
  showClearRecents: boolean;
  private focusedEmoji: EmojiVariants|null;
  private shownEmojiVariantIndex: number|null;
  private isLangEnglish: boolean;
  private gifSupport: boolean;

  constructor() {
    super();

    // TODO(crbug/1227852): Remove after setting arial label to emoji.
    this.isLangEnglish =
        navigator.languages.some(lang => lang.startsWith('en'));

    // Some methods will be passed down to child elements and thus we need
    // `bind(this)`.
    this.onEmojiClick = this.onEmojiClick.bind(this);
    this.showTooltip = this.showTooltip.bind(this);
  }

  /**
   * Handles the click event for show-clear button which results
   * in showing "clear recently used emojis" button.
   */
  onClearClick(ev: Event): void {
    ev.preventDefault();
    ev.stopPropagation();
    this.showClearRecents = true;
  }

  /**
   * Handles the event for clicking on the "clear recently used" button.
   * It makes "show-clear" button disappear and fires an event
   * indicating that the "clear recently used" is clicked.
   */
  private onClearRecentsClick(ev: Event): void {
    ev.preventDefault();
    ev.stopPropagation();
    this.showClearRecents = false;
    this.dispatchEvent(createCustomEvent(
        EMOJI_CLEAR_RECENTS_CLICK, {category: this.category}));
  }

  /**
   * Sets and shows tooltips for an emoji button based on a mouse/focus event.
   * By handling the on-focus and on-mouseenter events using this function,
   * one single paper-tool is reused for all emojis in the emoji-group.
   *
   */
  private showTooltip(event: MouseEvent|FocusEvent): void {
    // Target must always exist since this is triggered by a mouse/focus on an
    // element.
    const emoji = this.findEmojiOfEmojiButton(event.target as HTMLElement);

    // If the event is for an emoji button that is not already
    // focused, then replace the target of paper-tooltip with the new
    // emoji button.
    if (emoji && this.focusedEmoji !== emoji) {
      this.focusedEmoji = emoji;

      // Set the target of paper-tooltip to the focused emoji button.
      // Paper-tooltip will un-listen the events of the previous target and
      // starts listening the events for the focused emoji button to hide and
      // show the tooltip at the right time.
      this.$.tooltip.target = event.target;
      this.$.tooltip.show();
    }
  }

  /**
   * Handles event of clicking on an emoji button. It finds the emoji details
   * for the clicked emoji and fires another event including these the details.
   * Note: Initially, it validates and returns if the event is not for an
   * emoji button.
   *
   */
  private onEmojiClick(event: MouseEvent): void {
    const emoji =
        this.findEmojiOfEmojiButton(event.target as HTMLElement | null);

    // Ensure target is an emoji button.
    if (!emoji) {
      return;
    }

    // Text-based emoji clicked
    if (emoji.base.string) {
      const text = this.getDisplayEmojiForEmoji(emoji.base.string, emoji);

      this.dispatchEvent(createCustomEvent(EMOJI_TEXT_BUTTON_CLICK, {
        name: emoji.base.name,
        category: this.category,
        text,
        baseEmoji: emoji.base.string,
        isVariant: text !== emoji.base.string,
        groupedTone: false,
        groupedGender: false,
        alternates: emoji.alternates ?? [],
      }));
    } else {
      if (emoji.base.visualContent) {
        // Visual-based emoji clicked
        this.dispatchEvent(createCustomEvent(EMOJI_IMG_BUTTON_CLICK, {
          name: emoji.base.name,
          visualContent: emoji.base.visualContent,
          category: this.category,
        }));
      }
    }
  }

  private onHelpClick(): void {
    EmojiPickerApiProxy.getInstance().openHelpCentreArticle();
  }

  /**
   * Handles event of opening context menu of an emoji button. Emoji variants
   * are shown as the context menu.
   * Note: Initially, it validates and returns if the event is not for an
   * emoji button.
   */
  private onEmojiContextMenu(event: Event): void {
    const emoji =
        this.findEmojiOfEmojiButton(event.target as HTMLElement | null);

    // Ensure target is an emoji button.
    if (!emoji) {
      return;
    }
    event.preventDefault();

    assertInstanceof(event.target, HTMLElement);
    const dataIndex = Number(
        // This assert is safe as this can only be triggered via right click on
        // an html element.
        event.target.getAttribute('data-index'));

    // If the variants of the emoji is already shown, then hide it.
    // Otherwise, show the variants if there are some.
    if (emoji.alternates && emoji.alternates.length &&
        dataIndex !== this.shownEmojiVariantIndex) {
      this.shownEmojiVariantIndex = dataIndex;
    } else {
      this.shownEmojiVariantIndex = null;
    }

    // Send event so emoji-picker knows to close other variants.
    // need to defer this until <emoji-variants> is created and sized by
    // Polymer.
    beforeNextRender(this, () => {
      const variants = this.shownEmojiVariantIndex ?
          this.shadowRoot!.getElementById(`emoji-variant-${dataIndex}`) ??
              undefined :
          undefined;

      this.dispatchEvent(createCustomEvent(EMOJI_VARIANTS_SHOWN, {
        owner: this,
        variants: variants,
        baseEmoji: emoji.base.string,
      }));
    });
  }

  /**
   * Returns whether the emoji has variants or not.
   * Does not use `this`.
   */
  private hasVariants(emoji: EmojiVariants): boolean {
    // TODO: b/322909764 - The type of `EmojiVariants.alternates` cannot be
    // null/undefined, so the `!== undefined` check should be redundant. Either
    // add undefined to the type, or remove the below check.
    return emoji.alternates !== undefined && emoji.alternates.length > 0;
  }

  /**
   * Returns HTML class attribute of an emoji groups.
   */
  private getLayoutClassName(
      layoutType: EmojiGroupLayoutType,
      category: CategoryEnum): EmojiGroupLayoutType {
    if (layoutType) {
      return layoutType;
    }

    // If layout type is not provided then choose a default value based
    // on the category.
    return DEFAULT_CATEGORY_LAYOUTS[category] ||
        EmojiGroupLayoutType.GRID_LAYOUT;
  }

  /**
   * Returns the arial label of an emoji.
   */
  private getEmojiAriaLabel(emoji: EmojiVariants): string {
    // TODO(crbug/1227852): Just use emoji as the tooltip once ChromeVox can
    // announce them properly.
    if (emoji.base.string) {
      const emojiLabel = this.isLangEnglish ?
          emoji.base.name :
          (this.getDisplayEmojiForEmoji(emoji.base.string, emoji));
      if (emoji.alternates && emoji.alternates.length > 0) {
        return emojiLabel + ' with variants.';
      } else {
        return emojiLabel ?? '';
      }
    }
    return '';
  }

  /**
   * Returns the character to be shown for the emoji.
   */
  private getDisplayEmojiForEmoji(text: string, emoji: EmojiVariants): string {
    const {alternates, groupedTone, groupedGender} = emoji;
    const individualPreference = this.preferred[text];

    if (!this.useGroupedPreference || !(groupedTone || groupedGender)) {
      return individualPreference ?? text;
    }

    const preference =
        alternates.find(variant => variant.string === individualPreference);
    const tone = this.globalTone ?? preference?.tone ?? Tone.DEFAULT;
    const gender = this.globalGender ?? preference?.gender ?? Gender.DEFAULT;

    const variant = alternates.find(variant => {
      return (variant.tone ?? tone) === tone &&
          (variant.gender ?? gender) === gender;
    });

    return variant?.string ?? text;
  }

  /**
   * Return whether variants of an emoji is visible or not.
   */
  private isEmojiVariantVisible(
      emojiIndex: number, shownEmojiVariantIndex: number): boolean {
    return emojiIndex === shownEmojiVariantIndex;
  }

  /**
   * Hides emoji variants if any is visible.
   */
  hideEmojiVariants(): void {
    this.shownEmojiVariantIndex = null;
  }

  /**
   * Finds emoji details for an HTML button based on the attribute of
   * data-index and event target information.
   * The result will be null if the target is not for a button element
   * or it does not have data-index attribute.
   */
  private findEmojiOfEmojiButton(target: HTMLElement|null): EmojiVariants
      |undefined {
    const dataIndex = target?.getAttribute('data-index');

    if (!(target?.nodeName === 'BUTTON' || target?.nodeName === 'IMG') ||
        !dataIndex) {
      return undefined;
    }
    return this.data[Number(dataIndex)];
  }

  /**
   * Returns the first emoji button in the group.
   */
  firstEmojiButton(): HTMLElement|null {
    // !. is safe for shadowRoot as it always exists
    const elem: HTMLElement|null = this.shadowRoot!.querySelector<HTMLElement>('.emoji-button, emoji-image');
    if (elem instanceof EmojiImageComponent) {
      return elem.shadowRoot!.querySelector('img');
    }
    return elem;
  }

  /**
   * Returns whether the given element group is visual or not.
   */
  isVisual(category: CategoryEnum): boolean {
    return category === CategoryEnum.GIF;
  }

  /**
   * Returns whether any emoji in the array has variants or not.
   */
  private hasAnyVariants(data: EmojiVariants[]): boolean {
    // `hasVariants` does not use `this`, so there is no need to bind `this`
    // here.
    return data.some(this.hasVariants);
  }

  /**
   * Filters visual content to be displayed in the given column based on '
   * the height of the given column.
   */
  filterColumn(
      data: EmojiVariants[], columnSide: SideEnum,
      _dataLength: number): EmojiVariants[] {
    let leftColHeight = 0;
    let rightColHeight = 0;

    const colData = data.filter((item) => {
      if (item.base.visualContent) {
        const contentHeight = item.base.visualContent.previewSize.height *
            VISUAL_CONTENT_WIDTH / item.base.visualContent.previewSize.width;

        // Filter visual content to be displayed in the given column if it's
        // currently the shortest
        if (leftColHeight <= rightColHeight) {
          leftColHeight += contentHeight;
          return columnSide === SideEnum.LEFT;
        } else {
          rightColHeight += contentHeight;
          return columnSide === SideEnum.RIGHT;
        }
      }

      return false;
    });

    return colData;
  }

  /**
   * Returns the index of a visual based EmojiVariant.
   */
  getIndex(item: EmojiVariants): number {
    return this.data.indexOf(item);
  }

  formatCategory(category: CategoryEnum): string {
    return category === CategoryEnum.GIF ? 'GIF' : category;
  }

  getMoreOptionsAriaLabel(gifSupport: boolean): string|undefined {
    // TODO(b/281609806): Remove this condition once GIF support is fully
    // launched; make sure related node finder in tast test is updated before
    // removing this condition.
    return gifSupport ? 'More options' : undefined;
  }
}

declare global {
  interface HTMLElementTagNameMap {
    [EmojiGroupComponent.is]: EmojiGroupComponent;
  }
  interface HTMLElementEventMap {
    [EMOJI_CLEAR_RECENTS_CLICK]: EmojiClearRecentClickEvent;
    [EMOJI_TEXT_BUTTON_CLICK]: EmojiTextButtonClickEvent;
  }
}

customElements.define(EmojiGroupComponent.is, EmojiGroupComponent);