// 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);
}