chromium/ash/webui/common/resources/sea_pen/sea_pen_template_query_element.ts

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

/**
 * @fileoverview A polymer component that displays template query to search for
 * SeaPen wallpapers.
 */

import 'chrome://resources/ash/common/personalization/common.css.js';
import 'chrome://resources/ash/common/personalization/cros_button_style.css.js';
import 'chrome://resources/ash/common/personalization/personalization_shared_icons.html.js';
import 'chrome://resources/ash/common/sea_pen/sea_pen.css.js';
import 'chrome://resources/ash/common/sea_pen/sea_pen_chip_text_element.js';
import 'chrome://resources/ash/common/sea_pen/sea_pen_icons.html.js';
import 'chrome://resources/ash/common/sea_pen/sea_pen_options_element.js';
import 'chrome://resources/cros_components/lottie_renderer/lottie-renderer.js';

import {LottieRenderer} from 'chrome://resources/cros_components/lottie_renderer/lottie-renderer.js';
import {assert} from 'chrome://resources/js/assert.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {afterNextRender, beforeNextRender} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {getSeaPenTemplates, SeaPenOption, SeaPenTemplate} from './constants.js';
import {isSeaPenTextInputEnabled, isSeaPenUseExptTemplateEnabled} from './load_time_booleans.js';
import {SeaPenQuery, SeaPenThumbnail, SeaPenUserVisibleQuery} from './sea_pen.mojom-webui.js';
import {getSeaPenThumbnails} from './sea_pen_controller.js';
import {SeaPenTemplateChip, SeaPenTemplateId, SeaPenTemplateOption} from './sea_pen_generated.mojom-webui.js';
import {getSeaPenProvider} from './sea_pen_interface_provider.js';
import {logGenerateSeaPenWallpaper} from './sea_pen_metrics_logger.js';
import {WithSeaPenStore} from './sea_pen_store.js';
import {getTemplate} from './sea_pen_template_query_element.html.js';
import {ChipToken, getDefaultOptions, getSelectedOptionsFromQuery, getTemplateTokens, isNonEmptyArray, isPersonalizationApp, TemplateToken} from './sea_pen_utils.js';
import {getTransitionEnabled} from './transition.js';

// Two options are the same if they have the same key-value pairs.
function isSameOption(
    map1: Map<SeaPenTemplateChip, SeaPenOption>,
    map2: Map<SeaPenTemplateChip, SeaPenOption>): boolean {
  if (map1.size !== map2.size) {
    return false;
  }

  for (const [key, value] of map1.entries()) {
    if (!map2.has(key) || map2.get(key) !== value) {
      return false;
    }
  }

  return true;
}

export interface SeaPenTemplateQueryElement {
  $: {
    container: HTMLDivElement,
  };
}

export class SeaPenTemplateQueryElement extends WithSeaPenStore {
  static get is() {
    return 'sea-pen-template-query';
  }

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

  static get properties() {
    return {
      templateId: String,

      seaPenQuery_: {
        type: Object,
        value: null,
      },

      seaPenTemplate_: {
        type: Object,
        computed: 'computeSeaPenTemplate_(templateId)',
      },

      // A map of chip to its selected option. By default, populated after
      // `seaPenTemplate_` is constructed. Updated when the user selects the
      // option on the UI.
      selectedOptions_: {
        type: Object,
        observer: 'onSelectedOptionsChanged_',
      },

      // The tokens generated from `seaPenTemplate_` and `selectedOptions_`.
      templateTokens_: {
        type: Array,
      },

      // The selected chip token. Updated whenever the user clicks a chip in the
      // UI.
      selectedChip_: {
        type: Object,
      },

      // `options_` is an array of possible values for the selected chip. Each
      // "option" will be mapped to a clickable button that the user could
      // select. The options are dependent on the `selectedChip_`.
      options_: {
        type: Array,
      },

      // A boolean indicates whether the user is still selecting chip options.
      isSelectingOptions: {
        type: Boolean,
        reflectToAttribute: true,
      },

      thumbnails_: {
        type: Object,
        observer: 'updateSearchButton_',
      },

      thumbnailsLoading_: Boolean,

      searchButtonText_: {
        type: String,
        value() {
          return loadTimeData.getString('seaPenCreateButton');
        },
      },

      searchButtonIcon_: {
        type: String,
        value() {
          return 'sea-pen:photo-spark';
        },
      },

      seaPenUseExptTemplateEnabled_: {
        type: Boolean,
        value() {
          return isSeaPenUseExptTemplateEnabled();
        },
      },
    };
  }

