chromium/ash/webui/common/resources/personalization/wallpaper_grid_item_element.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.

/**
 * @fileoverview Polymer element that displays a single grid item.
 */

import '//resources/ash/common/cr_elements/cr_auto_img/cr_auto_img.js';
import './personalization_shared_icons.html.js';

import {assert} from '//resources/js/assert.js';
import {Url} from '//resources/mojo/url/mojom/url.mojom-webui.js';
import {PolymerElement} from '//resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {getTemplate} from './wallpaper_grid_item_element.html.js';

const enum ImageStatus {
  LOADING = 'loading',
  ERROR = 'error',
  READY = 'ready',
}

/**
 * Returns true if this event is a user action to select an item.
 * TODO(b/316619844): Move this into a util file and share with Personalization
 * App.
 */
function isSelectionEvent(event: Event): boolean {
  return (event instanceof MouseEvent && event.type === 'click') ||
      (event instanceof KeyboardEvent && event.key === 'Enter');
}

function getDataIndex(event: Event&{currentTarget: HTMLImageElement}): number {
  const dataIndex = event.currentTarget.dataset['index'];
  assert(typeof dataIndex === 'string', 'data-index property required');
  const index = parseInt(dataIndex, 10);
  assert(!isNaN(index), `could not parseInt on ${dataIndex}`);
  return index;
}

/**
 * TODO(b/316619844): Move this into a util file and share with Personalization
 * App.
 */
function shouldShowPlaceholder(imageStatus: ImageStatus[]): boolean {
  return imageStatus.length === 0 ||
      (imageStatus.includes(ImageStatus.LOADING) &&
       !imageStatus.includes(ImageStatus.ERROR));
}

/** Returns a css variable to control the animation delay. */
function getLoadingPlaceholderAnimationDelay(index: number): string {
  // 48 is chosen because 4 and 3 are both factors, and it's large enough
  // that 48 grid items don't fit on one screen.
  const rippleIndex = index % 48;
  // Since 83 is divisible by neither 3 nor 4, there is a slight pause once the
  // ripple effect finishes before restarting.
  const animationDelay = 83;
  // Setting the animation delay to the ripple index * the animation delay adds
  // a ripple effect.
  return `--animation-delay: ${rippleIndex * animationDelay}ms;`;
}

const wallpaperGridItemSelectedEventName = 'wallpaper-grid-item-selected';

export class WallpaperGridItemSelectedEvent extends CustomEvent<null> {
  constructor() {
    super(
        wallpaperGridItemSelectedEventName,
        {
          bubbles: true,
          composed: true,
          detail: null,
        },
    );
  }
}

declare global {
  interface HTMLElementEventMap {
    [wallpaperGridItemSelectedEventName]: WallpaperGridItemSelectedEvent;
  }
}

/** The maximum number of images to display in one wallpaper grid item. */
const enum MaxImageCount {
  COLLAGE = 4,
  DEFAULT = 2,
}

export class WallpaperGridItemElement extends PolymerElement {
  static get is(): 'wallpaper-grid-item' {
    return 'wallpaper-grid-item';
  }

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

  static get properties() {
    return {
      src: {
        type: Object,
        observer: 'onImageSrcChanged_',
        value: null,
      },

      index: Number,
      primaryText: String,
      secondaryText: String,
      infoText: String,

      isGooglePhotos: {
        type: Boolean,
        value: false,
      },

      dataSeaPenImage: {
        type: Boolean,
        value: false,
      },

      selected: {
        type: Boolean,
        observer: 'onSelectedChanged_',
      },

      disabled: {
        type: Boolean,
        value: false,
        observer: 'onDisabledChanged_',
      },

      collage: {
        type: Boolean,
        value: false,
        reflectToAttribute: true,
        observer: 'onCollageChanged_',
      },

      imageStatus_: {
        type: Array,
        value() {
          return [];
        },
        observer: 'onImageStatusChanged_',
      },
    };
  }

  /**
   * The source for the image to render for the grid item. Will display a
   * placeholder loading animation if `src` is null.
   * If `src` is an array, will display the first two images side by side.
   * If `collage` is set and `src` is an array, will display up to the first
   * four images tiled.
   * @default null
   */
  src: Url|Url[]|null;

  /** The index of the grid item within its parent grid. */
  index: number;

  /** The primary text to render for the grid item. */
  primaryText: string|undefined;

  /** The secondary text to render for the grid item. */
  secondaryText: string|undefined;

  /** Additional informational text about the item. */
  infoText: string|undefined;

  /**
   * Passed to cr-auto-img to send google photos auth token on image request.
   */
  isGooglePhotos: boolean;

  /**
   * Whether the wallpaper grid image is sea pen image. It's used to determine
   * the check mark icon type.
   */
  dataSeaPenImage: boolean;

  /**
   * Whether the grid item is currently selected. Controls the aria-selected
   * html attribute. When undefined, aria-selected will be removed.
   */
  selected: boolean|undefined;

  /**
   * Whether the grid item is currently disabled. Automatically sets the
   * aria-disabled attribute for screen readers and targeting with CSS.
   * @default false
   */
  disabled: boolean;

  /**
   * Whether to display 2 images side by side in split Dark/Light mode,
   * or 4 images in a collage.
   * @default false
   */
  collage: boolean;

  // Track if images are loaded, failed, or ready to display.
  private imageStatus_: ImageStatus[];

