chromium/ash/webui/camera_app_ui/resources/js/custom_effect.ts

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

import * as animation from './animation.js';
import {assertEnumVariant, assertExists, assertNotReached} from './assert.js';
import * as dom from './dom.js';
import {I18nString} from './i18n_string.js';
import {SvgWrapper} from './lit/components/svg-wrapper.js';
import * as loadTimeData from './models/load_time_data.js';
import * as state from './state.js';
import * as util from './util.js';

/**
 * Interval of emerge time between two consecutive ripples in milliseconds.
 */
const RIPPLE_INTERVAL_MS = 5000;

/**
 * Controller for showing ripple effect.
 */
class RippleEffect {
  /**
   * Initial width of ripple in px.
   */
  private readonly width: number;

  /**
   * Initial height of ripple in px.
   */
  private readonly height: number;

  private readonly cancelHandle: number;

  /**
   * @param anchor Element to show ripple effect on.
   */
  constructor(
      private readonly anchor: HTMLElement,
      private readonly parent: HTMLElement = document.body) {
    const style = this.anchor.computedStyleMap();

    this.width = util.getStyleValueInPx(style, '--ripple-start-width');
    this.height = util.getStyleValueInPx(style, '--ripple-start-height');
    this.cancelHandle = setInterval(() => {
      this.addRipple();
    }, RIPPLE_INTERVAL_MS);

    this.addRipple();
  }

  private addRipple(): void {
    const rect = this.anchor.getBoundingClientRect();
    if (rect.width === 0) {
      return;
    }
    const template = util.instantiateTemplate('#ripple-template');
    const ripple = dom.getFrom(template, '.ripple', HTMLDivElement);
    const style = ripple.attributeStyleMap;
    style.set('left', CSS.px(rect.left - (this.width - rect.width) / 2));
    style.set('top', CSS.px(rect.top - (this.height - rect.height) / 2));
    style.set('width', CSS.px(this.width));
    style.set('height', CSS.px(this.height));
    this.parent.appendChild(template);
    // We don't care about waiting for the single ripple animation to end
    // before returning.
    void animation.play(ripple).result.then(() => {
      ripple.remove();
    });
  }

  /**
   * Stops ripple effect.
   */
  stop(): void {
    clearInterval(this.cancelHandle);
  }
}

/**
 * Interval for toast updaing position.
 */
const TOAST_POSITION_UPDATE_MS = 500;

enum PositionProperty {
  BOTTOM = 'bottom',
  CENTER = 'center',
  LEFT = 'left',
  MIDDLE = 'middle',
  RIGHT = 'right',
  TOP = 'top',
}

type PositionProperties = Array<{
  elProperty: PositionProperty,
  toastProperty: PositionProperty,
  offset: number,
}>;

type PositionInfos = Array<{
  target: HTMLElement,
  properties: PositionProperties,
}>;

export enum IndicatorType {
  // NEW_FEATURE = 'new_feature',
}

/**
 * Setup the required state observers to dismiss toasts when changing
 * modes/cameras.
 */
export function setup(): void {
  state.addObserver(state.State.STREAMING, (val) => {
    if (!val) {
      hide();
    }
  });
}

function getIndicatorI18nStringId(indicatorType: IndicatorType): I18nString {
  switch (indicatorType) {
    default:
      assertNotReached();
  }
}

function getIndicatorIcon(indicatorType: IndicatorType): string|null {
  switch (indicatorType) {
    default:
      return 'new_feature_toast_icon.svg';
  }
}

function getOffsetProperties(
    element: HTMLElement, prefix: string): PositionProperties {
  const properties = [];
  const style = element.computedStyleMap();

  function getPositionProperty(key: string) {
    const property = assertExists(style.get(key)).toString();
    return assertEnumVariant(PositionProperty, property);
  }

  for (const dir of ['x', 'y']) {
    const toastProperty = getPositionProperty(`--${prefix}-ref-${dir}`);
    const elProperty = getPositionProperty(`--${prefix}-element-ref-${dir}`);
    const offset = util.getStyleValueInPx(style, `--${prefix}-offset-${dir}`);
    properties.push({elProperty, toastProperty, offset});
  }
  return properties;
}

function updatePositions(anchor: HTMLElement, infos: PositionInfos): void {
  for (const {target, properties} of infos) {
    updatePosition(anchor, target, properties);
  }
}

function updatePosition(
    anchor: HTMLElement, targetElement: HTMLElement,
    properties: PositionProperties): void {
  const rect = anchor.getBoundingClientRect();
  const style = targetElement.attributeStyleMap;
  if (rect.width === 0) {
    style.set('display', 'none');
    return;
  }
  style.clear();
  for (const {elProperty, toastProperty, offset} of properties) {
    let value;
    if (elProperty === PositionProperty.CENTER) {
      value = rect.left + offset + rect.width / 2;
    } else if (elProperty === PositionProperty.MIDDLE) {
      value = rect.top + offset + rect.height / 2;
    } else {
      value = rect[elProperty] + offset;
    }

    if (toastProperty === PositionProperty.CENTER) {
      const targetElementRect = targetElement.getBoundingClientRect();
      value -= targetElementRect.width / 2;
      style.set(PositionProperty.LEFT, CSS.px(value));
      continue;
    }
    if (toastProperty === PositionProperty.RIGHT) {
      value = window.innerWidth - value;
    } else if (toastProperty === PositionProperty.BOTTOM) {
      value = window.innerHeight - value;
    }
    style.set(toastProperty, CSS.px(value));
  }
}

/**
 * Controller for showing a toast.
 */
class Toast {
  protected cancelHandle: number;