  // TODO(b/319719709) this should be SeaPenTemplateId.
  templateId: string|null;
  private seaPenTemplate_: SeaPenTemplate;
  private seaPenQuery_: SeaPenQuery|null;
  private selectedOptions_: Map<SeaPenTemplateChip, SeaPenOption>;
  private templateTokens_: TemplateToken[];
  private options_: SeaPenOption[]|null;
  private selectedChip_: ChipToken|null;
  private thumbnails_: SeaPenThumbnail[]|null;
  private thumbnailsLoading_: boolean;
  private searchButtonText_: string;
  private searchButtonIcon_: string;
  private isSelectingOptions: boolean;
  private containerOriginalHeight_: number;
  private resizeObserver_: ResizeObserver;
  private seaPenUseExptTemplateEnabled_: boolean;

  static get observers() {
    return [
      'onSeaPenTemplateOrQueryChanged_(seaPenTemplate_, seaPenQuery_)',
    ];
  }

  override connectedCallback() {
    super.connectedCallback();
    this.addEventListener('click', this.onClick_);
    this.watch<SeaPenTemplateQueryElement['thumbnails_']>(
        'thumbnails_', state => state.thumbnails);
    this.watch<SeaPenTemplateQueryElement['thumbnailsLoading_']>(
        'thumbnailsLoading_', state => state.loading.thumbnails);
    this.watch<SeaPenTemplateQueryElement['seaPenQuery_']>(
        'seaPenQuery_', state => state.currentSeaPenQuery);
    this.updateFromStore();

    this.resizeObserver_ =
        new ResizeObserver(() => this.animateContainerHeight());

    beforeNextRender(this, () => {
      const inspireMeAnimation = this.getInspireMeAnimationElement_();
      if (inspireMeAnimation) {
        inspireMeAnimation.autoplay = false;
      }

      this.containerOriginalHeight_ = this.$.container.scrollHeight;
      this.$.container.style.height = `${this.containerOriginalHeight_}px`;
    });
  }

  override disconnectedCallback() {
    super.disconnectedCallback();
    this.resizeObserver_.disconnect();
    this.removeEventListener('click', this.onClick_);
  }

  // Called when there is a custom dom-change event dispatched from
  // `sea-pen-options` element.
  private onSeaPenOptionsDomChanged_() {
    const optionsContainer = this.shadowRoot!.querySelector('sea-pen-options');
    if (optionsContainer) {
      this.resizeObserver_.observe(optionsContainer);
    }
  }

  // Updates main container's height and applies transition style.
  private animateContainerHeight() {
    const optionsContainer = this.shadowRoot!.querySelector('sea-pen-options');
    const optionsContainerHeight =
        optionsContainer ? optionsContainer.scrollHeight : 0;
    this.$.container.style.height =
        `${this.containerOriginalHeight_ + optionsContainerHeight}px`;
  }

  // After exiting from the option selection (by "Esc" key or clicking on
  // anywhere), clear the selected chip state and set focus on the last selected
  // chip.
  onOptionSelectionDone() {
    if (!this.selectedChip_) {
      return;
    }
    const selectedChipIndex =
        Array
            .from(this.shadowRoot!.querySelectorAll<HTMLElement>(
                '.chip-container'))
            .findIndex(elem => elem.classList.contains('selected'));
    this.clearSelectedChipState_();
    afterNextRender(this, () => {
      this.shadowRoot!
          .querySelectorAll<HTMLElement>('.chip-text')[selectedChipIndex]
          ?.focus();
    });
  }

  private startInspireIconAnimation_() {
    this.getInspireMeAnimationElement_()?.play();
  }

  private stopInspireIconAnimation_() {
    this.getInspireMeAnimationElement_()?.stop();
  }

  private clearSelectedChipState_() {
    if (this.selectedChip_) {
      this.selectedChip_ = null;
      this.options_ = null;
      this.isSelectingOptions = false;
    }
  }

  private onClick_(): void {
    this.onOptionSelectionDone();
  }

  private computeSeaPenTemplate_(templateId: string|null) {
    const seaPenTemplates = getSeaPenTemplates();
    const correctTemplate = seaPenTemplates.find(
        (seaPenTemplate) => seaPenTemplate.id.toString() === templateId);
    return correctTemplate as SeaPenTemplate;
  }

  private isChip_(token: any): token is ChipToken {
    return typeof token?.translation === 'string';
  }

  private onClickChip_(event: Event&{model: {token: ChipToken}}) {
    assert(this.isChip_(event.model.token), 'Token must be a chip');
    if (this.selectedChip_?.id === event.model.token.id) {
      this.clearSelectedChipState_();
    } else {
      this.selectedChip_ = event.model.token;
      assert(
          this.seaPenTemplate_.options.has(this.selectedChip_.id),
          'options must exist');
      this.options_ = this.seaPenTemplate_.options.get(this.selectedChip_.id)!;
      this.isSelectingOptions = true;
    }
    // Stop the event propagation, otherwise, the event will be passed to parent
    // element, this.onClick_ will be triggered improperly.
    event.preventDefault();
    event.stopPropagation();
  }

