// Copyright 2013 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 animate from './animation.js';
import {assertEnumVariant, assertInstanceof} from './assert.js';
import * as dom from './dom.js';
import {I18nString} from './i18n_string.js';
import * as localDev from './local_dev.js';
import * as loadTimeData from './models/load_time_data.js';
import * as state from './state.js';
import {AspectRatioSet, Facing, FpsRange, Resolution} from './type.js';
/**
* Creates a canvas element for 2D drawing.
*
* @param params Size of the canvas.
* @param params.width Width of the canvas.
* @param params.height Height of the canvas.
*/
export function newDrawingCanvas(
{width, height}: {width: number, height: number}):
{canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D} {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx =
assertInstanceof(canvas.getContext('2d'), CanvasRenderingContext2D);
return {canvas, ctx};
}
/**
* Converts canvas content to a JPEG Blob.
*/
export function canvasToJpegBlob(canvas: HTMLCanvasElement): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob !== null) {
resolve(blob);
} else {
reject(new Error('Failed to convert canvas to jpeg blob.'));
}
}, 'image/jpeg');
});
}
/**
* Converts ImageBitmap to a JPEG Blob.
*/
export function bitmapToJpegBlob(bitmap: ImageBitmap): Promise<Blob> {
const {canvas, ctx} =
newDrawingCanvas({width: bitmap.width, height: bitmap.height});
ctx.drawImage(bitmap, 0, 0);
return canvasToJpegBlob(canvas);
}
/**
* Types for keyboard shortcuts.
*/
const KEYBOARD_KEYS = [
' ',
'-',
'=',
'A',
'B',
'C',
'D',
'E',
'F',
'G',
'H',
'I',
'J',
'K',
'L',
'M',
'N',
'O',
'P',
'Q',
'R',
'S',
'T',
'U',
'V',
'W',
'X',
'Y',
'Z',
'ArrowDown',
'ArrowLeft',
'ArrowRight',
'ArrowUp',
'AudioVolumeUp',
'AudioVolumeDown',
'BrowserBack',
'Delete', // Alt + Backspace
'End', // Ctrl + Alt + ArrowDown
'Enter',
'Escape',
'Home', // Ctrl + Alt + ArrowUp
'Tab',
] as const;
const KEYBOARD_KEY_SET = new Set(KEYBOARD_KEYS);
type KeyboardKey = typeof KEYBOARD_KEYS[number];
type WithModifiers<Modifiers extends string[], Key extends string> =
Modifiers extends [...infer Rest extends string[], infer Last extends
string] ?
WithModifiers<Rest, Key|`${Last}-${Key}`>:
Key;
export type KeyboardShortcut =
WithModifiers<['Ctrl', 'Alt', 'Shift'], KeyboardKey>|'Unsupported';
/**
* Returns a shortcut string, such as Ctrl-Alt-A.
*
* @return Shortcut identifier.
*/
export function getKeyboardShortcut(event: KeyboardEvent): KeyboardShortcut {
let key = event.key;
if (/^[a-z]$/.test(key)) {
key = key.toUpperCase();
}
if (!isSupportedKeyboardKey(key)) {
return 'Unsupported';
}
let modifiers: WithModifiers<['Ctrl', 'Alt', 'Shift'], ''> = '';
if (event.ctrlKey) {
modifiers = `${modifiers}Ctrl-`;
}
if (event.altKey) {
modifiers = `${modifiers}Alt-`;
}
if (event.shiftKey) {
modifiers = `${modifiers}Shift-`;
}
return `${modifiers}${key}`;
}
function isSupportedKeyboardKey(key: string): key is KeyboardKey {
// This is to workaround current TypeScript limitation on Set.has.
// See https://github.com/microsoft/TypeScript/issues/26255
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return KEYBOARD_KEY_SET.has(key as KeyboardKey);
}
/**
* Sets up i18n messages on DOM subtree by i18n attributes.
*
* @param rootElement Root of DOM subtree to be set up with.
*/
export function setupI18nElements(rootElement: DocumentFragment|Element): void {
function getElements(attr: string) {
const elements = [...dom.getAllFrom(rootElement, `[${attr}]`, HTMLElement)];
if (rootElement instanceof HTMLElement && rootElement.hasAttribute(attr)) {
elements.push(rootElement);
}
return elements;
}
function getMessage(element: HTMLElement, attr: string) {
return loadTimeData.getI18nMessage(
assertEnumVariant(I18nString, element.getAttribute(attr)));
}
function setAriaLabel(element: HTMLElement, attr: string) {
element.setAttribute('aria-label', getMessage(element, attr));
}
for (const element of getElements('i18n-text')) {
// The element that has i18n-text is assumed to have no direct text node
// child other than the one generated by i18n-text, and might have other
// elements as child. Remove all the text node in case this is called more
// than once, and append the text node.
for (const node of Array.from(element.childNodes)) {
if (node.nodeType === Node.TEXT_NODE) {
node.remove();
}
}
element.append(getMessage(element, 'i18n-text'));
}
for (const attribute of ['i18n-aria', 'i18n-label']) {
for (const element of getElements(attribute)) {
setAriaLabel(element, attribute);
}
}
}
/**
* Reads blob into Image.
*/
export function blobToImage(blob: Blob): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('Failed to load unprocessed image'));
img.src = URL.createObjectURL(blob);
});
}
/**
* Gets the facing preference according to device mode and lid state. The lower
* the index, the more preferred.
*/
export function getFacingPreference(): Facing[] {
if (isLidClosed()) {
return [Facing.EXTERNAL, Facing.ENVIRONMENT, Facing.USER];
}
if (state.get(state.State.TABLET)) {
return [Facing.ENVIRONMENT, Facing.USER, Facing.EXTERNAL];
}
return [Facing.USER, Facing.ENVIRONMENT, Facing.EXTERNAL];
}
/**
* Checks if the lid is closed or not.
*/
export function isLidClosed(): boolean {
return state.get(state.State.LID_CLOSED);
}
/**
* Checks if the sw privacy switch is on.
*/
export function isSWPrivacySwitchOn(): boolean {
return state.get(state.State.SW_PRIVACY_SWITCH_ON);
}
/**
* Toggle checked value of element.
*/
export function toggleChecked(
element: HTMLInputElement, checked: boolean): void {
element.checked = checked;
element.dispatchEvent(new Event('change'));
}
/**
* Binds on/off of specified state with different aria label on an element.
*/
export function bindElementAriaLabelWithState(
{element, state: s, onLabel, offLabel}: {
element: Element,
state: state.State,
onLabel: I18nString,
offLabel: I18nString,
}): void {
function update(value: boolean) {
const label = value ? onLabel : offLabel;
element.setAttribute('i18n-label', label);
element.setAttribute('aria-label', loadTimeData.getI18nMessage(label));
}
update(state.get(s));
state.addObserver(s, update);
}
/**
* Sets inkdrop effect on button or label in setting menu.
*/
export function setInkdropEffect(el: HTMLElement): void {
const tpl = instantiateTemplate('#inkdrop-template');
const ripple =
assertInstanceof(tpl.querySelector('.inkdrop-ripple'), HTMLElement);
el.appendChild(tpl);
el.addEventListener('click', (e) => {
const tRect =
assertInstanceof(e.target, HTMLElement).getBoundingClientRect();
const elRect = el.getBoundingClientRect();
const dropX = tRect.left + e.offsetX - elRect.left;
const dropY = tRect.top + e.offsetY - elRect.top;
const maxDx = Math.max(Math.abs(dropX), Math.abs(elRect.width - dropX));
const maxDy = Math.max(Math.abs(dropY), Math.abs(elRect.height - dropY));
const radius = Math.hypot(maxDx, maxDy);
el.style.setProperty('--drop-x', `${dropX}px`);
el.style.setProperty('--drop-y', `${dropY}px`);
el.style.setProperty('--drop-radius', `${radius}px`);
animate.play(ripple);
});
}
/**
* Instantiates template with the target selector.
*/
export function instantiateTemplate(selector: string): DocumentFragment {
const tpl = dom.get(selector, HTMLTemplateElement);
const doc = assertInstanceof(
document.importNode(tpl.content, true), DocumentFragment);
setupI18nElements(doc);
return doc;
}
/**
* Sleeps for a specified time.
*
* @param ms Milliseconds to sleep.
*/
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Gets value in px of a property in a StylePropertyMapReadOnly.
*/
export function getStyleValueInPx(
style: (StylePropertyMap|StylePropertyMapReadOnly), prop: string): number {
return assertInstanceof(style.get(prop), CSSNumericValue).to('px').value;
}
/**
* Trigger callback in fixed interval like |setInterval()| with specified delay
* before calling the first callback.
*/
export class DelayInterval {
private intervalId: number|null = null;
private readonly delayTimeoutId: number;
/**
* @param callback Callback to be triggered in fixed interval.
* @param delayMs Delay milliseconds at start.
* @param intervalMs Interval in milliseconds.
*/
constructor(callback: () => void, delayMs: number, intervalMs: number) {
this.delayTimeoutId = setTimeout(() => {
this.intervalId = setInterval(() => {
callback();
}, intervalMs);
callback();
}, delayMs);
}
/**
* Stop the interval.
*/
stop(): void {
if (this.intervalId === null) {
clearTimeout(this.delayTimeoutId);
} else {
clearInterval(this.intervalId);
}
}
}
/**
* Share file with share API.
*/
export async function share(file: File): Promise<void> {
const shareData = {files: [file]};
try {
if (!navigator.canShare(shareData)) {
throw new Error('cannot share');
}
await navigator.share(shareData);
} catch (e) {
// TODO(b/191950622): Handles all share error case, e.g. no
// share target, share abort... with right treatment like toast
// message.
}
}
/**
* Crops out maximum possible centered square from the image blob.
*
* @return Promise with result cropped square image.
*/
export async function cropSquare(blob: Blob): Promise<Blob> {
const img = await blobToImage(blob);
try {
const side = Math.min(img.width, img.height);
const {canvas, ctx} = newDrawingCanvas({width: side, height: side});
ctx.drawImage(
img, Math.floor((img.width - side) / 2),
Math.floor((img.height - side) / 2), side, side, 0, 0, side, side);
// TODO(b/174190121): Patch important exif entries from input blob to
// result blob.
const croppedBlob = await canvasToJpegBlob(canvas);
return croppedBlob;
} finally {
URL.revokeObjectURL(img.src);
}
}
/**
* Returns the mapped aspect ratio set according to the given resolution.
*/
export function toAspectRatioSet(resolution: Resolution|null): AspectRatioSet {
switch (resolution?.aspectRatio) {
case 1.3333:
return AspectRatioSet.RATIO_4_3;
case 1.7778:
return AspectRatioSet.RATIO_16_9;
default:
return AspectRatioSet.RATIO_OTHER;
}
}
/**
* Extract first url from CSS background-image value if exist.
*/
export function extractBackgroundImageValueUrl(element: HTMLElement): string|
null {
const style = element.attributeStyleMap;
const imageValue = style.get('background-image');
// attributeStyleMap.get() returns null instead of undefined if the property
// does not exist. Check undefined for type narrowing.
if (imageValue === null || imageValue === undefined) {
return null;
}
const match = imageValue.toString().match(/url\(['"](.*)['"]\)/);
return match?.[1] ?? null;
}
/**
* Load the image element with given blob.
*/
export async function loadImage(
image: HTMLImageElement, data: Blob|string): Promise<void> {
const src = typeof data === 'string' ? data : URL.createObjectURL(data);
return new Promise((resolve, reject) => {
image.onload = () => resolve();
image.onerror = (e) => {
reject(new Error(`Failed to load image: ${e}`));
URL.revokeObjectURL(image.src);
};
image.src = src;
});
}
/**
* Gets the mapping from name to enum value for a number enum.
*
* Note that in TypeScript, number enum contains both mapping from name to
* value and value to name, which most of the time isn't what we want.
*/
export function getNumberEnumMapping<T extends number>(
enumType: {[key: string]: T|string}): {[key: string]: T} {
return Object.fromEntries(Object.entries(enumType).flatMap(([k, v]) => {
if (typeof v === 'string') {
return [];
}
return [[k, v]];
}));
}
/**
* Returns FPS range from media track constraints.
*/
export function getFpsRangeFromConstraints(frameRate: ConstrainDouble|
undefined): FpsRange {
let minFps = 0;
let maxFps = 0;
// For devices that don't support constant frame rate, we pass {0,0} and let
// VCD fall back to the default range.
if (frameRate !== undefined) {
if (typeof frameRate === 'number') {
minFps = frameRate;
maxFps = frameRate;
} else if (frameRate.exact !== undefined) {
minFps = frameRate.exact;
maxFps = frameRate.exact;
}
}
return {minFps, maxFps};
}
// Observer to monitor the average FPS of preview within an interval.
export class FpsObserver {
private readonly timestamps: number[] = [];
private callbackId = 0;
constructor(private readonly videoElement: HTMLVideoElement) {
const FPS_MEASUREMENT_MAX_SAMPLE_COUNT = 100;
const updateFps = () => {
this.timestamps.push(performance.now());
if (this.timestamps.length > FPS_MEASUREMENT_MAX_SAMPLE_COUNT) {
this.timestamps.shift();
}
this.callbackId = this.videoElement.requestVideoFrameCallback(updateFps);
};
this.callbackId = this.videoElement.requestVideoFrameCallback(updateFps);
}
// Returns the average FPS according to the collected timestamps. If the
// amount of data is not enough, returns null instead.
getAverageFps(): number|null {
if (this.timestamps.length <= 1) {
return null;
}
return (this.timestamps.length - 1) /
(this.timestamps[this.timestamps.length - 1] - this.timestamps[0]) *
1000;
}
stop(): void {
this.videoElement.cancelVideoFrameCallback(this.callbackId);
}
}
/**
* Returns whether a FileSystemHandle is FileSystemFileHandle.
*
* This is needed since the type FileSystemHandle isn't a discriminated union
* now.
* See https://github.com/microsoft/TypeScript-DOM-lib-generator/issues/1494.
*/
export function isFileSystemFileHandle(handle: FileSystemHandle):
handle is FileSystemFileHandle {
return handle.kind === 'file';
}
/**
* Returns whether a FileSystemHandle is FileSystemDirectoryHandle.
*
* This is needed since the type FileSystemHandle isn't a discriminated union
* now.
* See https://github.com/microsoft/TypeScript-DOM-lib-generator/issues/1494.
*/
export function isFileSystemDirectoryHandle(handle: FileSystemHandle):
handle is FileSystemDirectoryHandle {
return handle.kind === 'directory';
}
/**
* Expands a path to full absolute path.
*
* This is a no-op for CCA on CrOS, but is needed for local dev since it might
* be served in a subpath.
*/
export const expandPath = localDev.overridableFunction((path: string) => path);
/**
* Lazily initialize a singleton.
*/
export function lazySingleton<T>(fn: () => T): () => T {
let val: T|null = null;
return () => {
if (val === null) {
val = fn();
}
return val;
};
}