# Conflicts:
#	codecs/cpp.Dockerfile
#	codecs/imagequant/example.html
#	codecs/webp/dec/webp_dec.d.ts
#	codecs/webp/dec/webp_dec.js
#	codecs/webp/dec/webp_dec.wasm
#	codecs/webp/enc/webp_enc.d.ts
#	codecs/webp/enc/webp_enc.js
#	codecs/webp/enc/webp_enc.wasm
#	package-lock.json
#	package.json
#	src/codecs/tiny.webp
#	src_old/codecs/encoders.ts
#	src_old/codecs/processor-worker/tiny.avif
#	src_old/codecs/processor-worker/tiny.webp
#	src_old/codecs/tiny.webp
#	src_old/components/compress/index.tsx
#	src_old/lib/util.ts
#	src_old/sw/util.ts
This commit is contained in:
Jake Archibald
2020-09-16 09:59:24 +01:00
parent dfee848a39
commit a6477b82fc
202 changed files with 10435 additions and 16953 deletions

View File

@@ -0,0 +1,95 @@
import * as style from './styles.css';
export interface SnackOptions {
timeout?: number;
actions?: string[];
}
function createSnack(
message: string,
options: SnackOptions,
): [Element, Promise<string>] {
const { timeout = 0, actions = ['dismiss'] } = options;
const el = document.createElement('div');
el.className = style.snackbar;
el.setAttribute('aria-live', 'assertive');
el.setAttribute('aria-atomic', 'true');
el.setAttribute('aria-hidden', 'false');
const text = document.createElement('div');
text.className = style.text;
text.textContent = message;
el.appendChild(text);
const result = new Promise<string>((resolve) => {
let timeoutId: number;
// Add action buttons
for (const action of actions) {
const button = document.createElement('button');
button.className = style.button;
button.textContent = action;
button.addEventListener('click', () => {
clearTimeout(timeoutId);
resolve(action);
});
el.appendChild(button);
}
// Add timeout
if (timeout) {
timeoutId = self.setTimeout(() => resolve(''), timeout);
}
});
return [el, result];
}
export default class SnackBarElement extends HTMLElement {
private _snackbars: [
string,
SnackOptions,
(action: Promise<string>) => void,
][] = [];
private _processingQueue = false;
/**
* Show a snackbar. Returns a promise for the name of the action clicked, or an empty string if no
* action is clicked.
*/
showSnackbar(message: string, options: SnackOptions = {}): Promise<string> {
return new Promise<string>((resolve) => {
this._snackbars.push([message, options, resolve]);
if (!this._processingQueue) this._processQueue();
});
}
private async _processQueue() {
this._processingQueue = true;
while (this._snackbars[0]) {
const [message, options, resolver] = this._snackbars[0];
const [el, result] = createSnack(message, options);
// Pass the result back to the original showSnackbar call.
resolver(result);
this.appendChild(el);
// Wait for the user to click an action, or for the snack to timeout.
await result;
// Transition the snack away.
el.setAttribute('aria-hidden', 'true');
await new Promise((resolve) => {
el.addEventListener('animationend', () => resolve());
});
el.remove();
this._snackbars.shift();
}
this._processingQueue = false;
}
}
customElements.define('snack-bar', SnackBarElement);

13
src_old/lib/SnackBar/missing-types.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
import { SnackOptions, SnackShowResult } from '.';
declare global {
namespace JSX {
interface IntrinsicElements {
'snack-bar': SnackBarAttributes;
}
interface SnackBarAttributes extends HTMLAttributes {
showSnackbar?: (options: SnackOptions) => Promise<SnackShowResult>;
}
}
}

View File