  private onClickInspire_(event: Event) {
    // Run getDefaultOptions (5 times at most) until we get an options that is
    // different from current; which highly likely to happen the first time.
    for (let i = 0; i < 5; i++) {
      const newOptions =
          getDefaultOptions(this.seaPenTemplate_, /*random=*/ true);

      if (!isSameOption(newOptions, this.selectedOptions_)) {
        this.selectedOptions_ = newOptions;
        break;
      }
    }

    this.onClickSearchButton_(event);
  }

  private onSeaPenTemplateOrQueryChanged_(
      template: SeaPenTemplate, seaPenQuery: SeaPenQuery|null) {
    this.clearSelectedChipState_();
    this.selectedOptions_ =
        getSelectedOptionsFromQuery(seaPenQuery, template) ??
        getDefaultOptions(template);
  }

  private onSelectedOptionsChanged_() {
    this.searchButtonText_ = this.i18n('seaPenCreateButton');
    this.searchButtonIcon_ = 'sea-pen:photo-spark';
    this.templateTokens_ =
        getTemplateTokens(this.seaPenTemplate_, this.selectedOptions_);
  }

  private getChipClassName_(chip: ChipToken, selectedChip: ChipToken|null):
      'selected'|'unselected' {
    assert(this.isChip_(chip), 'Token must be a chip');
    // If there are no selected chips, then use the 'selected' styling on all
    // chips.
    return !selectedChip || chip.id === selectedChip.id ? 'selected' :
                                                          'unselected';
  }

  private isOptionSelected_(
      option: SeaPenOption, selectedChipTranslation: string): string {
    return (option.translation === selectedChipTranslation).toString();
  }

  private getOptionClass_(
      option: SeaPenOption, selectedChipTranslation: string): string {
    return this.isOptionSelected_(option, selectedChipTranslation) === 'true' ?
        'action-button' :
        'unselected-option';
  }

  private getTextClassName_(selectedChip: TemplateToken|null): string {
    // Use the 'unselected' styling only if a chip has been selected.
    return selectedChip ? 'unselected' : '';
  }

  private getUserVisibleQueryInfo_(): SeaPenUserVisibleQuery {
    const translatedTokens: string[] = this.templateTokens_.map((token) => {
      return this.isChip_(token) ? token.translation : token;
    });
    return {
      text: translatedTokens.join(' '),
      templateTitle: this.seaPenTemplate_.title,
    };
  }

  private getSeaPenTemplateId_(): SeaPenTemplateId {
    return parseInt(this.templateId!, 10);
  }

  private getTemplateRequest_(): SeaPenQuery {
    const optionMap = new Map<SeaPenTemplateChip, SeaPenTemplateOption>();
    this.selectedOptions_.forEach((option, chip) => {
      optionMap.set(chip, option.value);
    });
    const id = this.getSeaPenTemplateId_();
    assert(!isNaN(id));
    return {
      templateQuery: {
        id,
        options: Object.fromEntries(optionMap),
        userVisibleQuery: this.getUserVisibleQueryInfo_(),
      },
    };
  }

  private getInspireMeAnimationElement_(): LottieRenderer|null|undefined {
    return this.shadowRoot?.querySelector<LottieRenderer>(
        '#inspireMeAnimation');
  }

  private onClickSearchButton_(event: Event) {
    this.clearSelectedChipState_();
    getSeaPenThumbnails(
        this.getTemplateRequest_(), getSeaPenProvider(), this.getStore());
    logGenerateSeaPenWallpaper(this.getSeaPenTemplateId_());

    // Stop the event propagation, otherwise, the event will be passed to parent
    // element, this.onClick_ will be triggered improperly.
    event.preventDefault();
    event.stopPropagation();
  }

  private updateSearchButton_(thumbnails: SeaPenThumbnail[]|null) {
    if (!thumbnails) {
      // The thumbnails are not loaded yet.
      this.searchButtonText_ = this.i18n('seaPenCreateButton');
      this.searchButtonIcon_ = 'sea-pen:photo-spark';
    } else {
      this.searchButtonText_ = this.i18n('seaPenRecreateButton');
      this.searchButtonIcon_ = 'personalization-shared:refresh';
    }
  }

  private shouldShowOptions_(options: SeaPenOption[]|null): boolean {
    return isNonEmptyArray(options);
  }

  private shouldShowFreeformNavigationInfo_(): boolean {
    return isSeaPenTextInputEnabled() && isPersonalizationApp();
  }

  private shouldEnableTextAnimation(
      selectedChip: ChipToken|null, token: ChipToken) {
    // enables text animation if the animation is enabled and the chip is
    // selected.
    return getTransitionEnabled() && !!selectedChip &&
        selectedChip.id === token.id;
  }

  private getTemplateAriaLabel_() {
    return this.getUserVisibleQueryInfo_().text;
  }
}

customElements.define(
    SeaPenTemplateQueryElement.is, SeaPenTemplateQueryElement);

declare global {
  interface HTMLElementTagNameMap {
    'sea-pen-template-query': SeaPenTemplateQueryElement;
  }
}