  constructor(
      protected readonly anchor: HTMLElement,
      protected readonly template: DocumentFragment,
      protected readonly toast: HTMLDivElement,
      protected readonly message: string,
      protected readonly positionInfos: PositionInfos,
      protected readonly parent: HTMLElement = document.body) {
    this.cancelHandle = setInterval(() => {
      updatePositions(anchor, positionInfos);
    }, TOAST_POSITION_UPDATE_MS);
  }

  show(): void {
    this.parent.appendChild(this.template);
    updatePositions(this.anchor, this.positionInfos);
  }

  focus(): void {
    this.anchor.setAttribute('aria-owns', 'new-feature-toast');
    this.toast.focus();
  }

  hide(): void {
    this.anchor.removeAttribute('aria-owns');
    clearInterval(this.cancelHandle);
    for (const {target} of this.positionInfos) {
      target.remove();
    }
  }
}

class NewFeatureToast extends Toast {
  constructor(anchor: HTMLElement, parent?: HTMLElement) {
    const template = util.instantiateTemplate('#new-feature-toast-template');
    const toast = dom.getFrom(template, '#new-feature-toast', HTMLDivElement);

    const i18nId =
        assertEnumVariant(I18nString, anchor.getAttribute('i18n-new-feature'));
    const textElement =
        dom.getFrom(template, '.custom-toast-text', HTMLSpanElement);
    const text = loadTimeData.getI18nMessage(i18nId);
    textElement.textContent = text;

    super(
        anchor, template, toast, text, [{
          target: toast,
          properties: getOffsetProperties(anchor, 'toast'),
        }],
        parent);
  }
}

class IndicatorToast extends Toast {
  constructor(
      anchor: HTMLElement, indicatorType: IndicatorType, parent?: HTMLElement) {
    const template = util.instantiateTemplate('#indicator-toast-template');
    const toast = dom.getFrom(template, '#indicator-toast', HTMLDivElement);

    const i18nId = getIndicatorI18nStringId(indicatorType);
    const textElement =
        dom.getFrom(template, '.custom-toast-text', HTMLSpanElement);
    const text = loadTimeData.getI18nMessage(i18nId);
    textElement.textContent = text;
    toast.setAttribute('aria-label', text);

    const icon = getIndicatorIcon(indicatorType);
    const iconElement = dom.getFrom(template, '#indicator-icon', SvgWrapper);
    if (icon === null) {
      iconElement.hidden = true;
    } else {
      iconElement.name = icon;
      iconElement.hidden = false;
    }

    const indicatorDot =
        dom.getFrom(template, '#indicator-dot', HTMLDivElement);
    super(
        anchor, template, toast, text,
        [
          {
            target: toast,
            properties: getOffsetProperties(anchor, 'toast'),
          },
          {
            target: indicatorDot,
            properties: getOffsetProperties(anchor, 'indicator-dot'),
          },
        ],
        parent);
  }
}

interface EffectPayload {
  ripple: RippleEffect|null;
  toast: Toast;
  timeout: number;
}

interface EffectHandle {
  hide: () => void;
  focusToast: () => void;
}

let globalEffectPayload: EffectPayload|null = null;

/**
 * Hides the specified effect or the effect being showing.
 */
export function hide(effectPayload?: EffectPayload): void {
  if (effectPayload !== undefined) {
    stopEffect(effectPayload);
    if (effectPayload === globalEffectPayload) {
      globalEffectPayload = null;
    }
  } else if (globalEffectPayload !== null) {
    stopEffect(globalEffectPayload);
    globalEffectPayload = null;
  }
}

function stopEffect(effectPayload: EffectPayload) {
  const {ripple, toast, timeout} = effectPayload;
  if (ripple !== null) {
    ripple.stop();
  }
  toast.hide();
  clearTimeout(timeout);
}

/**
 * Timeout for effects.
 */
const EFFECT_TIMEOUT_MS = 6000;

/**
 * Shows the new feature toast message and ripple around the `anchor` element.
 * The message to show is defined in HTML attribute and the relative position is
 * defined in CSS.
 *
 * @return Functions to hide the effect or focus the toast.
 */
export function showNewFeature(
    anchor: HTMLElement, parent?: HTMLElement): EffectHandle {
  return show(
      new NewFeatureToast(anchor, parent), new RippleEffect(anchor, parent));
}

/**
 * Shows the indicator toast message and an indicator dot around the `anchor`
 * element. The message to show is given by `indicatorType` and the relative
 * position of the toast and dot are defined in CSS.
 *
 * @return Functions to hide the effect or focus the toast.
 */
export function showIndicator(
    anchor: HTMLElement, indicatorType: IndicatorType,
    parent?: HTMLElement): EffectHandle {
  return show(new IndicatorToast(anchor, indicatorType, parent));
}

/**
 * Shows the effects.
 *
 * @return Functions to hide the effect or focus the toast.
 */
function show(toast: Toast, ripple: RippleEffect|null = null): EffectHandle {
  hide();

  const timeout = setTimeout(hide, EFFECT_TIMEOUT_MS);
  globalEffectPayload = {ripple, toast, timeout};
  toast.show();
  const originalEffectPayload = globalEffectPayload;
  return {
    hide: () => hide(originalEffectPayload),
    focusToast: () => toast.focus(),
  };
}

/**
 * @return If effect is showing.
 */
export function isShowing(): boolean {
  return globalEffectPayload !== null;
}

/**
 * Focuses to toast.
 */
export function focus(): void {
  if (globalEffectPayload === null) {
    return;
  }
  globalEffectPayload.toast.focus();
}

/**
 * Show the new feature toast for preview OCR scanning.
 */
export function showPreviewOCRToast(parent: HTMLElement): void {
  const modeSelector = dom.get(
      'mode-selector[i18n-new-feature=new_preview_ocr_toast]', HTMLElement);
  showNewFeature(modeSelector, parent);
}