@@ -0,0 +1,105 @@
snack-bar {
display: block;
position: fixed;
left: 0;
bottom: 0;
width: 100%;
height: 0;
overflow: visible;
}
.snackbar {
position: fixed;
display: flex;
box-sizing: border-box;
left: 50%;
bottom: 24px;
width: 344px;
margin-left: -172px;
background: #2a2a2a;
border-radius: 2px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
transform-origin: center;
color: #eee;
z-index: 100;
cursor: default;
will-change: transform;
animation: snackbar-show 300ms ease forwards 1;
}
.snackbar[aria-hidden='true'] {
animation: snackbar-hide 300ms ease forwards 1;
}
@keyframes snackbar-show {
from {
opacity: 0;
transform: scale(0.5);
}
}
@keyframes snackbar-hide {
to {
opacity: 0;
transform: translateY(100%);
}
}
@media (max-width: 400px) {
.snackbar {
width: 100%;
bottom: 0;
left: 0;
margin-left: 0;
border-radius: 0;
}
}
.text {
flex: 1 1 auto;
padding: 16px;
font-size: 100%;
}
.button {
position: relative;
flex: 0 1 auto;
padding: 8px;
height: 36px;
margin: auto 8px auto -8px;
min-width: 5em;
background: none;
border: none;
border-radius: 3px;
color: lightgreen;
font-weight: inherit;
letter-spacing: 0.05em;
font-size: 100%;
text-transform: uppercase;
text-align: center;
cursor: pointer;
overflow: hidden;
transition: background-color 200ms ease;
outline: none;
}
.button:hover {
background-color: rgba(0, 0, 0, 0.15);
}
.button:focus:before {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 120%;
height: 0;
padding: 0 0 120%;
margin: -60% 0 0 -60%;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
transform-origin: center;
will-change: transform;
animation: focus-ring 300ms ease-out forwards 1;
pointer-events: none;
}
@keyframes focus-ring {
from {
transform: scale(0.01);
}
}

View File

@@ -0,0 +1,62 @@
function cleanSetOrMerge<A extends any[] | object>(
source: A,
keys: string | number | string[],
toSetOrMerge: any[] | object,
merge: boolean,
): A {
const splitKeys = Array.isArray(keys) ? keys : ('' + keys).split('.');
// Going off road in terms of types, otherwise TypeScript doesn't like the access-by-index.
// The assumptions in this code break if the object contains things which aren't arrays or
// plain objects.
let last = copy(source) as any;
const newObject = last;
const lastIndex = splitKeys.length - 1;
for (const [i, key] of splitKeys.entries()) {
if (i !== lastIndex) {
// Copy everything along the path.
last = last[key] = copy(last[key]);
} else {
// Merge or set.
last[key] = merge
? Object.assign(copy(last[key]), toSetOrMerge)
: toSetOrMerge;
}
}
return newObject;
}
function copy<A extends any[] | object>(source: A): A {
// Some type cheating here, as TypeScript can't infer between generic types.
if (Array.isArray(source)) return [...source] as any;
return { ...(source as any) };
}
/**
* @param source Object to copy from.
* @param keys Path to modify, eg "foo.bar.baz".
* @param toMerge A value to merge into the value at the path.
*/
export function cleanMerge<A extends any[] | object>(
source: A,
keys: string | number | string[],
toMerge: any[] | object,
): A {
return cleanSetOrMerge(source, keys, toMerge, true);
}
/**
* @param source Object to copy from.
* @param keys Path to modify, eg "foo.bar.baz".
* @param newValue A value to set at the path.
*/
export function cleanSet<A extends any[] | object>(
source: A,
keys: string | number | string[],
newValue: any,
): A {
return cleanSetOrMerge(source, keys, newValue, false);
}

26
src_old/lib/fix-pmc.mjs Normal file
View File

@@ -0,0 +1,26 @@
import { options } from 'preact';
const classNameDescriptor = {
enumerable: false,
configurable: true,
get () {
return this.class;
},
set (value) {
this.class = value;
}
};
const old = options.vnode;
options.vnode = vnode => {
const a = vnode.attributes;
if (a != null) {
if ('className' in a) {
a.class = a.className;
}
if ('class' in a) {
Object.defineProperty(a, 'className', classNameDescriptor);
}
}
if (old != null) old(vnode);
};

107
src_old/lib/icons.tsx Normal file
View File