  override ready() {
    super.ready();
    this.addEventListener('click', this.onUserSelection_);
    this.addEventListener('keydown', this.onUserSelection_);
  }

  private onUserSelection_(event: MouseEvent|KeyboardEvent) {
    // Ignore extraneous events and let them continue.
    // Also ignore click and keydown events if this grid item is disabled.
    // These events will continue to propagate up in case someone else is
    // interested that this item was interacted with.
    if (!isSelectionEvent(event) || this.disabled) {
      return;
    }

    event.preventDefault();
    event.stopPropagation();
    this.dispatchEvent(new WallpaperGridItemSelectedEvent());
  }

  // Invoked on changes to |imageSrc|.
  private onImageSrcChanged_(src: Url|Url[]|null, old: Url|Url[]|null) {
    // Set loading status if src has just changed while we wait for new images.
    const oldSrcArray = this.getSrcArray_(old, this.collage);
    this.imageStatus_ = this.getSrcArray_(src, this.collage).map(({url}, i) => {
      if (oldSrcArray.length > i && oldSrcArray[i].url === url) {
        // If the underlying url has not changed, keep the prior image status.
        // If we have a new |Url| object but the underlying url is the same, the
        // img onload event will not fire and reset the status to ready.
        return this.imageStatus_[i];
      }
      return ImageStatus.LOADING;
    });
  }

  private onSelectedChanged_(selected: boolean|undefined) {
    if (typeof selected === 'boolean') {
      this.setAttribute('aria-selected', selected.toString());
    } else {
      this.removeAttribute('aria-selected');
    }
  }

  private onDisabledChanged_(disabled: boolean) {
    this.setAttribute('aria-disabled', disabled.toString());
  }

  private onCollageChanged_(collage: boolean) {
    if (collage) {
      const imageStatus =
          this.getSrcArray_(this.src, collage)
              .map(
                  (_, index) => this.imageStatus_.length > index ?
                      this.imageStatus_[index] :
                      ImageStatus.LOADING);
      this.imageStatus_ = imageStatus;
      return;
    }

    this.imageStatus_.length =
        Math.min(MaxImageCount.DEFAULT, this.imageStatus_.length);
  }

  private onImageStatusChanged_(imageStatus: ImageStatus[]) {
    if (shouldShowPlaceholder(imageStatus)) {
      this.setAttribute('placeholder', '');
    } else {
      this.removeAttribute('placeholder');
    }
  }

  private onImgError_(event: Event&{currentTarget: HTMLImageElement}) {
    const targetIndex = getDataIndex(event);
    this.imageStatus_ = this.imageStatus_.map(
        (status, index) => index === targetIndex ? ImageStatus.ERROR : status);
  }

  private onImgLoad_(event: Event&{currentTarget: HTMLImageElement}) {
    const targetIndex = getDataIndex(event);
    this.imageStatus_ = this.imageStatus_.map(
        (status, index) => index === targetIndex ? ImageStatus.READY : status);
  }

  private getSrcArray_(src: Url|Url[]|null, collage: boolean): Url[] {
    if (!src) {
      return [];
    }
    if (Array.isArray(src)) {
      const max = collage ? MaxImageCount.COLLAGE : MaxImageCount.DEFAULT;
      return src.slice(0, max);
    }
    return [src];
  }

  private isImageHidden_(imageStatus: ImageStatus[]): boolean {
    // |imageStatus| is usually a non-empty array when this function is called.
    // But there are weird cases where dom-repeat will still call this function
    // when |src| goes from an array back to undefined.
    assert(Array.isArray(imageStatus), 'image status must be an array');
    // Do not show the image while loading because it has an ugly white frame.
    // Do not show the image on error either because it has an ugly broken red
    // icon symbol.
    // Wait until all images are ready to show any of them.
    return imageStatus.length === 0 ||
        imageStatus.some(status => status !== ImageStatus.READY);
  }

  /** Returns the delay to use for the grid item's placeholder animation. */
  private getItemPlaceholderAnimationDelay_(
      index: WallpaperGridItemElement['index']): string {
    return getLoadingPlaceholderAnimationDelay(index);
  }

  /** Whether the primary text is currently visible. */
  private isPrimaryTextVisible_() {
    return !!this.primaryText && !!this.primaryText.length;
  }

  /** Whether the secondary text is currently visible. */
  private isSecondaryTextVisible_() {
    return !!this.secondaryText && !!this.secondaryText.length;
  }

  /** Whether any text is currently visible. */
  private isTextVisible_(): boolean {
    if (shouldShowPlaceholder(this.imageStatus_)) {
      // Hide text while placeholder is displayed.
      return false;
    }
    return this.isSecondaryTextVisible_() || this.isPrimaryTextVisible_();
  }

  private shouldShowInfoText_(): boolean {
    return typeof this.infoText === 'string' && this.infoText.length > 0 &&
        !shouldShowPlaceholder(this.imageStatus_);
  }

  private getCheckMarkIcon_(): string {
    return this.dataSeaPenImage ?
        'personalization-shared:sea-pen-circle-checkmark' :
        'personalization-shared:circle-checkmark';
  }
}

declare global {
  interface HTMLElementTagNameMap {
    [WallpaperGridItemElement.is]: WallpaperGridItemElement;
  }
}

customElements.define(WallpaperGridItemElement.is, WallpaperGridItemElement);