@@ -0,0 +1,107 @@
import { h } from 'preact';
// tslint:disable:max-line-length variable-name
const Icon = (props: JSX.HTMLAttributes) => (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
{...props}
/>
);
export const DownloadIcon = (props: JSX.HTMLAttributes) => (
<Icon {...props}>
<path d="M19 12v7H5v-7H3v7c0 1.1.9 2 2 2h14a2 2 0 0 0 2-2v-7h-2zm-6 .7l2.6-2.6 1.4 1.4-5 5-5-5 1.4-1.4 2.6 2.6V3h2z" />
</Icon>
);
export const ToggleBackgroundIcon = (props: JSX.HTMLAttributes) => (
<Icon {...props}>
<path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm2 4v-2H3c0 1.1.9 2 2 2zM3 9h2V7H3v2zm12 12h2v-2h-2v2zm4-18H9a2 2 0 0 0-2 2v10c0 1.1.9 2 2 2h10a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zm0 12H9V5h10v10zm-8 6h2v-2h-2v2zm-4 0h2v-2H7v2z" />
</Icon>
);
export const ToggleBackgroundActiveIcon = (props: JSX.HTMLAttributes) => (
<Icon {...props}>
<path d="M9 7H7v2h2V7zm0 4H7v2h2v-2zm0-8a2 2 0 0 0-2 2h2V3zm4 12h-2v2h2v-2zm6-12v2h2a2 2 0 0 0-2-2zm-6 0h-2v2h2V3zM9 17v-2H7c0 1.1.9 2 2 2zm10-4h2v-2h-2v2zm0-4h2V7h-2v2zm0 8a2 2 0 0 0 2-2h-2v2zM5 7H3v12c0 1.1.9 2 2 2h12v-2H5V7zm10-2h2V3h-2v2zm0 12h2v-2h-2v2z" />
</Icon>
);
export const RotateIcon = (props: JSX.HTMLAttributes) => (
<Icon {...props}>
<path d="M15.6 5.5L11 1v3a8 8 0 0 0 0 16v-2a6 6 0 0 1 0-12v4l4.5-4.5zm4.3 5.5a8 8 0 0 0-1.6-3.9L17 8.5c.5.8.9 1.6 1 2.5h2zM13 17.9v2a8 8 0 0 0 3.9-1.6L15.5 17c-.8.5-1.6.9-2.5 1zm3.9-2.4l1.4 1.4A8 8 0 0 0 20 13h-2c-.1.9-.5 1.7-1 2.5z" />
</Icon>
);
export const AddIcon = (props: JSX.HTMLAttributes) => (
<Icon {...props}>
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
</Icon>
);
export const RemoveIcon = (props: JSX.HTMLAttributes) => (
<Icon {...props}>
<path d="M19 13H5v-2h14v2z" />
</Icon>
);
export const UncheckedIcon = (props: JSX.HTMLAttributes) => (
<Icon {...props}>
<path d="M21.3 2.7v18.6H2.7V2.7h18.6m0-2.7H2.7A2.7 2.7 0 0 0 0 2.7v18.6A2.7 2.7 0 0 0 2.7 24h18.6a2.7 2.7 0 0 0 2.7-2.7V2.7A2.7 2.7 0 0 0 21.3 0z" />
</Icon>
);
export const CheckedIcon = (props: JSX.HTMLAttributes) => (
<Icon {...props}>
<path d="M21.3 0H2.7A2.7 2.7 0 0 0 0 2.7v18.6A2.7 2.7 0 0 0 2.7 24h18.6a2.7 2.7 0 0 0 2.7-2.7V2.7A2.7 2.7 0 0 0 21.3 0zm-12 18.7L2.7 12l1.8-1.9L9.3 15 19.5 4.8l1.8 1.9z" />
</Icon>
);
export const ExpandIcon = (props: JSX.HTMLAttributes) => (
<Icon {...props}>
<path d="M16.6 8.6L12 13.2 7.4 8.6 6 10l6 6 6-6z" />
</Icon>
);
export const BackIcon = (props: JSX.HTMLAttributes) => (
<Icon {...props}>
<path d="M20 11H7.8l5.6-5.6L12 4l-8 8 8 8 1.4-1.4L7.8 13H20v-2z" />
</Icon>
);
const copyAcrossRotations = {
up: 90,
right: 180,
down: -90,
left: 0,
};
export interface CopyAcrossIconProps extends JSX.HTMLAttributes {
copyDirection: keyof typeof copyAcrossRotations;
}
export const CopyAcrossIcon = (props: CopyAcrossIconProps) => {
const { copyDirection, ...otherProps } = props;
const id = 'point-' + copyDirection;
const rotation = copyAcrossRotations[copyDirection];
return (
<Icon {...otherProps}>
<defs>
<clipPath id={id}>
<path
d="M-12-12v24h24v-24zM4.5 2h-4v3l-5-5 5-5v3h4z"
transform={`translate(12 13) rotate(${rotation})`}
/>
</clipPath>
</defs>
<path
clip-path={`url(#${id})`}
d="M19 3h-4.2c-.4-1.2-1.5-2-2.8-2s-2.4.8-2.8 2H5a2 2 0 0 0-2 2v14c0 1.1.9 2 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zm-7 0a1 1 0 0 1 0 2c-.6 0-1-.4-1-1s.4-1 1-1z"
/>
</Icon>
);
};

View File

@@ -0,0 +1,57 @@
// This file contains the utils that are needed for the very first rendering of the page. They're
// here because WebPack isn't quite smart enough to split things in the same file.
/**
* A decorator that binds values to their class instance.
* @example
* class C {
* @bind
* foo () {
* return this;
* }
* }
* let f = new C().foo;
* f() instanceof C; // true
*/
export function bind(
target: any,
propertyKey: string,
descriptor: PropertyDescriptor,
) {
return {
// the first time the prototype property is accessed for an instance,
// define an instance property pointing to the bound function.
// This effectively "caches" the bound prototype method as an instance property.
get() {
const bound = descriptor.value.bind(this);
Object.defineProperty(this, propertyKey, {
value: bound,
});
return bound;
},
};
}
/** Creates a function ref that assigns its value to a given property of an object.
* @example
* // element is stored as `this.foo` when rendered.
* <div ref={linkRef(this, 'foo')} />
*/
export function linkRef<T>(obj: any, name: string) {
const refName = `$$ref_${name}`;
let ref = obj[refName];
if (!ref) {
ref = obj[refName] = (c: T) => {
obj[name] = c;
};
}
return ref;
}
// Edge doesn't support `new File`, so here's a hacky alternative.
// https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/9551546/
export class Fileish extends Blob {
constructor(data: any[], public name: string, opts?: BlobPropertyBag) {
super(data, opts);
}
}

7
src_old/lib/missing-types.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
interface HTMLImageElement {
decode: (() => Promise<void>) | undefined;
}
interface CanvasRenderingContext2D {
imageSmoothingQuality: 'low' | 'medium' | 'high';
}

115
src_old/lib/sw-bridge.ts Normal file
View File

@@ -0,0 +1,115 @@
import { get, set } from 'idb-keyval';
// Just for TypeScript
import SnackBarElement from './SnackBar';
/** Tell the service worker to skip waiting */
async function skipWaiting() {
const reg = await navigator.serviceWorker.getRegistration();
if (!reg || !reg.waiting) return;
reg.waiting.postMessage('skip-waiting');
}
/** Find the service worker that's 'active' or closest to 'active' */
async function getMostActiveServiceWorker() {
const reg = await navigator.serviceWorker.getRegistration();
if (!reg) return null;
return reg.active || reg.waiting || reg.installing;
}
/** Wait for an installing worker */
async function installingWorker(
reg: ServiceWorkerRegistration,
): Promise<ServiceWorker> {
if (reg.installing) return reg.installing;
return new Promise<ServiceWorker>((resolve) => {
reg.addEventListener('updatefound', () => resolve(reg.installing!), {
once: true,
});
});
}
/** Wait a service worker to become waiting */
async function updateReady(reg: ServiceWorkerRegistration): Promise<void> {
if (reg.waiting) return;
const installing = await installingWorker(reg);
return new Promise<void>((resolve) => {
installing.addEventListener('statechange', () => {
if (installing.state === 'installed') resolve();
});
});
}
/** Wait for a shared image */
export function getSharedImage(): Promise<File> {
return new Promise((resolve) => {
const onmessage = (event: MessageEvent) => {
if (event.data.action !== 'load-image') return;
resolve(event.data.file);
navigator.serviceWorker.removeEventListener('message', onmessage);
};
navigator.serviceWorker.addEventListener('message', onmessage);
// This message is picked up by the service worker - it's how it knows we're ready to receive
// the file.
navigator.serviceWorker.controller!.postMessage('share-ready');
});
}
/** Set up the service worker and monitor changes */
export async function offliner(showSnack: SnackBarElement['showSnackbar']) {
// This needs to be a typeof because Webpack.
if (typeof PRERENDER === 'boolean') return;
if (process.env.NODE_ENV === 'production') {
navigator.serviceWorker.register('../sw');
}
const hasController = !!navigator.serviceWorker.controller;
// Look for changes in the controller
navigator.serviceWorker.addEventListener('controllerchange', async () => {
// Is it the first install?
if (!hasController) {
showSnack('Ready to work offline', { timeout: 5000 });
return;
}
// Otherwise reload (the user will have agreed to this).
location.reload();
});
// If we don't have a controller, we don't need to check for updates we've just loaded from the
// network.
if (!hasController) return;
const reg = await navigator.serviceWorker.getRegistration();
// Service worker not registered yet.
if (!reg) return;
// Look for updates
await updateReady(reg);
// Ask the user if they want to update.
const result = await showSnack('Update available', {
actions: ['reload', 'dismiss'],
});
// Tell the waiting worker to activate, this will change the controller and cause a reload (see
// 'controllerchange')
if (result === 'reload') skipWaiting();
}
/**
* Tell the service worker the main app has loaded. If it's the first time the service worker has
* heard about this, cache the heavier assets like codecs.
*/
export async function mainAppLoaded() {
// If the user has already interacted, no need to tell the service worker anything.
const userInteracted = await get<boolean | undefined>('user-interacted');
if (userInteracted) return;
set('user-interacted', true);
const serviceWorker = await getMostActiveServiceWorker();
if (!serviceWorker) return; // Service worker not installing yet.
serviceWorker.postMessage('cache-all');
}

22
src_old/lib/util.scss Normal file
View File

@@ -0,0 +1,22 @@
.abs-fill {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
box-sizing: border-box;
contain: strict;
}
.unbutton {
cursor: pointer;
background: none;
border: none;
font: inherit;
padding: 0;
margin: 0;
&:focus {
outline: none;
}
}

355
src_old/lib/util.ts Normal file
View File

@@ -0,0 +1,355 @@
/** Compare two objects, returning a boolean indicating if
* they have the same properties and strictly equal values.
*/
export function shallowEqual(one: any, two: any) {
for (const i in one) if (one[i] !== two[i]) return false;
for (const i in two) if (!(i in one)) return false;
return true;
}
/** Replace the contents of a canvas with the given data */
export function drawDataToCanvas(canvas: HTMLCanvasElement, data: ImageData) {
const ctx = canvas.getContext('2d');
if (!ctx) throw Error('Canvas not initialized');
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.putImageData(data, 0, 0);
}
/**
* Encode some image data in a given format using the browser's encoders
*
* @param {ImageData} data
* @param {string} type A mime type, eg image/jpeg.
* @param {number} [quality] Between 0-1.
*/
export async function canvasEncode(
data: ImageData,
type: string,
quality?: number,
): Promise<Blob> {
const canvas = document.createElement('canvas');
canvas.width = data.width;
canvas.height = data.height;
const ctx = canvas.getContext('2d');
if (!ctx) throw Error('Canvas not initialized');
ctx.putImageData(data, 0, 0);
let blob: Blob | null;
if ('toBlob' in canvas) {
blob = await new Promise<Blob | null>((r) =>
canvas.toBlob(r, type, quality),
);
} else {
// Welcome to Edge.
// TypeScript thinks `canvas` is 'never', so it needs casting.
const dataUrl = (canvas as HTMLCanvasElement).toDataURL(type, quality);
const result = /data:([^;]+);base64,(.*)$/.exec(dataUrl);
if (!result) throw Error('Data URL reading failed');
const outputType = result[1];
const binaryStr = atob(result[2]);
const data = new Uint8Array(binaryStr.length);
for (let i = 0; i < data.length; i += 1) {
data[i] = binaryStr.charCodeAt(i);
}
blob = new Blob([data], { type: outputType });
}
if (!blob) throw Error('Encoding failed');
return blob;
}
async function decodeImage(url: string): Promise<HTMLImageElement> {
const img = new Image();
img.decoding = 'async';
img.src = url;
const loaded = new Promise((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(Error('Image loading error'));
});
if (img.decode) {
// Nice off-thread way supported in Safari/Chrome.
// Safari throws on decode if the source is SVG.
// https://bugs.webkit.org/show_bug.cgi?id=188347
await img.decode().catch(() => null);
}
// Always await loaded, as we may have bailed due to the Safari bug above.
await loaded;
return img;
}
/** Caches results from canDecodeImageType */
const canDecodeCache = new Map<string, Promise<boolean>>();
/**
* Tests whether the browser supports a particular image mime type.
*
* @param type Mimetype
* @example await canDecodeImageType('image/avif')
*/
export function canDecodeImageType(type: string): Promise<boolean> {
if (!canDecodeCache.has(type)) {
const resultPromise = (async () => {
const picture = document.createElement('picture');
const img = document.createElement('img');
const source = document.createElement('source');
source.srcset = 'data:,x';
source.type = type;
picture.append(source, img);
// Wait a single microtick just for the `img.currentSrc` to get populated.
await 0;
// At this point `img.currentSrc` will contain "data:,x" if format is supported and ""
// otherwise.
return !!img.currentSrc;
})();
canDecodeCache.set(type, resultPromise);
}
return canDecodeCache.get(type)!;
}
export function blobToArrayBuffer(blob: Blob): Promise<ArrayBuffer> {
return new Response(blob).arrayBuffer();
}
export function blobToText(blob: Blob): Promise<string> {
return new Response(blob).text();
}
const magicNumberToMimeType = new Map<RegExp, string>([
[/^%PDF-/, 'application/pdf'],
[/^GIF87a/, 'image/gif'],
[/^GIF89a/, 'image/gif'],
[/^\x89PNG\x0D\x0A\x1A\x0A/, 'image/png'],
[/^\xFF\xD8\xFF/, 'image/jpeg'],
[/^BM/, 'image/bmp'],
[/^I I/, 'image/tiff'],
[/^II*/, 'image/tiff'],
[/^MM\x00*/, 'image/tiff'],
[/^RIFF....WEBPVP8[LX ]/, 'image/webp'],
[/^\x00\x00\x00 ftypavif\x00\x00\x00\x00/, 'image/avif'],
]);
export async function sniffMimeType(blob: Blob): Promise<string> {
const firstChunk = await blobToArrayBuffer(blob.slice(0, 16));
const firstChunkString = Array.from(new Uint8Array(firstChunk))
.map((v) => String.fromCodePoint(v))
.join('');
for (const [detector, mimeType] of magicNumberToMimeType) {
if (detector.test(firstChunkString)) {
return mimeType;
}
}
return '';
}
export async function blobToImg(blob: Blob): Promise<HTMLImageElement> {
const url = URL.createObjectURL(blob);
try {
return await decodeImage(url);
} finally {
URL.revokeObjectURL(url);
}
}
interface DrawableToImageDataOptions {
width?: number;
height?: number;
sx?: number;
sy?: number;
sw?: number;
sh?: number;
}
export function drawableToImageData(
drawable: ImageBitmap | HTMLImageElement,
opts: DrawableToImageDataOptions = {},
): ImageData {
const {
width = drawable.width,
height = drawable.height,
sx = 0,
sy = 0,
sw = drawable.width,
sh = drawable.height,
} = opts;
// Make canvas same size as image
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
// Draw image onto canvas
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Could not create canvas context');
ctx.drawImage(drawable, sx, sy, sw, sh, 0, 0, width, height);
return ctx.getImageData(0, 0, width, height);
}
export async function builtinDecode(blob: Blob): Promise<ImageData> {
// Prefer createImageBitmap as it's the off-thread option for Firefox.
const drawable =
'createImageBitmap' in self
? await createImageBitmap(blob)
: await blobToImg(blob);
return drawableToImageData(drawable);
}
export type BuiltinResizeMethod = 'pixelated' | 'low' | 'medium' | 'high';
export function builtinResize(
data: ImageData,
sx: number,
sy: number,
sw: number,
sh: number,
dw: number,
dh: number,
method: BuiltinResizeMethod,
): ImageData {
const canvasSource = document.createElement('canvas');
canvasSource.width = data.width;
canvasSource.height = data.height;
drawDataToCanvas(canvasSource, data);
const canvasDest = document.createElement('canvas');
canvasDest.width = dw;
canvasDest.height = dh;
const ctx = canvasDest.getContext('2d');
if (!ctx) throw new Error('Could not create canvas context');
if (method === 'pixelated') {
ctx.imageSmoothingEnabled = false;
} else {
ctx.imageSmoothingQuality = method;
}
ctx.drawImage(canvasSource, sx, sy, sw, sh, 0, 0, dw, dh);
return ctx.getImageData(0, 0, dw, dh);
}
/**
* @param field An HTMLInputElement, but the casting is done here to tidy up onChange.
* @param defaultVal Value to return if 'field' doesn't exist.
*/
export function inputFieldValueAsNumber(
field: any,
defaultVal: number = 0,
): number {
if (!field) return defaultVal;
return Number(inputFieldValue(field));
}
/**
* @param field An HTMLInputElement, but the casting is done here to tidy up onChange.
* @param defaultVal Value to return if 'field' doesn't exist.
*/
export function inputFieldCheckedAsNumber(
field: any,
defaultVal: number = 0,
): number {
if (!field) return defaultVal;
return Number(inputFieldChecked(field));
}
/**
* @param field An HTMLInputElement, but the casting is done here to tidy up onChange.
* @param defaultVal Value to return if 'field' doesn't exist.
*/
export function inputFieldChecked(
field: any,
defaultVal: boolean = false,
): boolean {
if (!field) return defaultVal;
return (field as HTMLInputElement).checked;
}
/**
* @param field An HTMLInputElement, but the casting is done here to tidy up onChange.
* @param defaultVal Value to return if 'field' doesn't exist.
*/
export function inputFieldValue(field: any, defaultVal: string = ''): string {
if (!field) return defaultVal;
return (field as HTMLInputElement).value;
}
/**
* Creates a promise that resolves when the user types the konami code.
*/
export function konami(): Promise<void> {
return new Promise((resolve) => {
// Keycodes for: ↑ ↑ ↓ ↓ ← → ← → B A
const expectedPattern = '38384040373937396665';
let rollingPattern = '';
const listener = (event: KeyboardEvent) => {
rollingPattern += event.keyCode;
rollingPattern = rollingPattern.slice(-expectedPattern.length);
if (rollingPattern === expectedPattern) {
window.removeEventListener('keydown', listener);
resolve();
}
};
window.addEventListener('keydown', listener);
});
}
interface TransitionOptions {
from?: number;
to?: number;
duration?: number;
easing?: string;
}
export async function transitionHeight(
el: HTMLElement,
opts: TransitionOptions,
): Promise<void> {
const {
from = el.getBoundingClientRect().height,
to = el.getBoundingClientRect().height,
duration = 1000,
easing = 'ease-in-out',
} = opts;
if (from === to || duration === 0) {
el.style.height = to + 'px';
return;
}
el.style.height = from + 'px';
// Force a style calc so the browser picks up the start value.
getComputedStyle(el).transform;
el.style.transition = `height ${duration}ms ${easing}`;
el.style.height = to + 'px';
return new Promise<void>((resolve) => {
const listener = (event: Event) => {
if (event.target !== el) return;
el.style.transition = '';
el.removeEventListener('transitionend', listener);
el.removeEventListener('transitioncancel', listener);
resolve();
};
el.addEventListener('transitionend', listener);
el.addEventListener('transitioncancel', listener);
});
}
/**
* Simple event listener that prevents the default.
*/
export function preventDefault(event: Event) {
event.preventDefault();
}