Removing old code, some bugfixes

This commit is contained in:
Jake Archibald
2020-11-11 15:28:57 +00:00
parent 56f9d4b8c8
commit 2d21406484
134 changed files with 37 additions and 9068 deletions

View File

@@ -7,71 +7,59 @@ interface Props {
children: ComponentChildren;
}
interface State {
outgoingChildren: ComponentChild[];
children: ComponentChildren;
outgoingChildren: ComponentChildren;
}
export default class Expander extends Component<Props, State> {
state: State = {
outgoingChildren: [],
};
private lastElHeight: number = 0;
componentWillReceiveProps(nextProps: Props) {
const children = this.props.children as ComponentChild[] | undefined;
const nextChildren = nextProps.children as ComponentChild[] | undefined;
if (!nextChildren && children && children[0]) {
// Cache the current children for the shrink animation.
this.setState({ outgoingChildren: children });
static getDerivedStateFromProps(
props: Props,
state: State,
): Partial<State> | null {
if (!props.children && state.children) {
return { children: props.children, outgoingChildren: state.children };
}
if (props.children !== state.children) {
return { children: props.children, outgoingChildren: undefined };
}
return null;
}
componentWillUpdate(nextProps: Props) {
const children = this.props.children as ComponentChild[] | undefined;
const nextChildren = nextProps.children as ComponentChild[] | undefined;
async componentDidUpdate(_: Props, previousState: State) {
let heightFrom: number;
let heightTo: number;
// Only interested if going from empty to not-empty, or not-empty to empty.
if ((children && nextChildren) || (!children && !nextChildren)) return;
this.lastElHeight = (this
.base as HTMLElement).getBoundingClientRect().height;
}
async componentDidUpdate(previousProps: Props) {
const children = this.props.children as ComponentChild[] | undefined;
const previousChildren = previousProps.children as
| ComponentChild[]
| undefined;
// Only interested if going from empty to not-empty, or not-empty to empty.
if ((children && previousChildren) || (!children && !previousChildren))
if (previousState.children && !this.state.children) {
heightFrom = (this.base as HTMLElement).getBoundingClientRect().height;
heightTo = 0;
} else if (!previousState.children && this.state.children) {
heightFrom = 0;
heightTo = (this.base as HTMLElement).getBoundingClientRect().height;
} else {
return;
}
// What height do we need to transition to?
(this.base as HTMLElement).style.height = '';
(this.base as HTMLElement).style.overflow = 'hidden';
const newHeight = children
? (this.base as HTMLElement).getBoundingClientRect().height
: 0;
await transitionHeight(this.base as HTMLElement, {
duration: 300,
from: this.lastElHeight,
to: newHeight,
from: heightFrom,
to: heightTo,
});
// Unset the height & overflow, so element changes do the right thing.
(this.base as HTMLElement).style.height = '';
(this.base as HTMLElement).style.overflow = '';
if (this.state.outgoingChildren[0]) this.setState({ outgoingChildren: [] });
this.setState({ outgoingChildren: undefined });
}
render(props: Props, { outgoingChildren }: State) {
const children = props.children as ComponentChild[] | undefined;
const childrenExiting = (!children || !children[0]) && outgoingChildren[0];
render({}: Props, { children, outgoingChildren }: State) {
return (
<div class={childrenExiting ? style.childrenExiting : ''}>
{children && children[0] ? children : outgoingChildren}
<div class={outgoingChildren ? style.childrenExiting : ''}>
{outgoingChildren || children}
</div>
);
}

View File

@@ -107,6 +107,8 @@ class RangeInputElement extends HTMLElement {
};
private _update = () => {
// Not connected?
if (!this._valueDisplay) return;
const value = Number(this.value) || 0;
const min = Number(this.min) || 0;
const max = Number(this.max) || 100;

View File

@@ -133,7 +133,7 @@ export default class Options extends Component<Props, State> {
<label class={style.sectionEnabler}>
<Checkbox
name="quantizer.enable"
name="quantize.enable"
checked={!!processorState.quantize.enabled}
onChange={this.onProcessorEnabledChange}
/>
@@ -173,12 +173,12 @@ export default class Options extends Component<Props, State> {
</section>
<Expander>
{EncoderOptionComponent && encoderState && (
{EncoderOptionComponent && (
<EncoderOptionComponent
options={
// Casting options, as encoderOptionsComponentMap[encodeData.type] ensures
// the correct type, but typescript isn't smart enough.
encoderState.options as any
encoderState!.options as any
}
onChange={onEncoderOptionsChange}
/>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

View File

@@ -1,7 +0,0 @@
export const name = 'WASM AVIF Decoder';
const supportedMimeTypes = ['image/avif'];
export function canHandleMimeType(mimeType: string): boolean {
return supportedMimeTypes.includes(mimeType);
}

View File

@@ -1,17 +0,0 @@
import avif_dec, { AVIFModule } from '../../../codecs/avif/dec/avif_dec';
import wasmUrl from '../../../codecs/avif/dec/avif_dec.wasm';
import { initEmscriptenModule } from '../util';
let emscriptenModule: Promise<AVIFModule>;
export async function decode(data: ArrayBuffer): Promise<ImageData> {
if (!emscriptenModule)
emscriptenModule = initEmscriptenModule(avif_dec, wasmUrl);
const module = await emscriptenModule;
const result = module.decode(data);
if (!result) {
throw new Error('Decoding error');
}
return result;
}

View File

@@ -1,30 +0,0 @@
export interface EncodeOptions {
minQuantizer: number;
maxQuantizer: number;
minQuantizerAlpha: number;
maxQuantizerAlpha: number;
tileRowsLog2: number;
tileColsLog2: number;
speed: number;
subsample: number;
}
export const type = 'avif';
export const label = 'AVIF';
export const mimeType = 'image/avif';
export const extension = 'avif';
export const defaultOptions: EncodeOptions = {
minQuantizer: 33,
maxQuantizer: 63,
minQuantizerAlpha: 33,
maxQuantizerAlpha: 63,
tileColsLog2: 0,
tileRowsLog2: 0,
speed: 8,
subsample: 1,
};
export interface EncoderState {
type: typeof type;
options: EncodeOptions;
}

View File

@@ -1,24 +0,0 @@
import avif_enc, { AVIFModule } from '../../../codecs/avif/enc/avif_enc';
import wasmUrl from '../../../codecs/avif/enc/avif_enc.wasm';
import { EncodeOptions } from './encoder-meta';
import { initEmscriptenModule } from '../util';
let emscriptenModule: Promise<AVIFModule>;
export async function encode(
data: ImageData,
options: EncodeOptions,
): Promise<ArrayBuffer> {
if (!emscriptenModule)
emscriptenModule = initEmscriptenModule(avif_enc, wasmUrl);
const module = await emscriptenModule;
const result = module.encode(data.data, data.width, data.height, options);
if (!result) {
throw new Error('Encoding error');
}
// wasm cant run on SharedArrayBuffers, so we hard-cast to ArrayBuffer.
return result.buffer as ArrayBuffer;
}

View File

@@ -1,350 +0,0 @@
import { h, Component } from 'preact';
import { preventDefault, shallowEqual } from '../../lib/util';
import { EncodeOptions, defaultOptions } from './encoder-meta';
import * as style from '../../components/Options/style.scss';
import Checkbox from '../../components/checkbox';
import Expander from '../../components/expander';
import Select from '../../components/select';
import Range from '../../components/range';
import linkState from 'linkstate';
interface Props {
options: EncodeOptions;
onChange(newOptions: EncodeOptions): void;
}
interface State {
options: EncodeOptions;
lossless: boolean;
maxQuality: number;
minQuality: number;
separateAlpha: boolean;
losslessAlpha: boolean;
maxAlphaQuality: number;
minAlphaQuality: number;
showAdvanced: boolean;
grayscale: boolean;
subsample: number;
tileRows: number;
tileCols: number;
effort: number;
}
const maxQuant = 63;
const maxSpeed = 10;
export default class AVIFEncoderOptions extends Component<Props, State> {
static getDerivedStateFromProps(
props: Props,
state: State,
): Partial<State> | undefined {
if (state.options && shallowEqual(state.options, props.options)) return;
const { options } = props;
const lossless = options.maxQuantizer === 0 && options.minQuantizer === 0;
const minQuantizerValue = lossless
? defaultOptions.minQuantizer
: options.minQuantizer;
const maxQuantizerValue = lossless
? defaultOptions.maxQuantizer
: options.maxQuantizer;
const losslessAlpha =
options.maxQuantizerAlpha === 0 && options.minQuantizerAlpha === 0;
const minQuantizerAlphaValue = losslessAlpha
? defaultOptions.minQuantizerAlpha
: options.minQuantizerAlpha;
const maxQuantizerAlphaValue = losslessAlpha
? defaultOptions.maxQuantizerAlpha
: options.maxQuantizerAlpha;
// Create default form state from options
return {
options,
lossless,
losslessAlpha,
maxQuality: maxQuant - minQuantizerValue,
minQuality: maxQuant - maxQuantizerValue,
separateAlpha:
options.maxQuantizer !== options.maxQuantizerAlpha ||
options.minQuantizer !== options.minQuantizerAlpha,
maxAlphaQuality: maxQuant - minQuantizerAlphaValue,
minAlphaQuality: maxQuant - maxQuantizerAlphaValue,
grayscale: options.subsample === 0,
subsample:
options.subsample === 0 || lossless
? defaultOptions.subsample
: options.subsample,
tileRows: options.tileRowsLog2,
tileCols: options.tileColsLog2,
effort: maxSpeed - options.speed,
};
}
// The rest of the defaults are set in getDerivedStateFromProps
state: State = {
showAdvanced: false,
} as State;
private _inputChangeCallbacks = new Map<string, (event: Event) => void>();
private _inputChange = (prop: keyof State, type: 'number' | 'boolean') => {
// Cache the callback for performance
if (!this._inputChangeCallbacks.has(prop)) {
this._inputChangeCallbacks.set(prop, (event: Event) => {
const formEl = event.target as HTMLInputElement | HTMLSelectElement;
const newVal =
type === 'boolean'
? 'checked' in formEl
? formEl.checked
: !!formEl.value
: Number(formEl.value);
const newState: Partial<State> = {
[prop]: newVal,
};
// Ensure that min cannot be greater than max
switch (prop) {
case 'maxQuality':
if (newVal < this.state.minQuality) {
newState.minQuality = newVal as number;
}
break;
case 'minQuality':
if (newVal > this.state.maxQuality) {
newState.maxQuality = newVal as number;
}
break;
case 'maxAlphaQuality':
if (newVal < this.state.minAlphaQuality) {
newState.minAlphaQuality = newVal as number;
}
break;
case 'minAlphaQuality':
if (newVal > this.state.maxAlphaQuality) {
newState.maxAlphaQuality = newVal as number;
}
break;
}
const optionState = {
...this.state,
...newState,
};
const maxQuantizer = optionState.lossless
? 0
: maxQuant - optionState.minQuality;
const minQuantizer = optionState.lossless
? 0
: maxQuant - optionState.maxQuality;
const newOptions: EncodeOptions = {
maxQuantizer,
minQuantizer,
maxQuantizerAlpha: optionState.separateAlpha
? optionState.losslessAlpha
? 0
: maxQuant - optionState.minAlphaQuality
: maxQuantizer,
minQuantizerAlpha: optionState.separateAlpha
? optionState.losslessAlpha
? 0
: maxQuant - optionState.maxAlphaQuality
: minQuantizer,
// Always set to 4:4:4 if lossless
subsample: optionState.grayscale
? 0
: optionState.lossless
? 3
: optionState.subsample,
tileColsLog2: optionState.tileCols,
tileRowsLog2: optionState.tileRows,
speed: maxSpeed - optionState.effort,
};
// Updating options, so we don't recalculate in getDerivedStateFromProps.
newState.options = newOptions;
this.setState(
// It isn't clear to me why I have to cast this :)
newState as State,
);
this.props.onChange(newOptions);
});
}
return this._inputChangeCallbacks.get(prop)!;
};
render(
_: Props,
{
effort,
grayscale,
lossless,
losslessAlpha,
maxAlphaQuality,
maxQuality,
minAlphaQuality,
minQuality,
separateAlpha,
showAdvanced,
subsample,
tileCols,
tileRows,
}: State,
) {
return (
<form class={style.optionsSection} onSubmit={preventDefault}>
<label class={style.optionInputFirst}>
<Checkbox
checked={lossless}
onChange={this._inputChange('lossless', 'boolean')}
/>
Lossless
</label>
<Expander>
{!lossless && (
<div>
<div class={style.optionOneCell}>
<Range
min="0"
max="62"
value={maxQuality}
onInput={this._inputChange('maxQuality', 'number')}
>
Max quality:
</Range>
</div>
<div class={style.optionOneCell}>
<Range
min="0"
max="62"
value={minQuality}
onInput={this._inputChange('minQuality', 'number')}
>
Min quality:
</Range>
</div>
</div>
)}
</Expander>
<label class={style.optionInputFirst}>
<Checkbox
checked={separateAlpha}
onChange={this._inputChange('separateAlpha', 'boolean')}
/>
Separate alpha quality
</label>
<Expander>
{separateAlpha && (
<div>
<label class={style.optionInputFirst}>
<Checkbox
checked={losslessAlpha}
onChange={this._inputChange('losslessAlpha', 'boolean')}
/>
Lossless alpha
</label>
<Expander>
{!losslessAlpha && (
<div>
<div class={style.optionOneCell}>
<Range
min="0"
max="62"
value={maxAlphaQuality}
onInput={this._inputChange('maxAlphaQuality', 'number')}
>
Max alpha quality:
</Range>
</div>
<div class={style.optionOneCell}>
<Range
min="0"
max="62"
value={minAlphaQuality}
onInput={this._inputChange('minAlphaQuality', 'number')}
>
Min alpha quality:
</Range>
</div>
</div>
)}
</Expander>
</div>
)}
</Expander>
<label class={style.optionInputFirst}>
<Checkbox
checked={showAdvanced}
onChange={linkState(this, 'showAdvanced')}
/>
Show advanced settings
</label>
<Expander>
{showAdvanced && (
<div>
{/*<label class={style.optionInputFirst}>
<Checkbox
data-set-state="grayscale"
checked={grayscale}
onChange={this._inputChange('grayscale', 'boolean')}
/>
Grayscale
</label>*/}
<Expander>
{!grayscale && !lossless && (
<label class={style.optionTextFirst}>
Subsample chroma:
<Select
data-set-state="subsample"
value={subsample}
onChange={this._inputChange('subsample', 'number')}
>
<option value="1">4:2:0</option>
{/*<option value="2">4:2:2</option>*/}
<option value="3">4:4:4</option>
</Select>
</label>
)}
</Expander>
<div class={style.optionOneCell}>
<Range
min="0"
max="6"
value={tileRows}
onInput={this._inputChange('tileRows', 'number')}
>
Log2 of tile rows:
</Range>
</div>
<div class={style.optionOneCell}>
<Range
min="0"
max="6"
value={tileCols}
onInput={this._inputChange('tileCols', 'number')}
>
Log2 of tile cols:
</Range>
</div>
</div>
)}
</Expander>
<div class={style.optionOneCell}>
<Range
min="0"
max="10"
value={effort}
onInput={this._inputChange('effort', 'number')}
>
Effort:
</Range>
</div>
</form>
);
}
}

View File

@@ -1,14 +0,0 @@
import { canvasEncodeTest } from '../generic/util';
export interface EncodeOptions {}
export interface EncoderState {
type: typeof type;
options: EncodeOptions;
}
export const type = 'browser-bmp';
export const label = 'Browser BMP';
export const mimeType = 'image/bmp';
export const extension = 'bmp';
export const defaultOptions: EncodeOptions = {};
export const featureTest = () => canvasEncodeTest(mimeType);

View File

@@ -1,6 +0,0 @@
import { mimeType } from './encoder-meta';
import { canvasEncode } from '../../lib/util';
export function encode(data: ImageData) {
return canvasEncode(data, mimeType);
}

View File

@@ -1,14 +0,0 @@
import { canvasEncodeTest } from '../generic/util';
export interface EncodeOptions {}
export interface EncoderState {
type: typeof type;
options: EncodeOptions;
}
export const type = 'browser-gif';
export const label = 'Browser GIF';
export const mimeType = 'image/gif';
export const extension = 'gif';
export const defaultOptions: EncodeOptions = {};
export const featureTest = () => canvasEncodeTest(mimeType);

View File

@@ -1,6 +0,0 @@
import { mimeType } from './encoder-meta';
import { canvasEncode } from '../../lib/util';
export function encode(data: ImageData) {
return canvasEncode(data, mimeType);
}

View File

@@ -1,14 +0,0 @@
import { canvasEncodeTest } from '../generic/util';
export interface EncodeOptions {}
export interface EncoderState {
type: typeof type;
options: EncodeOptions;
}
export const type = 'browser-jp2';
export const label = 'Browser JPEG 2000';
export const mimeType = 'image/jp2';
export const extension = 'jp2';
export const defaultOptions: EncodeOptions = {};
export const featureTest = () => canvasEncodeTest(mimeType);

View File

@@ -1,6 +0,0 @@
import { mimeType } from './encoder-meta';
import { canvasEncode } from '../../lib/util';
export function encode(data: ImageData) {
return canvasEncode(data, mimeType);
}

View File

@@ -1,13 +0,0 @@
export interface EncodeOptions {
quality: number;
}
export interface EncoderState {
type: typeof type;
options: EncodeOptions;
}
export const type = 'browser-jpeg';
export const label = 'Browser JPEG';
export const mimeType = 'image/jpeg';
export const extension = 'jpg';
export const defaultOptions: EncodeOptions = { quality: 0.75 };

View File

@@ -1,6 +0,0 @@
import { EncodeOptions, mimeType } from './encoder-meta';
import { canvasEncode } from '../../lib/util';
export function encode(data: ImageData, { quality }: EncodeOptions) {
return canvasEncode(data, mimeType, quality);
}

View File

@@ -1,3 +0,0 @@
import qualityOption from '../generic/quality-option';
export default qualityOption({ min: 0, max: 1, step: 0.01 });

View File

@@ -1,14 +0,0 @@
import { canvasEncodeTest } from '../generic/util';
export interface EncodeOptions {}
export interface EncoderState {
type: typeof type;
options: EncodeOptions;
}
export const type = 'browser-pdf';
export const label = 'Browser PDF';
export const mimeType = 'application/pdf';
export const extension = 'pdf';
export const defaultOptions: EncodeOptions = {};
export const featureTest = () => canvasEncodeTest(mimeType);

View File

@@ -1,6 +0,0 @@
import { mimeType } from './encoder-meta';
import { canvasEncode } from '../../lib/util';
export function encode(data: ImageData) {
return canvasEncode(data, mimeType);
}

View File

@@ -1,11 +0,0 @@
export interface EncodeOptions {}
export interface EncoderState {
type: typeof type;
options: EncodeOptions;
}
export const type = 'browser-png';
export const label = 'Browser PNG';
export const mimeType = 'image/png';
export const extension = 'png';
export const defaultOptions: EncodeOptions = {};

View File

@@ -1,6 +0,0 @@
import { mimeType } from './encoder-meta';
import { canvasEncode } from '../../lib/util';
export function encode(data: ImageData) {
return canvasEncode(data, mimeType);
}

View File

@@ -1,14 +0,0 @@
import { canvasEncodeTest } from '../generic/util';
export interface EncodeOptions {}
export interface EncoderState {
type: typeof type;
options: EncodeOptions;
}
export const type = 'browser-tiff';
export const label = 'Browser TIFF';
export const mimeType = 'image/tiff';
export const extension = 'tiff';
export const defaultOptions: EncodeOptions = {};
export const featureTest = () => canvasEncodeTest(mimeType);

View File

@@ -1,6 +0,0 @@
import { mimeType } from './encoder-meta';
import { canvasEncode } from '../../lib/util';
export function encode(data: ImageData) {
return canvasEncode(data, mimeType);
}

View File

@@ -1,16 +0,0 @@
import { canvasEncodeTest } from '../generic/util';
export interface EncodeOptions {
quality: number;
}
export interface EncoderState {
type: typeof type;
options: EncodeOptions;
}
export const type = 'browser-webp';
export const label = 'Browser WebP';
export const mimeType = 'image/webp';
export const extension = 'webp';
export const defaultOptions: EncodeOptions = { quality: 0.75 };
export const featureTest = () => canvasEncodeTest(mimeType);

View File

@@ -1,6 +0,0 @@
import { EncodeOptions, mimeType } from './encoder-meta';
import { canvasEncode } from '../../lib/util';
export function encode(data: ImageData, { quality }: EncodeOptions) {
return canvasEncode(data, mimeType, quality);
}

View File

@@ -1,3 +0,0 @@
import qualityOption from '../generic/quality-option';
export default qualityOption({ min: 0, max: 1, step: 0.01 });

View File

@@ -1,21 +0,0 @@
import { builtinDecode, sniffMimeType, canDecodeImageType } from '../lib/util';
import Processor from './processor';
export async function decodeImage(
blob: Blob,
processor: Processor,
): Promise<ImageData> {
const mimeType = await sniffMimeType(blob);
const canDecode = await canDecodeImageType(mimeType);
try {
if (!canDecode) {
if (mimeType === 'image/avif') return await processor.avifDecode(blob);
if (mimeType === 'image/webp') return await processor.webpDecode(blob);
// If it's not one of those types, fall through and try built-in decoding for a laugh.
}
return await builtinDecode(blob);
} catch (err) {
throw Error("Couldn't decode image");
}
}

View File

@@ -1,85 +0,0 @@
import * as identity from './identity/encoder-meta';
import * as oxiPNG from './oxipng/encoder-meta';
import * as mozJPEG from './mozjpeg/encoder-meta';
import * as webP from './webp/encoder-meta';
import * as avif from './avif/encoder-meta';
import * as browserPNG from './browser-png/encoder-meta';
import * as browserJPEG from './browser-jpeg/encoder-meta';
import * as browserWebP from './browser-webp/encoder-meta';
import * as browserGIF from './browser-gif/encoder-meta';
import * as browserTIFF from './browser-tiff/encoder-meta';
import * as browserJP2 from './browser-jp2/encoder-meta';
import * as browserBMP from './browser-bmp/encoder-meta';
import * as browserPDF from './browser-pdf/encoder-meta';
export interface EncoderSupportMap {
[key: string]: boolean;
}
export type EncoderState =
| identity.EncoderState
| oxiPNG.EncoderState
| mozJPEG.EncoderState
| webP.EncoderState
| avif.EncoderState
| browserPNG.EncoderState
| browserJPEG.EncoderState
| browserWebP.EncoderState
| browserGIF.EncoderState
| browserTIFF.EncoderState
| browserJP2.EncoderState
| browserBMP.EncoderState
| browserPDF.EncoderState;
export type EncoderOptions =
| identity.EncodeOptions
| oxiPNG.EncodeOptions
| mozJPEG.EncodeOptions
| webP.EncodeOptions
| avif.EncodeOptions
| browserPNG.EncodeOptions
| browserJPEG.EncodeOptions
| browserWebP.EncodeOptions
| browserGIF.EncodeOptions
| browserTIFF.EncodeOptions
| browserJP2.EncodeOptions
| browserBMP.EncodeOptions
| browserPDF.EncodeOptions;
export type EncoderType = keyof typeof encoderMap;
export const encoderMap = {
[identity.type]: identity,
[oxiPNG.type]: oxiPNG,
[mozJPEG.type]: mozJPEG,
[webP.type]: webP,
[avif.type]: avif,
[browserPNG.type]: browserPNG,
[browserJPEG.type]: browserJPEG,
[browserWebP.type]: browserWebP,
// Safari & Firefox only:
[browserBMP.type]: browserBMP,
// Safari only:
[browserGIF.type]: browserGIF,
[browserTIFF.type]: browserTIFF,
[browserJP2.type]: browserJP2,
[browserPDF.type]: browserPDF,
};
export const encoders = Array.from(Object.values(encoderMap));
/** Does this browser support a given encoder? Indexed by label */
export const encodersSupported = Promise.resolve().then(async () => {
const encodersSupported: EncoderSupportMap = {};
await Promise.all(
encoders.map(async (encoder) => {
// If the encoder provides a featureTest, call it, otherwise assume supported.
const isSupported =
!('featureTest' in encoder) || (await encoder.featureTest());
encodersSupported[encoder.type] = isSupported;
}),
);
return encodersSupported;
});

View File

@@ -1,52 +0,0 @@
import { h, Component } from 'preact';
import { bind } from '../../lib/initial-util';
import * as style from '../../components/Options/style.scss';
import Range from '../../components/range';
interface EncodeOptions {
quality: number;
}
type Props = {
options: EncodeOptions;
onChange(newOptions: EncodeOptions): void;
};
interface QualityOptionArg {
min?: number;
max?: number;
step?: number;
}
export default function qualityOption(opts: QualityOptionArg = {}) {
const { min = 0, max = 100, step = 1 } = opts;
class QualityOptions extends Component<Props, {}> {
@bind
onChange(event: Event) {
const el = event.currentTarget as HTMLInputElement;
this.props.onChange({ quality: Number(el.value) });
}
render({ options }: Props) {
return (
<div class={style.optionsSection}>
<div class={style.optionOneCell}>
<Range
name="quality"
min={min}
max={max}
step={step || 'any'}
value={options.quality}
onInput={this.onChange}
>
Quality:
</Range>
</div>
</div>
);
}
}
return QualityOptions;
}

View File

@@ -1,13 +0,0 @@
import { canvasEncode } from '../../lib/util';
export async function canvasEncodeTest(mimeType: string) {
try {
const blob = await canvasEncode(new ImageData(1, 1), mimeType);
// According to the spec, the blob should be null if the format isn't supported…
if (!blob) return false;
// …but Safari & Firefox fall back to PNG, so we need to check the mime type.
return blob.type === mimeType;
} catch (err) {
return false;
}
}

View File

@@ -1,3 +0,0 @@
export interface HqxOptions {
factor: 2 | 3 | 4;
}

View File

@@ -1,20 +0,0 @@
import { resize } from '../../../codecs/hqx/pkg';
import { HqxOptions } from './processor-meta';
export async function hqx(
data: ImageData,
opts: HqxOptions,
): Promise<ImageData> {
const input = data;
const result = resize(
new Uint32Array(input.data.buffer),
input.width,
input.height,
opts.factor,
);
return new ImageData(
new Uint8ClampedArray(result.buffer),
data.width * opts.factor,
data.height * opts.factor,
);
}

View File

@@ -1,9 +0,0 @@
export interface EncodeOptions {}
export interface EncoderState {
type: typeof type;
options: EncodeOptions;
}
export const type = 'identity';
export const label = 'Original image';
export const defaultOptions: EncodeOptions = {};

View File

@@ -1,100 +0,0 @@
import { h, Component } from 'preact';
import { bind } from '../../lib/initial-util';
import {
inputFieldValueAsNumber,
konami,
preventDefault,
} from '../../lib/util';
import { QuantizeOptions } from './processor-meta';
import * as style from '../../components/Options/style.scss';
import Expander from '../../components/expander';
import Select from '../../components/select';
import Range from '../../components/range';
const konamiPromise = konami();
interface Props {
options: QuantizeOptions;
onChange(newOptions: QuantizeOptions): void;
}
interface State {
extendedSettings: boolean;
}
export default class QuantizerOptions extends Component<Props, State> {
state: State = { extendedSettings: false };
componentDidMount() {
konamiPromise.then(() => {
this.setState({ extendedSettings: true });
});
}
@bind
onChange(event: Event) {
const form = (event.currentTarget as HTMLInputElement).closest(
'form',
) as HTMLFormElement;
const { options } = this.props;
const newOptions: QuantizeOptions = {
zx: inputFieldValueAsNumber(form.zx, options.zx),
maxNumColors: inputFieldValueAsNumber(
form.maxNumColors,
options.maxNumColors,
),
dither: inputFieldValueAsNumber(form.dither),
};
this.props.onChange(newOptions);
}
render({ options }: Props, { extendedSettings }: State) {
return (
<form class={style.optionsSection} onSubmit={preventDefault}>
<Expander>
{extendedSettings ? (
<label class={style.optionTextFirst}>
Type:
<Select
name="zx"
value={'' + options.zx}
onChange={this.onChange}
>
<option value="0">Standard</option>
<option value="1">ZX</option>
</Select>
</label>
) : null}
</Expander>
<Expander>
{options.zx ? null : (
<div class={style.optionOneCell}>
<Range
name="maxNumColors"
min="2"
max="256"
value={options.maxNumColors}
onInput={this.onChange}
>
Colors:
</Range>
</div>
)}
</Expander>
<div class={style.optionOneCell}>
<Range
name="dither"
min="0"
max="1"
step="0.01"
value={options.dither}
onInput={this.onChange}
>
Dithering:
</Range>
</div>
</form>
);
}
}

View File

@@ -1,11 +0,0 @@
export interface QuantizeOptions {
zx: number;
maxNumColors: number;
dither: number;
}
export const defaultOptions: QuantizeOptions = {
zx: 0,
maxNumColors: 256,
dither: 1.0,
};

View File

@@ -1,30 +0,0 @@
import imagequant, {
QuantizerModule,
} from '../../../codecs/imagequant/imagequant';
import wasmUrl from '../../../codecs/imagequant/imagequant.wasm';
import { QuantizeOptions } from './processor-meta';
import { initEmscriptenModule } from '../util';
let emscriptenModule: Promise<QuantizerModule>;
export async function process(
data: ImageData,
opts: QuantizeOptions,
): Promise<ImageData> {
if (!emscriptenModule)
emscriptenModule = initEmscriptenModule(imagequant, wasmUrl);
const module = await emscriptenModule;
const result = opts.zx
? module.zx_quantize(data.data, data.width, data.height, opts.dither)
: module.quantize(
data.data,
data.width,
data.height,
opts.maxNumColors,
opts.dither,
);
return new ImageData(result, data.width, data.height);
}

View File

@@ -1,9 +0,0 @@
import { defaultOptions as rotateDefaultOptions } from './rotate/processor-meta';
export interface InputProcessorState {
rotate: import('./rotate/processor-meta').RotateOptions;
}
export const defaultInputProcessorState: InputProcessorState = {
rotate: rotateDefaultOptions,
};

View File

@@ -1,52 +0,0 @@
export enum MozJpegColorSpace {
GRAYSCALE = 1,
RGB,
YCbCr,
}
export interface EncodeOptions {
quality: number;
baseline: boolean;
arithmetic: boolean;
progressive: boolean;
optimize_coding: boolean;
smoothing: number;
color_space: MozJpegColorSpace;
quant_table: number;
trellis_multipass: boolean;
trellis_opt_zero: boolean;
trellis_opt_table: boolean;
trellis_loops: number;
auto_subsample: boolean;
chroma_subsample: number;
separate_chroma_quality: boolean;
chroma_quality: number;
}
export interface EncoderState {
type: typeof type;
options: EncodeOptions;
}
export const type = 'mozjpeg';
export const label = 'MozJPEG';
export const mimeType = 'image/jpeg';
export const extension = 'jpg';
export const defaultOptions: EncodeOptions = {
quality: 75,
baseline: false,
arithmetic: false,
progressive: true,
optimize_coding: true,
smoothing: 0,
color_space: MozJpegColorSpace.YCbCr,
quant_table: 3,
trellis_multipass: false,
trellis_opt_zero: false,
trellis_opt_table: false,
trellis_loops: 1,
auto_subsample: true,
chroma_subsample: 2,
separate_chroma_quality: false,
chroma_quality: 75,
};

View File

@@ -1,21 +0,0 @@
import mozjpeg_enc, {
MozJPEGModule,
} from '../../../codecs/mozjpeg_enc/mozjpeg_enc';
import wasmUrl from '../../../codecs/mozjpeg_enc/mozjpeg_enc.wasm';
import { EncodeOptions } from './encoder-meta';
import { initEmscriptenModule } from '../util';
let emscriptenModule: Promise<MozJPEGModule>;
export async function encode(
data: ImageData,
options: EncodeOptions,
): Promise<ArrayBuffer> {
if (!emscriptenModule)
emscriptenModule = initEmscriptenModule(mozjpeg_enc, wasmUrl);
const module = await emscriptenModule;
const resultView = module.encode(data.data, data.width, data.height, options);
// wasm cant run on SharedArrayBuffers, so we hard-cast to ArrayBuffer.
return resultView.buffer as ArrayBuffer;
}

View File

@@ -1,291 +0,0 @@
import { h, Component } from 'preact';
import { bind } from '../../lib/initial-util';
import {
inputFieldChecked,
inputFieldValueAsNumber,
preventDefault,
} from '../../lib/util';
import { EncodeOptions, MozJpegColorSpace } from './encoder-meta';
import * as style from '../../components/Options/style.scss';
import Checkbox from '../../components/checkbox';
import Expander from '../../components/expander';
import Select from '../../components/select';
import Range from '../../components/range';
import linkState from 'linkstate';
interface Props {
options: EncodeOptions;
onChange(newOptions: EncodeOptions): void;
}
interface State {
showAdvanced: boolean;
}
export default class MozJPEGEncoderOptions extends Component<Props, State> {
state: State = {
showAdvanced: false,
};
@bind
onChange(event: Event) {
const form = (event.currentTarget as HTMLInputElement).closest(
'form',
) as HTMLFormElement;
const { options } = this.props;
const newOptions: EncodeOptions = {
// Copy over options the form doesn't currently care about, eg arithmetic
...this.props.options,
// And now stuff from the form:
// .checked
baseline: inputFieldChecked(form.baseline, options.baseline),
progressive: inputFieldChecked(form.progressive, options.progressive),
optimize_coding: inputFieldChecked(
form.optimize_coding,
options.optimize_coding,
),
trellis_multipass: inputFieldChecked(
form.trellis_multipass,
options.trellis_multipass,
),
trellis_opt_zero: inputFieldChecked(
form.trellis_opt_zero,
options.trellis_opt_zero,
),
trellis_opt_table: inputFieldChecked(
form.trellis_opt_table,
options.trellis_opt_table,
),
auto_subsample: inputFieldChecked(
form.auto_subsample,
options.auto_subsample,
),
separate_chroma_quality: inputFieldChecked(
form.separate_chroma_quality,
options.separate_chroma_quality,
),
// .value
quality: inputFieldValueAsNumber(form.quality, options.quality),
chroma_quality: inputFieldValueAsNumber(
form.chroma_quality,
options.chroma_quality,
),
chroma_subsample: inputFieldValueAsNumber(
form.chroma_subsample,
options.chroma_subsample,
),
smoothing: inputFieldValueAsNumber(form.smoothing, options.smoothing),
color_space: inputFieldValueAsNumber(
form.color_space,
options.color_space,
),
quant_table: inputFieldValueAsNumber(
form.quant_table,
options.quant_table,
),
trellis_loops: inputFieldValueAsNumber(
form.trellis_loops,
options.trellis_loops,
),
};
this.props.onChange(newOptions);
}
render({ options }: Props, { showAdvanced }: State) {
// I'm rendering both lossy and lossless forms, as it becomes much easier when
// gathering the data.
return (
<form class={style.optionsSection} onSubmit={preventDefault}>
<div class={style.optionOneCell}>
<Range
name="quality"
min="0"
max="100"
value={options.quality}
onInput={this.onChange}
>
Quality:
</Range>
</div>
<label class={style.optionInputFirst}>
<Checkbox
checked={showAdvanced}
onChange={linkState(this, 'showAdvanced')}
/>
Show advanced settings
</label>
<Expander>
{showAdvanced ? (
<div>
<label class={style.optionTextFirst}>
Channels:
<Select
name="color_space"
value={options.color_space}
onChange={this.onChange}
>
<option value={MozJpegColorSpace.GRAYSCALE}>Grayscale</option>
<option value={MozJpegColorSpace.RGB}>RGB</option>
<option value={MozJpegColorSpace.YCbCr}>YCbCr</option>
</Select>
</label>
<Expander>
{options.color_space === MozJpegColorSpace.YCbCr ? (
<div>
<label class={style.optionInputFirst}>
<Checkbox
name="auto_subsample"
checked={options.auto_subsample}
onChange={this.onChange}
/>
Auto subsample chroma
</label>
<Expander>
{options.auto_subsample ? null : (
<div class={style.optionOneCell}>
<Range
name="chroma_subsample"
min="1"
max="4"
value={options.chroma_subsample}
onInput={this.onChange}
>
Subsample chroma by:
</Range>
</div>
)}
</Expander>
<label class={style.optionInputFirst}>
<Checkbox
name="separate_chroma_quality"
checked={options.separate_chroma_quality}
onChange={this.onChange}
/>
Separate chroma quality
</label>
<Expander>
{options.separate_chroma_quality ? (
<div class={style.optionOneCell}>
<Range
name="chroma_quality"
min="0"
max="100"
value={options.chroma_quality}
onInput={this.onChange}
>
Chroma quality:
</Range>
</div>
) : null}
</Expander>
</div>
) : null}
</Expander>
<label class={style.optionInputFirst}>
<Checkbox
name="baseline"
checked={options.baseline}
onChange={this.onChange}
/>
Pointless spec compliance
</label>
<Expander>
{options.baseline ? null : (
<label class={style.optionInputFirst}>
<Checkbox
name="progressive"
checked={options.progressive}
onChange={this.onChange}
/>
Progressive rendering
</label>
)}
</Expander>
<Expander>
{options.baseline ? (
<label class={style.optionInputFirst}>
<Checkbox
name="optimize_coding"
checked={options.optimize_coding}
onChange={this.onChange}
/>
Optimize Huffman table
</label>
) : null}
</Expander>
<div class={style.optionOneCell}>
<Range
name="smoothing"
min="0"
max="100"
value={options.smoothing}
onInput={this.onChange}
>
Smoothing:
</Range>
</div>
<label class={style.optionTextFirst}>
Quantization:
<Select
name="quant_table"
value={options.quant_table}
onChange={this.onChange}
>
<option value="0">JPEG Annex K</option>
<option value="1">Flat</option>
<option value="2">MSSIM-tuned Kodak</option>
<option value="3">ImageMagick</option>
<option value="4">PSNR-HVS-M-tuned Kodak</option>
<option value="5">Klein et al</option>
<option value="6">Watson et al</option>
<option value="7">Ahumada et al</option>
<option value="8">Peterson et al</option>
</Select>
</label>
<label class={style.optionInputFirst}>
<Checkbox
name="trellis_multipass"
checked={options.trellis_multipass}
onChange={this.onChange}
/>
Trellis multipass
</label>
<Expander>
{options.trellis_multipass ? (
<label class={style.optionInputFirst}>
<Checkbox
name="trellis_opt_zero"
checked={options.trellis_opt_zero}
onChange={this.onChange}
/>
Optimize zero block runs
</label>
) : null}
</Expander>
<label class={style.optionInputFirst}>
<Checkbox
name="trellis_opt_table"
checked={options.trellis_opt_table}
onChange={this.onChange}
/>
Optimize after trellis quantization
</label>
<div class={style.optionOneCell}>
<Range
name="trellis_loops"
min="1"
max="50"
value={options.trellis_loops}
onInput={this.onChange}
>
Trellis quantization passes:
</Range>
</div>
</div>
) : null}
</Expander>
</form>
);
}
}

View File

@@ -1,16 +0,0 @@
export interface EncodeOptions {
level: number;
}
export interface EncoderState {
type: typeof type;
options: EncodeOptions;
}
export const type = 'png';
export const label = 'OxiPNG';
export const mimeType = 'image/png';
export const extension = 'png';
export const defaultOptions: EncodeOptions = {
level: 2,
};

View File

@@ -1,9 +0,0 @@
import { optimise } from '../../../codecs/oxipng/pkg';
import { EncodeOptions } from './encoder-meta';
export async function compress(
data: ArrayBuffer,
options: EncodeOptions,
): Promise<ArrayBuffer> {
return optimise(new Uint8Array(data), options.level).buffer;
}

View File

@@ -1,44 +0,0 @@
import { h, Component } from 'preact';
import { bind } from '../../lib/initial-util';
import { inputFieldValueAsNumber, preventDefault } from '../../lib/util';
import { EncodeOptions } from './encoder-meta';
import Range from '../../components/range';
import * as style from '../../components/Options/style.scss';
type Props = {
options: EncodeOptions;
onChange(newOptions: EncodeOptions): void;
};
export default class OxiPNGEncoderOptions extends Component<Props, {}> {
@bind
onChange(event: Event) {
const form = (event.currentTarget as HTMLInputElement).closest(
'form',
) as HTMLFormElement;
const options: EncodeOptions = {
level: inputFieldValueAsNumber(form.level),
};
this.props.onChange(options);
}
render({ options }: Props) {
return (
<form class={style.optionsSection} onSubmit={preventDefault}>
<div class={style.optionOneCell}>
<Range
name="level"
min="0"
max="3"
step="1"
value={options.level}
onInput={this.onChange}
>
Effort:
</Range>
</div>
</form>
);
}
}

View File

@@ -1,27 +0,0 @@
import {
QuantizeOptions,
defaultOptions as quantizerDefaultOptions,
} from './imagequant/processor-meta';
import {
ResizeOptions,
defaultOptions as resizeDefaultOptions,
} from './resize/processor-meta';
interface Enableable {
enabled: boolean;
}
export interface PreprocessorState {
quantizer: Enableable & QuantizeOptions;
resize: Enableable & ResizeOptions;
}
export const defaultPreprocessorState: PreprocessorState = {
quantizer: {
enabled: false,
...quantizerDefaultOptions,
},
resize: {
enabled: false,
...resizeDefaultOptions,
},
};

View File

@@ -1,131 +0,0 @@
import { expose } from 'comlink';
import { isHqx } from '../resize/processor-meta';
import { clamp } from '../util';
function timed<T>(name: string, func: () => Promise<T>) {
console.time(name);
return func().finally(() => console.timeEnd(name));
}
async function mozjpegEncode(
data: ImageData,
options: import('../mozjpeg/encoder-meta').EncodeOptions,
): Promise<ArrayBuffer> {
const { encode } = await import(
/* webpackChunkName: "process-mozjpeg-enc" */
'../mozjpeg/encoder'
);
return timed('mozjpegEncode', () => encode(data, options));
}
async function quantize(
data: ImageData,
opts: import('../imagequant/processor-meta').QuantizeOptions,
): Promise<ImageData> {
const { process } = await import(
/* webpackChunkName: "process-imagequant" */
'../imagequant/processor'
);
return timed('quantize', () => process(data, opts));
}
async function rotate(
data: ImageData,
opts: import('../rotate/processor-meta').RotateOptions,
): Promise<ImageData> {
const { rotate } = await import(
/* webpackChunkName: "process-rotate" */
'../rotate/processor'
);
return timed('rotate', () => rotate(data, opts));
}
async function resize(
data: ImageData,
opts: import('../resize/processor-meta').WorkerResizeOptions,
): Promise<ImageData> {
if (isHqx(opts)) {
const { hqx } = await import(
/* webpackChunkName: "process-hqx" */
'../hqx/processor'
);
const widthRatio = opts.width / data.width;
const heightRatio = opts.height / data.height;
const ratio = Math.max(widthRatio, heightRatio);
if (ratio <= 1) return data;
const factor = clamp(Math.ceil(ratio), { min: 2, max: 4 }) as 2 | 3 | 4;
return timed('hqx', () => hqx(data, { factor }));
}
const { resize } = await import(
/* webpackChunkName: "process-resize" */
'../resize/processor'
);
return timed('resize', () => resize(data, opts));
}
async function oxiPngEncode(
data: ArrayBuffer,
options: import('../oxipng/encoder-meta').EncodeOptions,
): Promise<ArrayBuffer> {
const { compress } = await import(
/* webpackChunkName: "process-oxipng" */
'../oxipng/encoder'
);
return timed('oxiPngEncode', () => compress(data, options));
}
async function webpEncode(
data: ImageData,
options: import('../webp/encoder-meta').EncodeOptions,
): Promise<ArrayBuffer> {
const { encode } = await import(
/* webpackChunkName: "process-webp-enc" */
'../webp/encoder'
);
return timed('webpEncode', () => encode(data, options));
}
async function webpDecode(data: ArrayBuffer): Promise<ImageData> {
const { decode } = await import(
/* webpackChunkName: "process-webp-dec" */
'../webp/decoder'
);
return timed('webpDecode', () => decode(data));
}
async function avifEncode(
data: ImageData,
options: import('../avif/encoder-meta').EncodeOptions,
): Promise<ArrayBuffer> {
const { encode } = await import(
/* webpackChunkName: "process-avif-enc" */
'../avif/encoder'
);
return timed('avifEncode', () => encode(data, options));
}
async function avifDecode(data: ArrayBuffer): Promise<ImageData> {
const { decode } = await import(
/* webpackChunkName: "process-avif-dec" */
'../avif/decoder'
);
return timed('avifDencode', () => decode(data));
}
const exports = {
mozjpegEncode,
quantize,
rotate,
resize,
oxiPngEncode,
webpEncode,
webpDecode,
avifEncode,
avifDecode,
};
export type ProcessorWorkerApi = typeof exports;
expose(exports, self);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 303 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 B

View File

@@ -1,15 +0,0 @@
{
"compileOnSave": false,
"compilerOptions": {
"strict": true,
"target": "esnext",
"module": "esnext",
"lib": ["webworker", "esnext"],
"moduleResolution": "node",
"experimentalDecorators": true,
"noUnusedLocals": true,
"sourceMap": true,
"allowJs": false,
"baseUrl": "."
}
}

View File

@@ -1,251 +0,0 @@
import { proxy } from 'comlink';
import { QuantizeOptions } from './imagequant/processor-meta';
import { canvasEncode, blobToArrayBuffer } from '../lib/util';
import { EncodeOptions as MozJPEGEncoderOptions } from './mozjpeg/encoder-meta';
import { EncodeOptions as OxiPNGEncoderOptions } from './oxipng/encoder-meta';
import { EncodeOptions as WebPEncoderOptions } from './webp/encoder-meta';
import { EncodeOptions as AvifEncoderOptions } from './avif/encoder-meta';
import { EncodeOptions as BrowserJPEGOptions } from './browser-jpeg/encoder-meta';
import { EncodeOptions as BrowserWebpEncodeOptions } from './browser-webp/encoder-meta';
import {
BrowserResizeOptions,
VectorResizeOptions,
} from './resize/processor-meta';
import { browserResize, vectorResize } from './resize/processor-sync';
import * as browserBMP from './browser-bmp/encoder';
import * as browserPNG from './browser-png/encoder';
import * as browserJPEG from './browser-jpeg/encoder';
import * as browserWebP from './browser-webp/encoder';
import * as browserGIF from './browser-gif/encoder';
import * as browserTIFF from './browser-tiff/encoder';
import * as browserJP2 from './browser-jp2/encoder';
import * as browserPDF from './browser-pdf/encoder';
import { bind } from '../lib/initial-util';
type ProcessorWorkerApi = import('./processor-worker').ProcessorWorkerApi;
/** How long the worker should be idle before terminating. */
const workerTimeout = 10000;
interface ProcessingJobOptions {
needsWorker?: boolean;
}
export default class Processor {
/** Worker instance associated with this processor. */
private _worker?: Worker;
/** Comlinked worker API. */
private _workerApi?: ProcessorWorkerApi;
/** Rejector for a pending promise. */
private _abortRejector?: (err: Error) => void;
/** Is work currently happening? */
private _busy = false;
/** Incementing ID so we can tell if a job has been superseded. */
private _latestJobId: number = 0;
/** setTimeout ID for killing the worker when idle. */
private _workerTimeoutId: number = 0;
/**
* Decorator that manages the (re)starting of the worker and aborting existing jobs. Not all
* processing jobs require a worker (e.g. the main thread canvas encodes), use the needsWorker
* option to control this.
*/
private static _processingJob(options: ProcessingJobOptions = {}) {
const { needsWorker = false } = options;
return (
target: Processor,
propertyKey: string,
descriptor: PropertyDescriptor,
): void => {
const processingFunc = descriptor.value;
descriptor.value = async function (this: Processor, ...args: any[]) {
this._latestJobId += 1;
const jobId = this._latestJobId;
this.abortCurrent();
if (needsWorker) self.clearTimeout(this._workerTimeoutId);
if (!this._worker && needsWorker) {
// worker-loader does magic here.
// @ts-ignore - Typescript doesn't know about the 2nd param to new Worker, and the
// definition can't be overwritten.
this._worker = new Worker('./processor-worker', {
name: 'processor-worker',
type: 'module',
}) as Worker;
// Need to do some TypeScript trickery to make the type match.
this._workerApi = (proxy(this._worker) as any) as ProcessorWorkerApi;
}
this._busy = true;
const returnVal = Promise.race([
processingFunc.call(this, ...args),
new Promise((_, reject) => {
this._abortRejector = reject;
}),
]);
// Wait for the operation to settle.
await returnVal.catch(() => {});
// If no other jobs are happening, cleanup.
if (jobId === this._latestJobId) this._jobCleanup();
return returnVal;
};
};
}
private _jobCleanup(): void {
this._busy = false;
if (!this._worker) return;
// If the worker is unused for 10 seconds, remove it to save memory.
this._workerTimeoutId = self.setTimeout(
this.terminateWorker,
workerTimeout,
);
}
/** Abort the current job, if any */
abortCurrent() {
if (!this._busy) return;
if (!this._abortRejector)
throw Error("There must be a rejector if it's busy");
this._abortRejector(new DOMException('Aborted', 'AbortError'));
this._abortRejector = undefined;
this._busy = false;
this.terminateWorker();
}
@bind
terminateWorker() {
if (!this._worker) return;
this._worker.terminate();
this._worker = undefined;
}
// Off main thread jobs:
@Processor._processingJob({ needsWorker: true })
imageQuant(data: ImageData, opts: QuantizeOptions): Promise<ImageData> {
return this._workerApi!.quantize(data, opts);
}
@Processor._processingJob({ needsWorker: true })
rotate(
data: ImageData,
opts: import('./rotate/processor-meta').RotateOptions,
): Promise<ImageData> {
return this._workerApi!.rotate(data, opts);
}
@Processor._processingJob({ needsWorker: true })
workerResize(
data: ImageData,
opts: import('./resize/processor-meta').WorkerResizeOptions,
): Promise<ImageData> {
return this._workerApi!.resize(data, opts);
}
@Processor._processingJob({ needsWorker: true })
mozjpegEncode(
data: ImageData,
opts: MozJPEGEncoderOptions,
): Promise<ArrayBuffer> {
return this._workerApi!.mozjpegEncode(data, opts);
}
@Processor._processingJob({ needsWorker: true })
async oxiPngEncode(
data: ImageData,
opts: OxiPNGEncoderOptions,
): Promise<ArrayBuffer> {
// OxiPNG expects PNG input.
const pngBlob = await canvasEncode(data, 'image/png');
const pngBuffer = await blobToArrayBuffer(pngBlob);
return this._workerApi!.oxiPngEncode(pngBuffer, opts);
}
@Processor._processingJob({ needsWorker: true })
webpEncode(data: ImageData, opts: WebPEncoderOptions): Promise<ArrayBuffer> {
return this._workerApi!.webpEncode(data, opts);
}
@Processor._processingJob({ needsWorker: true })
async webpDecode(blob: Blob): Promise<ImageData> {
const data = await blobToArrayBuffer(blob);
return this._workerApi!.webpDecode(data);
}
@Processor._processingJob({ needsWorker: true })
async avifDecode(blob: Blob): Promise<ImageData> {
const data = await blobToArrayBuffer(blob);
return this._workerApi!.avifDecode(data);
}
@Processor._processingJob({ needsWorker: true })
avifEncode(data: ImageData, opts: AvifEncoderOptions): Promise<ArrayBuffer> {
return this._workerApi!.avifEncode(data, opts);
}
// Not-worker jobs:
@Processor._processingJob()
browserBmpEncode(data: ImageData): Promise<Blob> {
return browserBMP.encode(data);
}
@Processor._processingJob()
browserPngEncode(data: ImageData): Promise<Blob> {
return browserPNG.encode(data);
}
@Processor._processingJob()
browserJpegEncode(data: ImageData, opts: BrowserJPEGOptions): Promise<Blob> {
return browserJPEG.encode(data, opts);
}
@Processor._processingJob()
browserWebpEncode(
data: ImageData,
opts: BrowserWebpEncodeOptions,
): Promise<Blob> {
return browserWebP.encode(data, opts);
}
@Processor._processingJob()
browserGifEncode(data: ImageData): Promise<Blob> {
return browserGIF.encode(data);
}
@Processor._processingJob()
browserTiffEncode(data: ImageData): Promise<Blob> {
return browserTIFF.encode(data);
}
@Processor._processingJob()
browserJp2Encode(data: ImageData): Promise<Blob> {
return browserJP2.encode(data);
}
@Processor._processingJob()
browserPdfEncode(data: ImageData): Promise<Blob> {
return browserPDF.encode(data);
}
// Synchronous jobs
resize(data: ImageData, opts: BrowserResizeOptions) {
this.abortCurrent();
return browserResize(data, opts);
}
vectorResize(data: HTMLImageElement, opts: VectorResizeOptions) {
this.abortCurrent();
return vectorResize(data, opts);
}
}

View File

@@ -1,254 +0,0 @@
import { h, Component } from 'preact';
import linkState from 'linkstate';
import { bind, linkRef } from '../../lib/initial-util';
import {
inputFieldValueAsNumber,
inputFieldValue,
preventDefault,
inputFieldChecked,
} from '../../lib/util';
import { ResizeOptions, isWorkerOptions } from './processor-meta';
import * as style from '../../components/Options/style.scss';
import Checkbox from '../../components/checkbox';
import Expander from '../../components/expander';
import Select from '../../components/select';
interface Props {
isVector: Boolean;
inputWidth: number;
inputHeight: number;
options: ResizeOptions;
onChange(newOptions: ResizeOptions): void;
}
interface State {
maintainAspect: boolean;
}
const sizePresets = [0.25, 0.3333, 0.5, 1, 2, 3, 4];
export default class ResizerOptions extends Component<Props, State> {
state: State = {
maintainAspect: true,
};
private form?: HTMLFormElement;
private presetWidths: { [idx: number]: number } = {};
private presetHeights: { [idx: number]: number } = {};
constructor(props: Props) {
super(props);
this.generatePresetValues(props.inputWidth, props.inputHeight);
}
private reportOptions() {
const form = this.form!;
const width = form.width as HTMLInputElement;
const height = form.height as HTMLInputElement;
const { options } = this.props;
if (!width.checkValidity() || !height.checkValidity()) return;
const newOptions: ResizeOptions = {
width: inputFieldValueAsNumber(width),
height: inputFieldValueAsNumber(height),
method: form.resizeMethod.value,
premultiply: inputFieldChecked(form.premultiply, true),
linearRGB: inputFieldChecked(form.linearRGB, true),
// Casting, as the formfield only returns the correct values.
fitMethod: inputFieldValue(
form.fitMethod,
options.fitMethod,
) as ResizeOptions['fitMethod'],
};
this.props.onChange(newOptions);
}
@bind
private onChange() {
this.reportOptions();
}
private getAspect() {
return this.props.inputWidth / this.props.inputHeight;
}
componentDidUpdate(prevProps: Props, prevState: State) {
if (!prevState.maintainAspect && this.state.maintainAspect) {
this.form!.height.value = Math.round(
Number(this.form!.width.value) / this.getAspect(),
);
this.reportOptions();
}
}
componentWillReceiveProps(nextProps: Props) {
if (
this.props.inputWidth !== nextProps.inputWidth ||
this.props.inputHeight !== nextProps.inputHeight
) {
this.generatePresetValues(nextProps.inputWidth, nextProps.inputHeight);
}
}
@bind
private onWidthInput() {
if (this.state.maintainAspect) {
const width = inputFieldValueAsNumber(this.form!.width);
this.form!.height.value = Math.round(width / this.getAspect());
}
this.reportOptions();
}
@bind
private onHeightInput() {
if (this.state.maintainAspect) {
const height = inputFieldValueAsNumber(this.form!.height);
this.form!.width.value = Math.round(height * this.getAspect());
}
this.reportOptions();
}
private generatePresetValues(width: number, height: number) {
for (const preset of sizePresets) {
this.presetWidths[preset] = Math.round(width * preset);
this.presetHeights[preset] = Math.round(height * preset);
}
}
private getPreset(): number | string {
const { width, height } = this.props.options;
for (const preset of sizePresets) {
if (
width === this.presetWidths[preset] &&
height === this.presetHeights[preset]
)
return preset;
}
return 'custom';
}
@bind
private onPresetChange(event: Event) {
const select = event.target as HTMLSelectElement;
if (select.value === 'custom') return;
const multiplier = Number(select.value);
(this.form!.width as HTMLInputElement).value =
Math.round(this.props.inputWidth * multiplier) + '';
(this.form!.height as HTMLInputElement).value =
Math.round(this.props.inputHeight * multiplier) + '';
this.reportOptions();
}
render({ options, isVector }: Props, { maintainAspect }: State) {
return (
<form
ref={linkRef(this, 'form')}
class={style.optionsSection}
onSubmit={preventDefault}
>
<label class={style.optionTextFirst}>
Method:
<Select
name="resizeMethod"
value={options.method}
onChange={this.onChange}
>
{isVector && <option value="vector">Vector</option>}
<option value="lanczos3">Lanczos3</option>
<option value="mitchell">Mitchell</option>
<option value="catrom">Catmull-Rom</option>
<option value="triangle">Triangle (bilinear)</option>
<option value="hqx">hqx (pixel art)</option>
<option value="browser-pixelated">Browser pixelated</option>
<option value="browser-low">Browser low quality</option>
<option value="browser-medium">Browser medium quality</option>
<option value="browser-high">Browser high quality</option>
</Select>
</label>
<label class={style.optionTextFirst}>
Preset:
<Select value={this.getPreset()} onChange={this.onPresetChange}>
{sizePresets.map((preset) => (
<option value={preset}>{preset * 100}%</option>
))}
<option value="custom">Custom</option>
</Select>
</label>
<label class={style.optionTextFirst}>
Width:
<input
required
class={style.textField}
name="width"
type="number"
min="1"
value={'' + options.width}
onInput={this.onWidthInput}
/>
</label>
<label class={style.optionTextFirst}>
Height:
<input
required
class={style.textField}
name="height"
type="number"
min="1"
value={'' + options.height}
onInput={this.onHeightInput}
/>
</label>
<Expander>
{isWorkerOptions(options) ? (
<label class={style.optionInputFirst}>
<Checkbox
name="premultiply"
checked={options.premultiply}
onChange={this.onChange}
/>
Premultiply alpha channel
</label>
) : null}
{isWorkerOptions(options) ? (
<label class={style.optionInputFirst}>
<Checkbox
name="linearRGB"
checked={options.linearRGB}
onChange={this.onChange}
/>
Linear RGB
</label>
) : null}
</Expander>
<label class={style.optionInputFirst}>
<Checkbox
name="maintainAspect"
checked={maintainAspect}
onChange={linkState(this, 'maintainAspect')}
/>
Maintain aspect ratio
</label>
<Expander>
{maintainAspect ? null : (
<label class={style.optionTextFirst}>
Fit method:
<Select
name="fitMethod"
value={options.fitMethod}
onChange={this.onChange}
>
<option value="stretch">Stretch</option>
<option value="contain">Contain</option>
</Select>
</label>
)}
</Expander>
</form>
);
}
}

View File

@@ -1,75 +0,0 @@
type BrowserResizeMethods =
| 'browser-pixelated'
| 'browser-low'
| 'browser-medium'
| 'browser-high';
type WorkerResizeMethods =
| 'triangle'
| 'catrom'
| 'mitchell'
| 'lanczos3'
| 'hqx';
const workerResizeMethods: WorkerResizeMethods[] = [
'triangle',
'catrom',
'mitchell',
'lanczos3',
'hqx',
];
export type ResizeOptions =
| BrowserResizeOptions
| WorkerResizeOptions
| VectorResizeOptions;
export interface ResizeOptionsCommon {
width: number;
height: number;
fitMethod: 'stretch' | 'contain';
}
export interface BrowserResizeOptions extends ResizeOptionsCommon {
method: BrowserResizeMethods;
}
export interface WorkerResizeOptions extends ResizeOptionsCommon {
method: WorkerResizeMethods;
premultiply: boolean;
linearRGB: boolean;
}
export interface VectorResizeOptions extends ResizeOptionsCommon {
method: 'vector';
}
/**
* Return whether a set of options are worker resize options.
*
* @param opts
*/
export function isWorkerOptions(
opts: ResizeOptions,
): opts is WorkerResizeOptions {
return (workerResizeMethods as string[]).includes(opts.method);
}
/**
* Return whether a set of options are from the HQ<n>X set
*
* @param opts
*/
export function isHqx(opts: ResizeOptions): opts is WorkerResizeOptions {
return opts.method === 'hqx';
}
export const defaultOptions: ResizeOptions = {
// Width and height will always default to the image size.
// This is set elsewhere.
width: 1,
height: 1,
// This will be set to 'vector' if the input is SVG.
method: 'lanczos3',
fitMethod: 'stretch',
premultiply: true,
linearRGB: true,
};

View File

@@ -1,55 +0,0 @@
import {
builtinResize,
BuiltinResizeMethod,
drawableToImageData,
} from '../../lib/util';
import { BrowserResizeOptions, VectorResizeOptions } from './processor-meta';
import { getContainOffsets } from './util';
export function browserResize(
data: ImageData,
opts: BrowserResizeOptions,
): ImageData {
let sx = 0;
let sy = 0;
let sw = data.width;
let sh = data.height;
if (opts.fitMethod === 'contain') {
({ sx, sy, sw, sh } = getContainOffsets(sw, sh, opts.width, opts.height));
}
return builtinResize(
data,
sx,
sy,
sw,
sh,
opts.width,
opts.height,
opts.method.slice('browser-'.length) as BuiltinResizeMethod,
);
}
export function vectorResize(
data: HTMLImageElement,
opts: VectorResizeOptions,
): ImageData {
let sx = 0;
let sy = 0;
let sw = data.width;
let sh = data.height;
if (opts.fitMethod === 'contain') {
({ sx, sy, sw, sh } = getContainOffsets(sw, sh, opts.width, opts.height));
}
return drawableToImageData(data, {
sx,
sy,
sw,
sh,
width: opts.width,
height: opts.height,
});
}

View File

@@ -1,73 +0,0 @@
import { WorkerResizeOptions } from './processor-meta';
import { getContainOffsets } from './util';
import { resize as codecResize } from '../../../codecs/resize/pkg';
function crop(
data: ImageData,
sx: number,
sy: number,
sw: number,
sh: number,
): ImageData {
const inputPixels = new Uint32Array(data.data.buffer);
// Copy within the same buffer for speed and memory efficiency.
for (let y = 0; y < sh; y += 1) {
const start = (y + sy) * data.width + sx;
inputPixels.copyWithin(y * sw, start, start + sw);
}
return new ImageData(
new Uint8ClampedArray(inputPixels.buffer.slice(0, sw * sh * 4)),
sw,
sh,
);
}
/** Resize methods by index */
const resizeMethods: WorkerResizeOptions['method'][] = [
'triangle',
'catrom',
'mitchell',
'lanczos3',
];
export async function resize(
data: ImageData,
opts: WorkerResizeOptions,
): Promise<ImageData> {
let input = data;
if (opts.fitMethod === 'contain') {
const { sx, sy, sw, sh } = getContainOffsets(
data.width,
data.height,
opts.width,
opts.height,
);
input = crop(
input,
Math.round(sx),
Math.round(sy),
Math.round(sw),
Math.round(sh),
);
}
const result = codecResize(
new Uint8Array(input.data.buffer),
input.width,
input.height,
opts.width,
opts.height,
resizeMethods.indexOf(opts.method),
opts.premultiply,
opts.linearRGB,
);
return new ImageData(
new Uint8ClampedArray(result.buffer),
opts.width,
opts.height,
);
}

View File

@@ -1,19 +0,0 @@
export function getContainOffsets(
sw: number,
sh: number,
dw: number,
dh: number,
) {
const currentAspect = sw / sh;
const endAspect = dw / dh;
if (endAspect > currentAspect) {
const newSh = sw / endAspect;
const newSy = (sh - newSh) / 2;
return { sw, sh: newSh, sx: 0, sy: newSy };
}
const newSw = sh * endAspect;
const newSx = (sw - newSw) / 2;
return { sh, sw: newSw, sx: newSx, sy: 0 };
}

View File

@@ -1,12 +0,0 @@
export interface RotateOptions {
rotate: 0 | 90 | 180 | 270;
}
export const defaultOptions: RotateOptions = { rotate: 0 };
export interface RotateModuleInstance {
exports: {
memory: WebAssembly.Memory;
rotate(width: number, height: number, rotate: 0 | 90 | 180 | 270): void;
};
}

View File

@@ -1,43 +0,0 @@
import wasmUrl from '../../../codecs/rotate/rotate.wasm';
import { RotateOptions, RotateModuleInstance } from './processor-meta';
// We are loading a 500B module here. Loading the code to feature-detect
// `instantiateStreaming` probably takes longer to load than the time we save by
// using `instantiateStreaming` in the first place. So lets just use
// `ArrayBuffer`s here.
const instancePromise = fetch(wasmUrl)
.then((r) => r.arrayBuffer())
.then((buf) => WebAssembly.instantiate(buf));
export async function rotate(
data: ImageData,
opts: RotateOptions,
): Promise<ImageData> {
const { instance } = (await instancePromise) as {
instance: RotateModuleInstance;
};
// Number of wasm memory pages (á 64KiB) needed to store the image twice.
const bytesPerImage = data.width * data.height * 4;
const numPagesNeeded = Math.ceil((bytesPerImage * 2 + 8) / (64 * 1024));
// Only count full pages, just to be safe.
const numPagesAvailable = Math.floor(
instance.exports.memory.buffer.byteLength / (64 * 1024),
);
const additionalPagesToAllocate = numPagesNeeded - numPagesAvailable;
if (additionalPagesToAllocate > 0) {
instance.exports.memory.grow(additionalPagesToAllocate);
}
const view = new Uint8ClampedArray(instance.exports.memory.buffer);
view.set(data.data, 8);
instance.exports.rotate(data.width, data.height, opts.rotate);
const flipDimensions = opts.rotate % 180 !== 0;
return new ImageData(
view.slice(bytesPerImage + 8, bytesPerImage * 2 + 8),
flipDimensions ? data.height : data.width,
flipDimensions ? data.width : data.height,
);
}

View File

@@ -1,39 +0,0 @@
type ModuleFactory<M extends EmscriptenWasm.Module> = (
opts: EmscriptenWasm.ModuleOpts,
) => M;
export function initEmscriptenModule<T extends EmscriptenWasm.Module>(
moduleFactory: ModuleFactory<T>,
wasmUrl: string,
): Promise<T> {
return new Promise((resolve) => {
const module = moduleFactory({
// Just to be safe, don't automatically invoke any wasm functions
noInitialRun: true,
locateFile(url: string): string {
// Redirect the request for the wasm binary to whatever webpack gave us.
if (url.endsWith('.wasm')) return wasmUrl;
return url;
},
onRuntimeInitialized() {
// An Emscripten is a then-able that resolves with itself, causing an infite loop when you
// wrap it in a real promise. Delete the `then` prop solves this for now.
// https://github.com/kripken/emscripten/issues/5820
delete (module as any).then;
resolve(module);
},
});
});
}
interface ClampOpts {
min?: number;
max?: number;
}
export function clamp(x: number, opts: ClampOpts): number {
return Math.min(
Math.max(x, opts.min || Number.MIN_VALUE),
opts.max || Number.MAX_VALUE,
);
}

View File

@@ -1,7 +0,0 @@
export const name = 'WASM WebP Decoder';
const supportedMimeTypes = ['image/webp'];
export function canHandleMimeType(mimeType: string): boolean {
return supportedMimeTypes.includes(mimeType);
}

View File

@@ -1,17 +0,0 @@
import webp_dec, { WebPModule } from '../../../codecs/webp/dec/webp_dec';
import wasmUrl from '../../../codecs/webp/dec/webp_dec.wasm';
import { initEmscriptenModule } from '../util';
let emscriptenModule: Promise<WebPModule>;
export async function decode(data: ArrayBuffer): Promise<ImageData> {
if (!emscriptenModule)
emscriptenModule = initEmscriptenModule(webp_dec, wasmUrl);
const module = await emscriptenModule;
const result = module.decode(data);
if (!result) {
throw new Error('Decoding error');
}
return result;
}

View File

@@ -1,75 +0,0 @@
export enum WebPImageHint {
WEBP_HINT_DEFAULT, // default preset.
WEBP_HINT_PICTURE, // digital picture, like portrait, inner shot
WEBP_HINT_PHOTO, // outdoor photograph, with natural lighting
WEBP_HINT_GRAPH, // Discrete tone image (graph, map-tile etc).
}
export interface EncodeOptions {
quality: number;
target_size: number;
target_PSNR: number;
method: number;
sns_strength: number;
filter_strength: number;
filter_sharpness: number;
filter_type: number;
partitions: number;
segments: number;
pass: number;
show_compressed: number;
preprocessing: number;
autofilter: number;
partition_limit: number;
alpha_compression: number;
alpha_filtering: number;
alpha_quality: number;
lossless: number;
exact: number;
image_hint: number;
emulate_jpeg_size: number;
thread_level: number;
low_memory: number;
near_lossless: number;
use_delta_palette: number;
use_sharp_yuv: number;
}
export interface EncoderState {
type: typeof type;
options: EncodeOptions;
}
export const type = 'webp';
export const label = 'WebP';
export const mimeType = 'image/webp';
export const extension = 'webp';
// These come from struct WebPConfig in encode.h.
export const defaultOptions: EncodeOptions = {
quality: 75,
target_size: 0,
target_PSNR: 0,
method: 4,
sns_strength: 50,
filter_strength: 60,
filter_sharpness: 0,
filter_type: 1,
partitions: 0,
segments: 4,
pass: 1,
show_compressed: 0,
preprocessing: 0,
autofilter: 0,
partition_limit: 0,
alpha_compression: 1,
alpha_filtering: 1,
alpha_quality: 100,
lossless: 0,
exact: 0,
image_hint: 0,
emulate_jpeg_size: 0,
thread_level: 0,
low_memory: 0,
near_lossless: 100,
use_delta_palette: 0,
use_sharp_yuv: 0,
};

View File

@@ -1,22 +0,0 @@
import webp_enc, { WebPModule } from '../../../codecs/webp/enc/webp_enc';
import wasmUrl from '../../../codecs/webp/enc/webp_enc.wasm';
import { EncodeOptions } from './encoder-meta';
import { initEmscriptenModule } from '../util';
let emscriptenModule: Promise<WebPModule>;
export async function encode(
data: ImageData,
options: EncodeOptions,
): Promise<ArrayBuffer> {
if (!emscriptenModule)
emscriptenModule = initEmscriptenModule(webp_enc, wasmUrl);
const module = await emscriptenModule;
const result = module.encode(data.data, data.width, data.height, options);
if (!result) {
throw new Error('Encoding error.');
}
// wasm cant run on SharedArrayBuffers, so we hard-cast to ArrayBuffer.
return result.buffer as ArrayBuffer;
}

View File

@@ -1,394 +0,0 @@
import { h, Component } from 'preact';
import { bind } from '../../lib/initial-util';
import {
inputFieldCheckedAsNumber,
inputFieldValueAsNumber,
preventDefault,
} from '../../lib/util';
import { EncodeOptions, WebPImageHint } from './encoder-meta';
import * as style from '../../components/Options/style.scss';
import Checkbox from '../../components/checkbox';
import Expander from '../../components/expander';
import Select from '../../components/select';
import Range from '../../components/range';
import linkState from 'linkstate';
interface Props {
options: EncodeOptions;
onChange(newOptions: EncodeOptions): void;
}
interface State {
showAdvanced: boolean;
}
// From kLosslessPresets in config_enc.c
// The format is [method, quality].
const losslessPresets: [number, number][] = [
[0, 0],
[1, 20],
[2, 25],
[3, 30],
[3, 50],
[4, 50],
[4, 75],
[4, 90],
[5, 90],
[6, 100],
];
const losslessPresetDefault = 6;
function determineLosslessQuality(quality: number, method: number): number {
const index = losslessPresets.findIndex(
([presetMethod, presetQuality]) =>
presetMethod === method && presetQuality === quality,
);
if (index !== -1) return index;
// Quality doesn't match one of the presets.
// This can happen when toggling 'lossless'.
return losslessPresetDefault;
}
export default class WebPEncoderOptions extends Component<Props, State> {
state: State = {
showAdvanced: false,
};
@bind
onChange(event: Event) {
const form = (event.currentTarget as HTMLInputElement).closest(
'form',
) as HTMLFormElement;
const lossless = inputFieldCheckedAsNumber(form.lossless);
const { options } = this.props;
const losslessPresetValue = inputFieldValueAsNumber(
form.lossless_preset,
determineLosslessQuality(options.quality, options.method),
);
const newOptions: EncodeOptions = {
// Copy over options the form doesn't care about, eg emulate_jpeg_size
...options,
// And now stuff from the form:
lossless,
// Special-cased inputs:
// In lossless mode, the quality is derived from the preset.
quality: lossless
? losslessPresets[losslessPresetValue][1]
: inputFieldValueAsNumber(form.quality, options.quality),
// In lossless mode, the method is derived from the preset.
method: lossless
? losslessPresets[losslessPresetValue][0]
: inputFieldValueAsNumber(form.method_input, options.method),
image_hint: inputFieldCheckedAsNumber(form.image_hint, options.image_hint)
? WebPImageHint.WEBP_HINT_GRAPH
: WebPImageHint.WEBP_HINT_DEFAULT,
// .checked
exact: inputFieldCheckedAsNumber(form.exact, options.exact),
alpha_compression: inputFieldCheckedAsNumber(
form.alpha_compression,
options.alpha_compression,
),
autofilter: inputFieldCheckedAsNumber(
form.autofilter,
options.autofilter,
),
filter_type: inputFieldCheckedAsNumber(
form.filter_type,
options.filter_type,
),
use_sharp_yuv: inputFieldCheckedAsNumber(
form.use_sharp_yuv,
options.use_sharp_yuv,
),
// .value
near_lossless:
100 -
inputFieldValueAsNumber(
form.near_lossless,
100 - options.near_lossless,
),
alpha_quality: inputFieldValueAsNumber(
form.alpha_quality,
options.alpha_quality,
),
alpha_filtering: inputFieldValueAsNumber(
form.alpha_filtering,
options.alpha_filtering,
),
sns_strength: inputFieldValueAsNumber(
form.sns_strength,
options.sns_strength,
),
filter_strength: inputFieldValueAsNumber(
form.filter_strength,
options.filter_strength,
),
filter_sharpness:
7 -
inputFieldValueAsNumber(
form.filter_sharpness,
7 - options.filter_sharpness,
),
pass: inputFieldValueAsNumber(form.pass, options.pass),
preprocessing: inputFieldValueAsNumber(
form.preprocessing,
options.preprocessing,
),
segments: inputFieldValueAsNumber(form.segments, options.segments),
partitions: inputFieldValueAsNumber(form.partitions, options.partitions),
};
this.props.onChange(newOptions);
}
private _losslessSpecificOptions(options: EncodeOptions) {
return (
<div key="lossless">
<div class={style.optionOneCell}>
<Range
name="lossless_preset"
min="0"
max="9"
value={determineLosslessQuality(options.quality, options.method)}
onInput={this.onChange}
>
Effort:
</Range>
</div>
<div class={style.optionOneCell}>
<Range
name="near_lossless"
min="0"
max="100"
value={'' + (100 - options.near_lossless)}
onInput={this.onChange}
>
Slight loss:
</Range>
</div>
<label class={style.optionInputFirst}>
{/*
Although there are 3 different kinds of image hint, webp only
seems to do something with the 'graph' type, and I don't really
understand what it does.
*/}
<Checkbox
name="image_hint"
checked={options.image_hint === WebPImageHint.WEBP_HINT_GRAPH}
onChange={this.onChange}
/>
Discrete tone image
</label>
</div>
);
}
private _lossySpecificOptions(options: EncodeOptions) {
const { showAdvanced } = this.state;
return (
<div key="lossy">
<div class={style.optionOneCell}>
<Range
name="method_input"
min="0"
max="6"
value={options.method}
onInput={this.onChange}
>
Effort:
</Range>
</div>
<div class={style.optionOneCell}>
<Range
name="quality"
min="0"
max="100"
step="0.1"
value={options.quality}
onInput={this.onChange}
>
Quality:
</Range>
</div>
<label class={style.optionInputFirst}>
<Checkbox
checked={showAdvanced}
onChange={linkState(this, 'showAdvanced')}
/>
Show advanced settings
</label>
<Expander>
{showAdvanced ? (
<div>
<label class={style.optionInputFirst}>
<Checkbox
name="alpha_compression"
checked={!!options.alpha_compression}
onChange={this.onChange}
/>
Compress alpha
</label>
<div class={style.optionOneCell}>
<Range
name="alpha_quality"
min="0"
max="100"
value={options.alpha_quality}
onInput={this.onChange}
>
Alpha quality:
</Range>
</div>
<div class={style.optionOneCell}>
<Range
name="alpha_filtering"
min="0"
max="2"
value={options.alpha_filtering}
onInput={this.onChange}
>
Alpha filter quality:
</Range>
</div>
<label class={style.optionInputFirst}>
<Checkbox
name="autofilter"
checked={!!options.autofilter}
onChange={this.onChange}
/>
Auto adjust filter strength
</label>
<Expander>
{options.autofilter ? null : (
<div class={style.optionOneCell}>
<Range
name="filter_strength"
min="0"
max="100"
value={options.filter_strength}
onInput={this.onChange}
>
Filter strength:
</Range>
</div>
)}
</Expander>
<label class={style.optionInputFirst}>
<Checkbox
name="filter_type"
checked={!!options.filter_type}
onChange={this.onChange}
/>
Strong filter
</label>
<div class={style.optionOneCell}>
<Range
name="filter_sharpness"
min="0"
max="7"
value={7 - options.filter_sharpness}
onInput={this.onChange}
>
Filter sharpness:
</Range>
</div>
<label class={style.optionInputFirst}>
<Checkbox
name="use_sharp_yuv"
checked={!!options.use_sharp_yuv}
onChange={this.onChange}
/>
Sharp RGBYUV conversion
</label>
<div class={style.optionOneCell}>
<Range
name="pass"
min="1"
max="10"
value={options.pass}
onInput={this.onChange}
>
Passes:
</Range>
</div>
<div class={style.optionOneCell}>
<Range
name="sns_strength"
min="0"
max="100"
value={options.sns_strength}
onInput={this.onChange}
>
Spatial noise shaping:
</Range>
</div>
<label class={style.optionTextFirst}>
Preprocess:
<Select
name="preprocessing"
value={options.preprocessing}
onChange={this.onChange}
>
<option value="0">None</option>
<option value="1">Segment smooth</option>
<option value="2">Pseudo-random dithering</option>
</Select>
</label>
<div class={style.optionOneCell}>
<Range
name="segments"
min="1"
max="4"
value={options.segments}
onInput={this.onChange}
>
Segments:
</Range>
</div>
<div class={style.optionOneCell}>
<Range
name="partitions"
min="0"
max="3"
value={options.partitions}
onInput={this.onChange}
>
Partitions:
</Range>
</div>
</div>
) : null}
</Expander>
</div>
);
}
render({ options }: Props) {
// I'm rendering both lossy and lossless forms, as it becomes much easier when
// gathering the data.
return (
<form class={style.optionsSection} onSubmit={preventDefault}>
<label class={style.optionInputFirst}>
<Checkbox
name="lossless"
checked={!!options.lossless}
onChange={this.onChange}
/>
Lossless
</label>
{options.lossless
? this._losslessSpecificOptions(options)
: this._lossySpecificOptions(options)}
<label class={style.optionInputFirst}>
<Checkbox
name="exact"
checked={!!options.exact}
onChange={this.onChange}
/>
Preserve transparent data
</label>
</form>
);
}
}

View File

@@ -1,156 +0,0 @@
import { h, Component } from 'preact';
import { bind, linkRef, Fileish } from '../../lib/initial-util';
import * as style from './style.scss';
import { FileDropEvent } from 'file-drop-element';
import 'file-drop-element';
import SnackBarElement, { SnackOptions } from '../../lib/SnackBar';
import '../../lib/SnackBar';
import Intro from '../intro';
import '../custom-els/LoadingSpinner';
const ROUTE_EDITOR = '/editor';
const compressPromise = import(
/* webpackChunkName: "main-app" */
'../compress'
);
const swBridgePromise = import(
/* webpackChunkName: "sw-bridge" */
'../../lib/sw-bridge'
);
function back() {
window.history.back();
}
interface Props {}
interface State {
awaitingShareTarget: boolean;
file?: File | Fileish;
isEditorOpen: Boolean;
Compress?: typeof import('../compress').default;
}
export default class App extends Component<Props, State> {
state: State = {
awaitingShareTarget: new URL(location.href).searchParams.has(
'share-target',
),
isEditorOpen: false,
file: undefined,
Compress: undefined,
};
snackbar?: SnackBarElement;
constructor() {
super();
compressPromise
.then((module) => {
this.setState({ Compress: module.default });
})
.catch(() => {
this.showSnack('Failed to load app');
});
swBridgePromise.then(async ({ offliner, getSharedImage }) => {
offliner(this.showSnack);
if (!this.state.awaitingShareTarget) return;
const file = await getSharedImage();
// Remove the ?share-target from the URL
history.replaceState('', '', '/');
this.openEditor();
this.setState({ file, awaitingShareTarget: false });
});
// In development, persist application state across hot reloads:
if (process.env.NODE_ENV === 'development') {
this.setState(window.STATE);
const oldCDU = this.componentDidUpdate;
this.componentDidUpdate = (props, state, prev) => {
if (oldCDU) oldCDU.call(this, props, state, prev);
window.STATE = this.state;
};
}
// Since iOS 10, Apple tries to prevent disabling pinch-zoom. This is great in theory, but
// really breaks things on Squoosh, as you can easily end up zooming the UI when you mean to
// zoom the image. Once you've done this, it's really difficult to undo. Anyway, this seems to
// prevent it.
document.body.addEventListener('gesturestart', (event) => {
event.preventDefault();
});
window.addEventListener('popstate', this.onPopState);
}
@bind
private onFileDrop({ files }: FileDropEvent) {
if (!files || files.length === 0) return;
const file = files[0];
this.openEditor();
this.setState({ file });
}
@bind
private onIntroPickFile(file: File | Fileish) {
this.openEditor();
this.setState({ file });
}
@bind
private showSnack(
message: string,
options: SnackOptions = {},
): Promise<string> {
if (!this.snackbar) throw Error('Snackbar missing');
return this.snackbar.showSnackbar(message, options);
}
@bind
private onPopState() {
this.setState({ isEditorOpen: location.pathname === ROUTE_EDITOR });
}
@bind
private openEditor() {
if (this.state.isEditorOpen) return;
// Change path, but preserve query string.
const editorURL = new URL(location.href);
editorURL.pathname = ROUTE_EDITOR;
history.pushState(null, '', editorURL.href);
this.setState({ isEditorOpen: true });
}
render(
{}: Props,
{ file, isEditorOpen, Compress, awaitingShareTarget }: State,
) {
const showSpinner = awaitingShareTarget || (isEditorOpen && !Compress);
return (
<div id="app" class={style.app}>
<file-drop
accept="image/*"
onfiledrop={this.onFileDrop}
class={style.drop}
>
{showSpinner ? (
<loading-spinner class={style.appLoader} />
) : isEditorOpen ? (
Compress && (
<Compress file={file!} showSnack={this.showSnack} onBack={back} />
)
) : (
<Intro onFile={this.onIntroPickFile} showSnack={this.showSnack} />
)}
<snack-bar ref={linkRef(this, 'snackbar')} />
</file-drop>
</div>
);
}
}

View File

@@ -1,68 +0,0 @@
.app {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: hidden;
contain: strict;
}
.drop {
overflow: hidden;
touch-action: none;
height: 100%;
width: 100%;
&:global {
&::after {
content: '';
position: absolute;
display: block;
left: 10px;
top: 10px;
right: 10px;
bottom: 10px;
border: 2px dashed #fff;
background-color:rgba(88, 116, 88, 0.2);
border-color: rgba(65, 129, 65, 0.5);
border-radius: 10px;
opacity: 0;
transform: scale(0.95);
transition: all 200ms ease-in;
transition-property: transform, opacity;
pointer-events: none;
}
&.drop-valid::after {
opacity: 1;
transform: scale(1);
transition-timing-function: ease-out;
}
}
}
.option-pair {
display: flex;
justify-content: flex-end;
width: 100%;
height: 100%;
&.horizontal {
justify-content: space-between;
align-items: flex-end;
}
&.vertical {
flex-direction: column;
}
}
.app-loader {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
--size: 225px;
--stroke-width: 26px;
}

View File

@@ -1,220 +0,0 @@
import { h, Component } from 'preact';
import * as style from './style.scss';
import { bind } from '../../lib/initial-util';
import { cleanSet, cleanMerge } from '../../lib/clean-modify';
import OxiPNGEncoderOptions from '../../codecs/oxipng/options';
import MozJpegEncoderOptions from '../../codecs/mozjpeg/options';
import BrowserJPEGEncoderOptions from '../../codecs/browser-jpeg/options';
import WebPEncoderOptions from '../../codecs/webp/options';
import AvifEncoderOptions from '../../codecs/avif/options';
import BrowserWebPEncoderOptions from '../../codecs/browser-webp/options';
import QuantizerOptionsComponent from '../../codecs/imagequant/options';
import ResizeOptionsComponent from '../../codecs/resize/options';
import * as identity from '../../codecs/identity/encoder-meta';
import * as oxiPNG from '../../codecs/oxipng/encoder-meta';
import * as mozJPEG from '../../codecs/mozjpeg/encoder-meta';
import * as webP from '../../codecs/webp/encoder-meta';
import * as avif from '../../codecs/avif/encoder-meta';
import * as browserPNG from '../../codecs/browser-png/encoder-meta';
import * as browserJPEG from '../../codecs/browser-jpeg/encoder-meta';
import * as browserWebP from '../../codecs/browser-webp/encoder-meta';
import * as browserGIF from '../../codecs/browser-gif/encoder-meta';
import * as browserTIFF from '../../codecs/browser-tiff/encoder-meta';
import * as browserJP2 from '../../codecs/browser-jp2/encoder-meta';
import * as browserBMP from '../../codecs/browser-bmp/encoder-meta';
import * as browserPDF from '../../codecs/browser-pdf/encoder-meta';
import {
EncoderState,
EncoderType,
EncoderOptions,
encoders,
encodersSupported,
EncoderSupportMap,
} from '../../codecs/encoders';
import { QuantizeOptions } from '../../codecs/imagequant/processor-meta';
import { ResizeOptions } from '../../codecs/resize/processor-meta';
import { PreprocessorState } from '../../codecs/preprocessors';
import { SourceImage } from '../compress';
import Checkbox from '../checkbox';
import Expander from '../expander';
import Select from '../select';
const encoderOptionsComponentMap: {
[x: string]: (new (...args: any[]) => Component<any, any>) | undefined;
} = {
[identity.type]: undefined,
[oxiPNG.type]: OxiPNGEncoderOptions,
[mozJPEG.type]: MozJpegEncoderOptions,
[webP.type]: WebPEncoderOptions,
[avif.type]: AvifEncoderOptions,
[browserPNG.type]: undefined,
[browserJPEG.type]: BrowserJPEGEncoderOptions,
[browserWebP.type]: BrowserWebPEncoderOptions,
[browserBMP.type]: undefined,
// Only Safari supports the rest, and it doesn't support quality settings.
[browserGIF.type]: undefined,
[browserTIFF.type]: undefined,
[browserJP2.type]: undefined,
[browserPDF.type]: undefined,
};
interface Props {
mobileView: boolean;
source?: SourceImage;
encoderState: EncoderState;
preprocessorState: PreprocessorState;
onEncoderTypeChange(newType: EncoderType): void;
onEncoderOptionsChange(newOptions: EncoderOptions): void;
onPreprocessorOptionsChange(newOptions: PreprocessorState): void;
}
interface State {
encoderSupportMap?: EncoderSupportMap;
}
export default class Options extends Component<Props, State> {
state: State = {
encoderSupportMap: undefined,
};
constructor() {
super();
encodersSupported.then((encoderSupportMap) =>
this.setState({ encoderSupportMap }),
);
}
@bind
private onEncoderTypeChange(event: Event) {
const el = event.currentTarget as HTMLSelectElement;
// The select element only has values matching encoder types,
// so 'as' is safe here.
const type = el.value as EncoderType;
this.props.onEncoderTypeChange(type);
}
@bind
private onPreprocessorEnabledChange(event: Event) {
const el = event.currentTarget as HTMLInputElement;
const preprocessor = el.name.split('.')[0] as keyof PreprocessorState;
this.props.onPreprocessorOptionsChange(
cleanSet(
this.props.preprocessorState,
`${preprocessor}.enabled`,
el.checked,
),
);
}
@bind
private onQuantizerOptionsChange(opts: QuantizeOptions) {
this.props.onPreprocessorOptionsChange(
cleanMerge(this.props.preprocessorState, 'quantizer', opts),
);
}
@bind
private onResizeOptionsChange(opts: ResizeOptions) {
this.props.onPreprocessorOptionsChange(
cleanMerge(this.props.preprocessorState, 'resize', opts),
);
}
render(
{ source, encoderState, preprocessorState, onEncoderOptionsChange }: Props,
{ encoderSupportMap }: State,
) {
// tslint:disable variable-name
const EncoderOptionComponent =
encoderOptionsComponentMap[encoderState.type];
return (
<div class={style.optionsScroller}>
<Expander>
{encoderState.type === identity.type ? null : (
<div>
<h3 class={style.optionsTitle}>Edit</h3>
<label class={style.sectionEnabler}>
<Checkbox
name="resize.enable"
checked={!!preprocessorState.resize.enabled}
onChange={this.onPreprocessorEnabledChange}
/>
Resize
</label>
<Expander>
{preprocessorState.resize.enabled ? (
<ResizeOptionsComponent
isVector={Boolean(source && source.vectorImage)}
inputWidth={source ? source.processed.width : 1}
inputHeight={source ? source.processed.height : 1}
options={preprocessorState.resize}
onChange={this.onResizeOptionsChange}
/>
) : null}
</Expander>
<label class={style.sectionEnabler}>
<Checkbox
name="quantizer.enable"
checked={!!preprocessorState.quantizer.enabled}
onChange={this.onPreprocessorEnabledChange}
/>
Reduce palette
</label>
<Expander>
{preprocessorState.quantizer.enabled ? (
<QuantizerOptionsComponent
options={preprocessorState.quantizer}
onChange={this.onQuantizerOptionsChange}
/>
) : null}
</Expander>
</div>
)}
</Expander>
<h3 class={style.optionsTitle}>Compress</h3>
<section class={`${style.optionOneCell} ${style.optionsSection}`}>
{encoderSupportMap ? (
<Select
value={encoderState.type}
onChange={this.onEncoderTypeChange}
large
>
{encoders
.filter((encoder) => encoderSupportMap[encoder.type])
.map((encoder) => (
// tslint:disable-next-line:jsx-key
<option value={encoder.type}>{encoder.label}</option>
))}
</Select>
) : (
<Select large>
<option>Loading</option>
</Select>
)}
</section>
<Expander>
{EncoderOptionComponent ? (
<EncoderOptionComponent
options={
// Casting options, as encoderOptionsComponentMap[encodeData.type] ensures
// the correct type, but typescript isn't smart enough.
encoderState.options as any
}
onChange={onEncoderOptionsChange}
/>
) : null}
</Expander>
</div>
);
}
}

View File

@@ -1,59 +0,0 @@
$horizontalPadding: 15px;
.options-title {
background: rgba(0, 0, 0, 0.9);
margin: 0;
padding: 10px $horizontalPadding;
font-weight: normal;
font-size: 1.4rem;
border-bottom: 1px solid #000;
}
.option-text-first {
display: grid;
align-items: center;
grid-template-columns: 87px 1fr;
grid-gap: 0.7em;
padding: 10px $horizontalPadding;
}
.option-one-cell {
display: grid;
grid-template-columns: 1fr;
padding: 10px $horizontalPadding;
}
.option-input-first,
.section-enabler {
cursor: pointer;
display: grid;
align-items: center;
grid-template-columns: auto 1fr;
grid-gap: 0.7em;
padding: 10px $horizontalPadding;
}
.section-enabler {
background: rgba(0, 0, 0, 0.8);
}
.options-section {
background: rgba(0, 0, 0, 0.7);
}
.text-field {
background: #fff;
color: #000;
font: inherit;
border: none;
padding: 2px 0 2px 10px;
width: 100%;
box-sizing: border-box;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.5);
}
.options-scroller {
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}

View File

@@ -1,376 +0,0 @@
import PointerTracker, { Pointer } from 'pointer-tracker';
import './styles.css';
interface Point {
clientX: number;
clientY: number;
}
interface ChangeOptions {
/**
* Fire a 'change' event if values are different to current values
*/
allowChangeEvent?: boolean;
}
interface ApplyChangeOpts extends ChangeOptions {
panX?: number;
panY?: number;
scaleDiff?: number;
originX?: number;
originY?: number;
}
interface SetTransformOpts extends ChangeOptions {
scale?: number;
x?: number;
y?: number;
}
type ScaleRelativeToValues = 'container' | 'content';
export interface ScaleToOpts extends ChangeOptions {
/** Transform origin. Can be a number, or string percent, eg "50%" */
originX?: number | string;
/** Transform origin. Can be a number, or string percent, eg "50%" */
originY?: number | string;
/** Should the transform origin be relative to the container, or content? */
relativeTo?: ScaleRelativeToValues;
}
function getDistance(a: Point, b?: Point): number {
if (!b) return 0;
return Math.sqrt((b.clientX - a.clientX) ** 2 + (b.clientY - a.clientY) ** 2);
}
function getMidpoint(a: Point, b?: Point): Point {
if (!b) return a;
return {
clientX: (a.clientX + b.clientX) / 2,
clientY: (a.clientY + b.clientY) / 2,
};
}
function getAbsoluteValue(value: string | number, max: number): number {
if (typeof value === 'number') return value;
if (value.trimRight().endsWith('%')) {
return (max * parseFloat(value)) / 100;
}
return parseFloat(value);
}
// I'd rather use DOMMatrix/DOMPoint here, but the browser support isn't good enough.
// Given that, better to use something everything supports.
let cachedSvg: SVGSVGElement;
function getSVG(): SVGSVGElement {
return (
cachedSvg ||
(cachedSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'))
);
}
function createMatrix(): SVGMatrix {
return getSVG().createSVGMatrix();
}
function createPoint(): SVGPoint {
return getSVG().createSVGPoint();
}
const MIN_SCALE = 0.01;
export default class PinchZoom extends HTMLElement {
// The element that we'll transform.
// Ideally this would be shadow DOM, but we don't have the browser
// support yet.
private _positioningEl?: Element;
// Current transform.
private _transform: SVGMatrix = createMatrix();
constructor() {
super();
// Watch for children changes.
// Note this won't fire for initial contents,
// so _stageElChange is also called in connectedCallback.
new MutationObserver(() => this._stageElChange()).observe(this, {
childList: true,
});
// Watch for pointers
const pointerTracker: PointerTracker = new PointerTracker(this, {
start: (pointer, event) => {
// We only want to track 2 pointers at most
if (pointerTracker.currentPointers.length === 2 || !this._positioningEl)
return false;
event.preventDefault();
return true;
},
move: (previousPointers) => {
this._onPointerMove(previousPointers, pointerTracker.currentPointers);
},
});
this.addEventListener('wheel', (event) => this._onWheel(event));
}
connectedCallback() {
this._stageElChange();
}
get x() {
return this._transform.e;
}
get y() {
return this._transform.f;
}
get scale() {
return this._transform.a;
}
/**
* Change the scale, adjusting x/y by a given transform origin.
*/
scaleTo(scale: number, opts: ScaleToOpts = {}) {
let { originX = 0, originY = 0 } = opts;
const { relativeTo = 'content', allowChangeEvent = false } = opts;
const relativeToEl = relativeTo === 'content' ? this._positioningEl : this;
// No content element? Fall back to just setting scale
if (!relativeToEl || !this._positioningEl) {
this.setTransform({ scale, allowChangeEvent });
return;
}
const rect = relativeToEl.getBoundingClientRect();
originX = getAbsoluteValue(originX, rect.width);
originY = getAbsoluteValue(originY, rect.height);
if (relativeTo === 'content') {
originX += this.x;
originY += this.y;
} else {
const currentRect = this._positioningEl.getBoundingClientRect();
originX -= currentRect.left;
originY -= currentRect.top;
}
this._applyChange({
allowChangeEvent,
originX,
originY,
scaleDiff: scale / this.scale,
});
}
/**
* Update the stage with a given scale/x/y.
*/
setTransform(opts: SetTransformOpts = {}) {
const { scale = this.scale, allowChangeEvent = false } = opts;
let { x = this.x, y = this.y } = opts;
// If we don't have an element to position, just set the value as given.
// We'll check bounds later.
if (!this._positioningEl) {
this._updateTransform(scale, x, y, allowChangeEvent);
return;
}
// Get current layout
const thisBounds = this.getBoundingClientRect();
const positioningElBounds = this._positioningEl.getBoundingClientRect();
// Not displayed. May be disconnected or display:none.
// Just take the values, and we'll check bounds later.
if (!thisBounds.width || !thisBounds.height) {
this._updateTransform(scale, x, y, allowChangeEvent);
return;
}
// Create points for _positioningEl.
let topLeft = createPoint();
topLeft.x = positioningElBounds.left - thisBounds.left;
topLeft.y = positioningElBounds.top - thisBounds.top;
let bottomRight = createPoint();
bottomRight.x = positioningElBounds.width + topLeft.x;
bottomRight.y = positioningElBounds.height + topLeft.y;
// Calculate the intended position of _positioningEl.
const matrix = createMatrix()
.translate(x, y)
.scale(scale)
// Undo current transform
.multiply(this._transform.inverse());
topLeft = topLeft.matrixTransform(matrix);
bottomRight = bottomRight.matrixTransform(matrix);
// Ensure _positioningEl can't move beyond out-of-bounds.
// Correct for x
if (topLeft.x > thisBounds.width) {
x += thisBounds.width - topLeft.x;
} else if (bottomRight.x < 0) {
x += -bottomRight.x;
}
// Correct for y
if (topLeft.y > thisBounds.height) {
y += thisBounds.height - topLeft.y;
} else if (bottomRight.y < 0) {
y += -bottomRight.y;
}
this._updateTransform(scale, x, y, allowChangeEvent);
}
/**
* Update transform values without checking bounds. This is only called in setTransform.
*/
private _updateTransform(
scale: number,
x: number,
y: number,
allowChangeEvent: boolean,
) {
// Avoid scaling to zero
if (scale < MIN_SCALE) return;
// Return if there's no change
if (scale === this.scale && x === this.x && y === this.y) return;
this._transform.e = x;
this._transform.f = y;
this._transform.d = this._transform.a = scale;
this.style.setProperty('--x', this.x + 'px');
this.style.setProperty('--y', this.y + 'px');
this.style.setProperty('--scale', this.scale + '');
if (allowChangeEvent) {
const event = new Event('change', { bubbles: true });
this.dispatchEvent(event);
}
}
/**
* Called when the direct children of this element change.
* Until we have have shadow dom support across the board, we
* require a single element to be the child of <pinch-zoom>, and
* that's the element we pan/scale.
*/
private _stageElChange() {
this._positioningEl = undefined;
if (this.children.length === 0) return;
this._positioningEl = this.children[0];
if (this.children.length > 1) {
console.warn('<pinch-zoom> must not have more than one child.');
}
// Do a bounds check
this.setTransform({ allowChangeEvent: true });
}
private _onWheel(event: WheelEvent) {
if (!this._positioningEl) return;
event.preventDefault();
const currentRect = this._positioningEl.getBoundingClientRect();
let { deltaY } = event;
const { ctrlKey, deltaMode } = event;
if (deltaMode === 1) {
// 1 is "lines", 0 is "pixels"
// Firefox uses "lines" for some types of mouse
deltaY *= 15;
}
// ctrlKey is true when pinch-zooming on a trackpad.
const divisor = ctrlKey ? 100 : 300;
const scaleDiff = 1 - deltaY / divisor;
this._applyChange({
scaleDiff,
originX: event.clientX - currentRect.left,
originY: event.clientY - currentRect.top,
allowChangeEvent: true,
});
}
private _onPointerMove(
previousPointers: Pointer[],
currentPointers: Pointer[],
) {
if (!this._positioningEl) return;
// Combine next points with previous points
const currentRect = this._positioningEl.getBoundingClientRect();
// For calculating panning movement
const prevMidpoint = getMidpoint(previousPointers[0], previousPointers[1]);
const newMidpoint = getMidpoint(currentPointers[0], currentPointers[1]);
// Midpoint within the element
const originX = prevMidpoint.clientX - currentRect.left;
const originY = prevMidpoint.clientY - currentRect.top;
// Calculate the desired change in scale
const prevDistance = getDistance(previousPointers[0], previousPointers[1]);
const newDistance = getDistance(currentPointers[0], currentPointers[1]);
const scaleDiff = prevDistance ? newDistance / prevDistance : 1;
this._applyChange({
originX,
originY,
scaleDiff,
panX: newMidpoint.clientX - prevMidpoint.clientX,
panY: newMidpoint.clientY - prevMidpoint.clientY,
allowChangeEvent: true,
});
}
/** Transform the view & fire a change event */
private _applyChange(opts: ApplyChangeOpts = {}) {
const {
panX = 0,
panY = 0,
originX = 0,
originY = 0,
scaleDiff = 1,
allowChangeEvent = false,
} = opts;
const matrix = createMatrix()
// Translate according to panning.
.translate(panX, panY)
// Scale about the origin.
.translate(originX, originY)
// Apply current translate
.translate(this.x, this.y)
.scale(scaleDiff)
.translate(-originX, -originY)
// Apply current scale.
.scale(this.scale);
// Convert the transform into basic translate & scale.
this.setTransform({
allowChangeEvent,
scale: matrix.a,
x: matrix.e,
y: matrix.f,
});
}
}
customElements.define('pinch-zoom', PinchZoom);

View File

@@ -1,16 +0,0 @@
declare interface CSSStyleDeclaration {
willChange: string | null;
}
// TypeScript, you make me sad.
// https://github.com/Microsoft/TypeScript/issues/18756
interface Window {
PointerEvent: typeof PointerEvent;
Touch: typeof Touch;
}
declare namespace JSX {
interface IntrinsicElements {
'pinch-zoom': HTMLAttributes;
}
}

View File

@@ -1,14 +0,0 @@
pinch-zoom {
display: block;
overflow: hidden;
touch-action: none;
--scale: 1;
--x: 0;
--y: 0;
}
pinch-zoom > * {
transform: translate(var(--x), var(--y)) scale(var(--scale));
transform-origin: 0 0;
will-change: transform;
}

View File

@@ -1,171 +0,0 @@
import PointerTracker, { Pointer } from 'pointer-tracker';
import * as styles from './styles.css';
const legacyClipCompatAttr = 'legacy-clip-compat';
const orientationAttr = 'orientation';
type TwoUpOrientation = 'horizontal' | 'vertical';
/**
* A split view that the user can adjust. The first child becomes
* the left-hand side, and the second child becomes the right-hand side.
*/
export default class TwoUp extends HTMLElement {
static get observedAttributes() {
return [orientationAttr];
}
private readonly _handle = document.createElement('div');
/**
* The position of the split in pixels.
*/
private _position = 0;
/**
* The position of the split in %.
*/
private _relativePosition = 0.5;
/**
* The value of _position when the pointer went down.
*/
private _positionOnPointerStart = 0;
/**
* Has connectedCallback been called yet?
*/
private _everConnected = false;
constructor() {
super();
this._handle.className = styles.twoUpHandle;
// Watch for children changes.
// Note this won't fire for initial contents,
// so _childrenChange is also called in connectedCallback.
new MutationObserver(() => this._childrenChange()).observe(this, {
childList: true,
});
// Watch for element size changes.
if ('ResizeObserver' in window) {
new ResizeObserver(() => this._resetPosition()).observe(this);
} else {
window.addEventListener('resize', () => this._resetPosition());
}
// Watch for pointers on the handle.
const pointerTracker: PointerTracker = new PointerTracker(this._handle, {
start: (_, event) => {
// We only want to track 1 pointer.
if (pointerTracker.currentPointers.length === 1) return false;
event.preventDefault();
this._positionOnPointerStart = this._position;
return true;
},
move: () => {
this._pointerChange(
pointerTracker.startPointers[0],
pointerTracker.currentPointers[0],
);
},
});
}
connectedCallback() {
this._childrenChange();
this._handle.innerHTML = `<div class="${
styles.scrubber
}">${`<svg viewBox="0 0 27 20" fill="currentColor">${'<path d="M17 19.2l9.5-9.6L16.9 0zM9.6 0L0 9.6l9.6 9.6z"/>'}</svg>`}</div>`;
if (!this._everConnected) {
this._resetPosition();
this._everConnected = true;
}
}
attributeChangedCallback(name: string) {
if (name === orientationAttr) {
this._resetPosition();
}
}
private _resetPosition() {
// Set the initial position of the handle.
requestAnimationFrame(() => {
const bounds = this.getBoundingClientRect();
const dimensionAxis =
this.orientation === 'vertical' ? 'height' : 'width';
this._position = bounds[dimensionAxis] * this._relativePosition;
this._setPosition();
});
}
/**
* If true, this element works in browsers that don't support clip-path (Edge).
* However, this means you'll have to set the height of this element manually.
*/
get legacyClipCompat() {
return this.hasAttribute(legacyClipCompatAttr);
}
set legacyClipCompat(val: boolean) {
if (val) {
this.setAttribute(legacyClipCompatAttr, '');
} else {
this.removeAttribute(legacyClipCompatAttr);
}
}
/**
* Split vertically rather than horizontally.
*/
get orientation(): TwoUpOrientation {
const value = this.getAttribute(orientationAttr);
// This mirrors the behaviour of input.type, where setting just sets the attribute, but getting
// returns the value only if it's valid.
if (value && value.toLowerCase() === 'vertical') return 'vertical';
return 'horizontal';
}
set orientation(val: TwoUpOrientation) {
this.setAttribute(orientationAttr, val);
}
/**
* Called when element's child list changes
*/
private _childrenChange() {
// Ensure the handle is the last child.
// The CSS depends on this.
if (this.lastElementChild !== this._handle) {
this.appendChild(this._handle);
}
}
/**
* Called when a pointer moves.
*/
private _pointerChange(startPoint: Pointer, currentPoint: Pointer) {
const pointAxis = this.orientation === 'vertical' ? 'clientY' : 'clientX';
const dimensionAxis = this.orientation === 'vertical' ? 'height' : 'width';
const bounds = this.getBoundingClientRect();
this._position =
this._positionOnPointerStart +
(currentPoint[pointAxis] - startPoint[pointAxis]);
// Clamp position to element bounds.
this._position = Math.max(
0,
Math.min(this._position, bounds[dimensionAxis]),
);
this._relativePosition = this._position / bounds[dimensionAxis];
this._setPosition();
}
private _setPosition() {
this.style.setProperty('--split-point', `${this._position}px`);
}
}
customElements.define('two-up', TwoUp);

View File

@@ -1,52 +0,0 @@
declare interface CSSStyleDeclaration {
willChange: string | null;
}
// TypeScript, you make me sad.
// https://github.com/Microsoft/TypeScript/issues/18756
interface Window {
PointerEvent: typeof PointerEvent;
Touch: typeof Touch;
}
interface TwoUpAttributes extends JSX.HTMLAttributes {
orientation?: string;
'legacy-clip-compat'?: boolean;
}
declare namespace JSX {
interface IntrinsicElements {
'two-up': TwoUpAttributes;
}
}
interface DOMRectReadOnly {
readonly x: number;
readonly y: number;
readonly width: number;
readonly height: number;
readonly top: number;
readonly right: number;
readonly bottom: number;
readonly left: number;
}
interface ResizeObserverCallback {
(entries: ResizeObserverEntry[], observer: ResizeObserver): void;
}
interface ResizeObserverEntry {
readonly target: Element;
readonly contentRect: DOMRectReadOnly;
}
interface ResizeObserver {
observe(target: Element): void;
unobserve(target: Element): void;
disconnect(): void;
}
declare var ResizeObserver: {
prototype: ResizeObserver;
new (callback: ResizeObserverCallback): ResizeObserver;
};

View File

@@ -1,131 +0,0 @@
two-up {
display: grid;
position: relative;
--split-point: 0;
--accent-color: #777;
--track-color: var(--accent-color);
--thumb-background: #fff;
--thumb-color: var(--accent-color);
--thumb-size: 62px;
--bar-size: 6px;
--bar-touch-size: 30px;
}
two-up > * {
/* Overlay all children on top of each other, and let two-up's layout contain all of them. */
grid-area: 1/1;
}
two-up[legacy-clip-compat] > :not(.two-up-handle) {
/* Legacy mode uses clip rather than clip-path (Edge doesn't support clip-path), but clip requires
elements to be positioned absolutely */
position: absolute;
}
.two-up-handle {
touch-action: none;
position: relative;
width: var(--bar-touch-size);
transform: translateX(var(--split-point)) translateX(-50%);
will-change: transform;
cursor: ew-resize;
}
.two-up-handle::before {
content: '';
display: block;
height: 100%;
width: var(--bar-size);
margin: 0 auto;
box-shadow: inset calc(var(--bar-size) / 2) 0 0 rgba(0, 0, 0, 0.1),
0 1px 4px rgba(0, 0, 0, 0.4);
background: var(--track-color);
}
.scrubber {
display: flex;
position: absolute;
top: 50%;
left: 50%;
transform-origin: 50% 50%;
transform: translate(-50%, -50%);
width: var(--thumb-size);
height: calc(var(--thumb-size) * 0.9);
background: var(--thumb-background);
border: 1px solid rgba(0, 0, 0, 0.2);
border-radius: var(--thumb-size);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
color: var(--thumb-color);
box-sizing: border-box;
padding: 0 calc(var(--thumb-size) * 0.24);
}
.scrubber svg {
flex: 1;
}
two-up[orientation='vertical'] .two-up-handle {
width: auto;
height: var(--bar-touch-size);
transform: translateY(var(--split-point)) translateY(-50%);
cursor: ns-resize;
}
two-up[orientation='vertical'] .two-up-handle::before {
width: auto;
height: var(--bar-size);
box-shadow: inset 0 calc(var(--bar-size) / 2) 0 rgba(0, 0, 0, 0.1),
0 1px 4px rgba(0, 0, 0, 0.4);
margin: calc((var(--bar-touch-size) - var(--bar-size)) / 2) 0 0 0;
}
two-up[orientation='vertical'] .scrubber {
box-shadow: 1px 0 4px rgba(0, 0, 0, 0.1);
transform: translate(-50%, -50%) rotate(-90deg);
}
two-up > :nth-child(1):not(.two-up-handle) {
-webkit-clip-path: inset(0 calc(100% - var(--split-point)) 0 0);
clip-path: inset(0 calc(100% - var(--split-point)) 0 0);
}
two-up > :nth-child(2):not(.two-up-handle) {
-webkit-clip-path: inset(0 0 0 var(--split-point));
clip-path: inset(0 0 0 var(--split-point));
}
two-up[orientation='vertical'] > :nth-child(1):not(.two-up-handle) {
-webkit-clip-path: inset(0 0 calc(100% - var(--split-point)) 0);
clip-path: inset(0 0 calc(100% - var(--split-point)) 0);
}
two-up[orientation='vertical'] > :nth-child(2):not(.two-up-handle) {
-webkit-clip-path: inset(var(--split-point) 0 0 0);
clip-path: inset(var(--split-point) 0 0 0);
}
/*
Even in legacy-clip-compat, prefer clip-path if it's supported.
It performs way better in Safari.
*/
@supports not (
(clip-path: inset(0 0 0 0)) or (-webkit-clip-path: inset(0 0 0 0))
) {
two-up[legacy-clip-compat] > :nth-child(1):not(.two-up-handle) {
clip: rect(auto var(--split-point) auto auto);
}
two-up[legacy-clip-compat] > :nth-child(2):not(.two-up-handle) {
clip: rect(auto auto auto var(--split-point));
}
two-up[orientation='vertical'][legacy-clip-compat]
> :nth-child(1):not(.two-up-handle) {
clip: rect(auto auto var(--split-point) auto);
}
two-up[orientation='vertical'][legacy-clip-compat]
> :nth-child(2):not(.two-up-handle) {
clip: rect(var(--split-point) auto auto auto);
}
}

View File

@@ -1,384 +0,0 @@
import { h, Component } from 'preact';
import PinchZoom, { ScaleToOpts } from './custom-els/PinchZoom';
import './custom-els/PinchZoom';
import './custom-els/TwoUp';
import * as style from './style.scss';
import { bind, linkRef } from '../../lib/initial-util';
import { shallowEqual, drawDataToCanvas } from '../../lib/util';
import {
ToggleBackgroundIcon,
AddIcon,
RemoveIcon,
BackIcon,
ToggleBackgroundActiveIcon,
RotateIcon,
} from '../../lib/icons';
import { twoUpHandle } from './custom-els/TwoUp/styles.css';
import { InputProcessorState } from '../../codecs/input-processors';
import { cleanSet } from '../../lib/clean-modify';
import { SourceImage } from '../compress';
interface Props {
source?: SourceImage;
inputProcessorState?: InputProcessorState;
mobileView: boolean;
leftCompressed?: ImageData;
rightCompressed?: ImageData;
leftImgContain: boolean;
rightImgContain: boolean;
onBack: () => void;
onInputProcessorChange: (newState: InputProcessorState) => void;
}
interface State {
scale: number;
editingScale: boolean;
altBackground: boolean;
}
const scaleToOpts: ScaleToOpts = {
originX: '50%',
originY: '50%',
relativeTo: 'container',
allowChangeEvent: true,
};
export default class Output extends Component<Props, State> {
state: State = {
scale: 1,
editingScale: false,
altBackground: false,
};
canvasLeft?: HTMLCanvasElement;
canvasRight?: HTMLCanvasElement;
pinchZoomLeft?: PinchZoom;
pinchZoomRight?: PinchZoom;
scaleInput?: HTMLInputElement;
retargetedEvents = new WeakSet<Event>();
componentDidMount() {
const leftDraw = this.leftDrawable();
const rightDraw = this.rightDrawable();
// Reset the pinch zoom, which may have an position set from the previous view, after pressing
// the back button.
this.pinchZoomLeft!.setTransform({
allowChangeEvent: true,
x: 0,
y: 0,
scale: 1,
});
if (this.canvasLeft && leftDraw) {
drawDataToCanvas(this.canvasLeft, leftDraw);
}
if (this.canvasRight && rightDraw) {
drawDataToCanvas(this.canvasRight, rightDraw);
}
}
componentDidUpdate(prevProps: Props, prevState: State) {
const prevLeftDraw = this.leftDrawable(prevProps);
const prevRightDraw = this.rightDrawable(prevProps);
const leftDraw = this.leftDrawable();
const rightDraw = this.rightDrawable();
const sourceFileChanged =
// Has the value become (un)defined?
!!this.props.source !== !!prevProps.source ||
// Or has the file changed?
(this.props.source &&
prevProps.source &&
this.props.source.file !== prevProps.source.file);
const oldSourceData = prevProps.source && prevProps.source.processed;
const newSourceData = this.props.source && this.props.source.processed;
const pinchZoom = this.pinchZoomLeft!;
if (sourceFileChanged) {
// New image? Reset the pinch-zoom.
pinchZoom.setTransform({
allowChangeEvent: true,
x: 0,
y: 0,
scale: 1,
});
} else if (
oldSourceData &&
newSourceData &&
oldSourceData !== newSourceData
) {
// Since the pinch zoom transform origin is the top-left of the content, we need to flip
// things around a bit when the content size changes, so the new content appears as if it were
// central to the previous content.
const scaleChange = 1 - pinchZoom.scale;
const oldXScaleOffset = (oldSourceData.width / 2) * scaleChange;
const oldYScaleOffset = (oldSourceData.height / 2) * scaleChange;
pinchZoom.setTransform({
allowChangeEvent: true,
x: pinchZoom.x - oldXScaleOffset + oldYScaleOffset,
y: pinchZoom.y - oldYScaleOffset + oldXScaleOffset,
});
}
if (leftDraw && leftDraw !== prevLeftDraw && this.canvasLeft) {
drawDataToCanvas(this.canvasLeft, leftDraw);
}
if (rightDraw && rightDraw !== prevRightDraw && this.canvasRight) {
drawDataToCanvas(this.canvasRight, rightDraw);
}
}
shouldComponentUpdate(nextProps: Props, nextState: State) {
return (
!shallowEqual(this.props, nextProps) ||
!shallowEqual(this.state, nextState)
);
}
private leftDrawable(props: Props = this.props): ImageData | undefined {
return props.leftCompressed || (props.source && props.source.processed);
}
private rightDrawable(props: Props = this.props): ImageData | undefined {
return props.rightCompressed || (props.source && props.source.processed);
}
@bind
private toggleBackground() {
this.setState({
altBackground: !this.state.altBackground,
});
}
@bind
private zoomIn() {
if (!this.pinchZoomLeft) throw Error('Missing pinch-zoom element');
this.pinchZoomLeft.scaleTo(this.state.scale * 1.25, scaleToOpts);
}
@bind
private zoomOut() {
if (!this.pinchZoomLeft) throw Error('Missing pinch-zoom element');
this.pinchZoomLeft.scaleTo(this.state.scale / 1.25, scaleToOpts);
}
@bind
private onRotateClick() {
const { inputProcessorState } = this.props;
if (!inputProcessorState) return;
const newState = cleanSet(
inputProcessorState,
'rotate.rotate',
(inputProcessorState.rotate.rotate + 90) % 360,
);
this.props.onInputProcessorChange(newState);
}
@bind
private onScaleValueFocus() {
this.setState({ editingScale: true }, () => {
if (this.scaleInput) {
// Firefox unfocuses the input straight away unless I force a style calculation here. I have
// no idea why, but it's late and I'm quite tired.
getComputedStyle(this.scaleInput).transform;
this.scaleInput.focus();
}
});
}
@bind
private onScaleInputBlur() {
this.setState({ editingScale: false });
}
@bind
private onScaleInputChanged(event: Event) {
const target = event.target as HTMLInputElement;
const percent = parseFloat(target.value);
if (isNaN(percent)) return;
if (!this.pinchZoomLeft) throw Error('Missing pinch-zoom element');
this.pinchZoomLeft.scaleTo(percent / 100, scaleToOpts);
}
@bind
private onPinchZoomLeftChange(event: Event) {
if (!this.pinchZoomRight || !this.pinchZoomLeft)
throw Error('Missing pinch-zoom element');
this.setState({
scale: this.pinchZoomLeft.scale,
});
this.pinchZoomRight.setTransform({
scale: this.pinchZoomLeft.scale,
x: this.pinchZoomLeft.x,
y: this.pinchZoomLeft.y,
});
}
/**
* We're using two pinch zoom elements, but we want them to stay in sync. When one moves, we
* update the position of the other. However, this is tricky when it comes to multi-touch, when
* one finger is on one pinch-zoom, and the other finger is on the other. To overcome this, we
* redirect all relevant pointer/touch/mouse events to the first pinch zoom element.
*
* @param event Event to redirect
*/
@bind
private onRetargetableEvent(event: Event) {
const targetEl = event.target as HTMLElement;
if (!this.pinchZoomLeft) throw Error('Missing pinch-zoom element');
// If the event is on the handle of the two-up, let it through,
// unless it's a wheel event, in which case always let it through.
if (event.type !== 'wheel' && targetEl.closest(`.${twoUpHandle}`)) return;
// If we've already retargeted this event, let it through.
if (this.retargetedEvents.has(event)) return;
// Stop the event in its tracks.
event.stopImmediatePropagation();
event.preventDefault();
// Clone the event & dispatch
// Some TypeScript trickery needed due to https://github.com/Microsoft/TypeScript/issues/3841
const clonedEvent = new (event.constructor as typeof Event)(
event.type,
event,
);
this.retargetedEvents.add(clonedEvent);
this.pinchZoomLeft.dispatchEvent(clonedEvent);
// Unfocus any active element on touchend. This fixes an issue on (at least) Android Chrome,
// where the software keyboard is hidden, but the input remains focused, then after interaction
// with this element the keyboard reappears for NO GOOD REASON. Thanks Android.
if (
event.type === 'touchend' &&
document.activeElement &&
document.activeElement instanceof HTMLElement
) {
document.activeElement.blur();
}
}
render(
{ mobileView, leftImgContain, rightImgContain, source, onBack }: Props,
{ scale, editingScale, altBackground }: State,
) {
const leftDraw = this.leftDrawable();
const rightDraw = this.rightDrawable();
// To keep position stable, the output is put in a square using the longest dimension.
const originalImage = source && source.processed;
return (
<div
class={`${style.output} ${altBackground ? style.altBackground : ''}`}
>
<two-up
legacy-clip-compat
class={style.twoUp}
orientation={mobileView ? 'vertical' : 'horizontal'}
// Event redirecting. See onRetargetableEvent.
onTouchStartCapture={this.onRetargetableEvent}
onTouchEndCapture={this.onRetargetableEvent}
onTouchMoveCapture={this.onRetargetableEvent}
onPointerDownCapture={this.onRetargetableEvent}
onMouseDownCapture={this.onRetargetableEvent}
onWheelCapture={this.onRetargetableEvent}
>
<pinch-zoom
class={style.pinchZoom}
onChange={this.onPinchZoomLeftChange}
ref={linkRef(this, 'pinchZoomLeft')}
>
<canvas
class={style.pinchTarget}
ref={linkRef(this, 'canvasLeft')}
width={leftDraw && leftDraw.width}
height={leftDraw && leftDraw.height}
style={{
width: originalImage && originalImage.width,
height: originalImage && originalImage.height,
objectFit: leftImgContain ? 'contain' : '',
}}
/>
</pinch-zoom>
<pinch-zoom
class={style.pinchZoom}
ref={linkRef(this, 'pinchZoomRight')}
>
<canvas
class={style.pinchTarget}
ref={linkRef(this, 'canvasRight')}
width={rightDraw && rightDraw.width}
height={rightDraw && rightDraw.height}
style={{
width: originalImage && originalImage.width,
height: originalImage && originalImage.height,
objectFit: rightImgContain ? 'contain' : '',
}}
/>
</pinch-zoom>
</two-up>
<div class={style.back}>
<button class={style.button} onClick={onBack}>
<BackIcon />
</button>
</div>
<div class={style.controls}>
<div class={style.zoomControls}>
<button class={style.button} onClick={this.zoomOut}>
<RemoveIcon />
</button>
{editingScale ? (
<input
type="number"
step="1"
min="1"
max="1000000"
ref={linkRef(this, 'scaleInput')}
class={style.zoom}
value={Math.round(scale * 100)}
onInput={this.onScaleInputChanged}
onBlur={this.onScaleInputBlur}
/>
) : (
<span
class={style.zoom}
tabIndex={0}
onFocus={this.onScaleValueFocus}
>
<span class={style.zoomValue}>{Math.round(scale * 100)}</span>%
</span>
)}
<button class={style.button} onClick={this.zoomIn}>
<AddIcon />
</button>
</div>
<div class={style.buttonsNoWrap}>
<button
class={style.button}
onClick={this.onRotateClick}
title="Rotate image"
>
<RotateIcon />
</button>
<button
class={`${style.button} ${altBackground ? style.active : ''}`}
onClick={this.toggleBackground}
title="Change canvas color"
>
{altBackground ? (
<ToggleBackgroundActiveIcon />
) : (
<ToggleBackgroundIcon />
)}
</button>
</div>
</div>
</div>
);
}
}

View File

@@ -1,166 +0,0 @@
.output {
composes: abs-fill from '../../lib/util.scss';
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #000;
opacity: 0;
transition: opacity 500ms ease;
}
&.alt-background::before {
opacity: 0.6;
}
}
.two-up {
composes: abs-fill from '../../lib/util.scss';
--accent-color: var(--button-fg);
}
.pinch-zoom {
composes: abs-fill from '../../lib/util.scss';
outline: none;
display: flex;
justify-content: center;
align-items: center;
}
.pinch-target {
// This fixes a severe painting bug in Chrome.
// We should try to remove this once the issue is fixed.
// https://bugs.chromium.org/p/chromium/issues/detail?id=870222#c10
will-change: auto;
// Prevent the image becoming misshapen due to default flexbox layout.
flex-shrink: 0;
}
.controls {
position: absolute;
display: flex;
justify-content: center;
top: 0;
left: 0;
right: 0;
padding: 9px 84px;
overflow: hidden;
flex-wrap: wrap;
contain: content;
// Allow clicks to fall through to the pinch zoom area
pointer-events: none;
& > * {
pointer-events: auto;
}
@media (min-width: 860px) {
padding: 9px;
top: auto;
left: 320px;
right: 320px;
bottom: 0;
flex-wrap: wrap-reverse;
}
}
.zoom-controls {
display: flex;
& :not(:first-child) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
margin-left: 0;
}
& :not(:last-child) {
margin-right: 0;
border-right-width: 0;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
}
.button,
.zoom {
display: flex;
align-items: center;
box-sizing: border-box;
margin: 4px;
background-color: #fff;
border: 1px solid rgba(0,0,0,0.2);
border-radius: 5px;
line-height: 1;
white-space: nowrap;
height: 36px;
padding: 0 8px;
cursor: pointer;
@media (min-width: 600px) {
height: 48px;
padding: 0 16px;
}
&:focus {
box-shadow: 0 0 0 2px var(--button-fg);
outline: none;
z-index: 1;
}
}
.button {
color: var(--button-fg);
&:hover {
background-color: #eee;
}
&.active {
background: #34B9EB;
color: #fff;
&:hover {
background: #32a3ce;
}
}
}
.zoom {
color: #625E80;
cursor: text;
width: 6em;
font: inherit;
text-align: center;
justify-content: center;
&:focus {
box-shadow: inset 0 1px 4px rgba(0,0,0,0.2), 0 0 0 2px var(--button-fg);
}
}
.zoom-value {
position: relative;
top: 1px;
margin: 0 3px 0 0;
color: #888;
border-bottom: 1px dashed #999;
}
.back {
position: absolute;
top: 0;
left: 0;
padding: 9px;
}
.buttons-no-wrap {
display: flex;
pointer-events: none;
& > * {
pointer-events: auto;
}
}

View File

@@ -1,21 +0,0 @@
import { h, Component } from 'preact';
import * as style from './style.scss';
import { UncheckedIcon, CheckedIcon } from '../../lib/icons';
interface Props extends JSX.HTMLAttributes {}
interface State {}
export default class Checkbox extends Component<Props, State> {
render(props: Props) {
return (
<div class={style.checkbox}>
{props.checked ? (
<CheckedIcon class={`${style.icon} ${style.checked}`} />
) : (
<UncheckedIcon class={style.icon} />
)}
<input class={style.realCheckbox} type="checkbox" {...props} />
</div>
);
}
}

View File

@@ -1,22 +0,0 @@
.checkbox {
display: inline-block;
position: relative;
--size: 17px;
}
.real-checkbox {
top: 0;
position: absolute;
opacity: 0;
pointer-events: none;
}
.icon {
display: block;
width: var(--size);
height: var(--size);
}
.checked {
fill: #34B9EB;
}

View File

@@ -1,320 +0,0 @@
import * as style from './styles.css';
import { transitionHeight } from '../../../../lib/util';
interface CloseAllOptions {
exceptFirst?: boolean;
}
const openOneOnlyAttr = 'open-one-only';
function getClosestHeading(el: Element): HTMLElement | undefined {
// Look for the child of multi-panel, but stop at interactive elements like links & buttons
const closestEl = el.closest('multi-panel > *, a, button');
if (closestEl && closestEl.classList.contains(style.panelHeading)) {
return closestEl as HTMLElement;
}
return undefined;
}
async function close(heading: HTMLElement) {
const content = heading.nextElementSibling as HTMLElement;
// if there is no content, nothing to expand
if (!content) return;
const from = content.getBoundingClientRect().height;
heading.removeAttribute('content-expanded');
content.setAttribute('aria-expanded', 'false');
// Wait a microtask so other calls to open/close can get the final sizes.
await null;
await transitionHeight(content, {
from,
to: 0,
duration: 300,
});
content.style.height = '';
}
async function open(heading: HTMLElement) {
const content = heading.nextElementSibling as HTMLElement;
// if there is no content, nothing to expand
if (!content) return;
const from = content.getBoundingClientRect().height;
heading.setAttribute('content-expanded', '');
content.setAttribute('aria-expanded', 'true');
const to = content.getBoundingClientRect().height;
// Wait a microtask so other calls to open/close can get the final sizes.
await null;
await transitionHeight(content, {
from,
to,
duration: 300,
});
content.style.height = '';
}
/**
* A multi-panel view that the user can add any number of 'panels'.
* 'a panel' consists of two elements. Even index element becomes heading,
* and odd index element becomes the expandable content.
*/
export default class MultiPanel extends HTMLElement {
static get observedAttributes() {
return [openOneOnlyAttr];
}
constructor() {
super();
// add EventListeners
this.addEventListener('click', this._onClick);
this.addEventListener('keydown', this._onKeyDown);
// Watch for children changes.
new MutationObserver(() => this._childrenChange()).observe(this, {
childList: true,
});
}
connectedCallback() {
this._childrenChange();
}
attributeChangedCallback(
name: string,
oldValue: string | null,
newValue: string | null,
) {
if (name === openOneOnlyAttr && newValue === null) {
this._closeAll({ exceptFirst: true });
}
}
// Click event handler
private _onClick(event: MouseEvent) {
const el = event.target as HTMLElement;
const heading = getClosestHeading(el);
if (!heading) return;
this._toggle(heading);
}
// KeyDown event handler
private _onKeyDown(event: KeyboardEvent) {
const selectedEl = document.activeElement!;
const heading = getClosestHeading(selectedEl);
// if keydown event is not on heading element, ignore
if (!heading) return;
// if something inside of heading has focus, ignore
if (selectedEl !== heading) return;
// dont handle modifier shortcuts used by assistive technology.
if (event.altKey) return;
let newHeading: HTMLElement | undefined;
switch (event.key) {
case 'ArrowLeft':
case 'ArrowUp':
newHeading = this._prevHeading();
break;
case 'ArrowRight':
case 'ArrowDown':
newHeading = this._nextHeading();
break;
case 'Home':
newHeading = this._firstHeading();
break;
case 'End':
newHeading = this._lastHeading();
break;
// this has 3 cases listed to support IEs and FF before 37
case 'Enter':
case ' ':
case 'Spacebar':
this._toggle(heading);
break;
// Any other key press is ignored and passed back to the browser.
default:
return;
}
event.preventDefault();
if (newHeading) {
selectedEl.setAttribute('tabindex', '-1');
newHeading.setAttribute('tabindex', '0');
newHeading.focus();
}
}
private _toggle(heading: HTMLElement) {
if (!heading) return;
// toggle expanded and aria-expanded attributes
if (heading.hasAttribute('content-expanded')) {
close(heading);
} else {
if (this.openOneOnly) this._closeAll();
open(heading);
}
}
private _closeAll(options: CloseAllOptions = {}): void {
const { exceptFirst = false } = options;
let els = [...this.children].filter((el) =>
el.matches('[content-expanded]'),
) as HTMLElement[];
if (exceptFirst) {
els = els.slice(1);
}
for (const el of els) close(el);
}
// children of multi-panel should always be even number (heading/content pair)
private _childrenChange() {
let preserveTabIndex = false;
let heading = this.firstElementChild;
while (heading) {
const content = heading.nextElementSibling;
const randomId = Math.random().toString(36).substr(2, 9);
// if at the end of this loop, runout of element for content,
// it means it has odd number of elements. log error and set heading to end the loop.
if (!content) {
console.error(
'<multi-panel> requires an even number of element children.',
);
break;
}
// When odd number of elements were inserted in the middle,
// what was heading before may become content after the insertion.
// Remove classes and attributes to prepare for this change.
heading.classList.remove(style.panelContent);
content.classList.remove(style.panelHeading);
heading.removeAttribute('aria-expanded');
heading.removeAttribute('content-expanded');
// If appreciable, remove tabindex from content which used to be header.
content.removeAttribute('tabindex');
// Assign heading and content classes
heading.classList.add(style.panelHeading);
content.classList.add(style.panelContent);
// Assign ids and aria-X for heading/content pair.
heading.id = `panel-heading-${randomId}`;
heading.setAttribute('aria-controls', `panel-content-${randomId}`);
content.id = `panel-content-${randomId}`;
content.setAttribute('aria-labelledby', `panel-heading-${randomId}`);
// If tabindex 0 is assigned to a heading, flag to preserve tab index position.
// Otherwise, make sure tabindex -1 is set to heading elements.
if (heading.getAttribute('tabindex') === '0') {
preserveTabIndex = true;
} else {
heading.setAttribute('tabindex', '-1');
}
// It's possible that the heading & content expanded attributes are now out of sync. Resync
// them using the heading as the source of truth.
content.setAttribute(
'aria-expanded',
heading.hasAttribute('content-expanded') ? 'true' : 'false',
);
// next sibling of content = next heading
heading = content.nextElementSibling;
}
// if no flag, make 1st heading as tabindex 0 (otherwise keep previous tab index position).
if (!preserveTabIndex && this.firstElementChild) {
this.firstElementChild.setAttribute('tabindex', '0');
}
// In case we're openOneOnly, and an additional open item has been added:
if (this.openOneOnly) this._closeAll({ exceptFirst: true });
}
// returns heading that is before currently selected one.
private _prevHeading() {
// activeElement would be the currently selected heading
// 2 elements before that would be the previous heading unless it is the first element.
if (this.firstElementChild === document.activeElement) {
return this.firstElementChild as HTMLElement;
}
// previous Element of active Element is previous Content,
// previous Element of previous Content is previousHeading
const previousContent = document.activeElement!.previousElementSibling;
if (previousContent) {
return previousContent.previousElementSibling as HTMLElement;
}
}
// returns heading that is after currently selected one.
private _nextHeading() {
// activeElement would be the currently selected heading
// 2 elemements after that would be the next heading.
const nextContent = document.activeElement!.nextElementSibling;
if (nextContent) {
return nextContent.nextElementSibling as HTMLElement;
}
}
// returns first heading in multi-panel.
private _firstHeading() {
// first element is always first heading
return this.firstElementChild as HTMLElement;
}
// returns last heading in multi-panel.
private _lastHeading() {
// if the last element is heading, return last element
const lastEl = this.lastElementChild as HTMLElement;
if (lastEl && lastEl.classList.contains(style.panelHeading)) {
return lastEl;
}
// otherwise return 2nd from the last
const lastContent = this.lastElementChild;
if (lastContent) {
return lastContent.previousElementSibling as HTMLElement;
}
}
/**
* If true, only one panel can be open at once. When one opens, others close.
*/
get openOneOnly() {
return this.hasAttribute(openOneOnlyAttr);
}
set openOneOnly(val: boolean) {
if (val) {
this.setAttribute(openOneOnlyAttr, '');
} else {
this.removeAttribute(openOneOnlyAttr);
}
}
}
customElements.define('multi-panel', MultiPanel);

View File

@@ -1,9 +0,0 @@
interface MultiPanelAttributes extends JSX.HTMLAttributes {
'open-one-only'?: boolean;
}
declare namespace JSX {
interface IntrinsicElements {
'multi-panel': MultiPanelAttributes;
}
}

View File

@@ -1,10 +0,0 @@
.panel-heading {
background: gray;
}
.panel-content {
height: 0px;
overflow: auto;
}
.panel-content[aria-expanded='true'] {
height: auto;
}

View File

@@ -1,774 +0,0 @@
import { h, Component } from 'preact';
import { bind, Fileish } from '../../lib/initial-util';
import { blobToImg, drawableToImageData, blobToText } from '../../lib/util';
import * as style from './style.scss';
import Output from '../Output';
import Options from '../Options';
import ResultCache from './result-cache';
import * as identity from '../../codecs/identity/encoder-meta';
import * as oxiPNG from '../../codecs/oxipng/encoder-meta';
import * as mozJPEG from '../../codecs/mozjpeg/encoder-meta';
import * as webP from '../../codecs/webp/encoder-meta';
import * as avif from '../../codecs/avif/encoder-meta';
import * as browserPNG from '../../codecs/browser-png/encoder-meta';
import * as browserJPEG from '../../codecs/browser-jpeg/encoder-meta';
import * as browserWebP from '../../codecs/browser-webp/encoder-meta';
import * as browserGIF from '../../codecs/browser-gif/encoder-meta';
import * as browserTIFF from '../../codecs/browser-tiff/encoder-meta';
import * as browserJP2 from '../../codecs/browser-jp2/encoder-meta';
import * as browserBMP from '../../codecs/browser-bmp/encoder-meta';
import * as browserPDF from '../../codecs/browser-pdf/encoder-meta';
import {
EncoderState,
EncoderType,
EncoderOptions,
encoderMap,
} from '../../codecs/encoders';
import {
PreprocessorState,
defaultPreprocessorState,
} from '../../codecs/preprocessors';
import { decodeImage } from '../../codecs/decoders';
import { cleanMerge, cleanSet } from '../../lib/clean-modify';
import Processor from '../../codecs/processor';
import {
BrowserResizeOptions,
isWorkerOptions as isWorkerResizeOptions,
isHqx,
WorkerResizeOptions,
} from '../../codecs/resize/processor-meta';
import './custom-els/MultiPanel';
import Results from '../results';
import { ExpandIcon, CopyAcrossIconProps } from '../../lib/icons';
import SnackBarElement from '../../lib/SnackBar';
import {
InputProcessorState,
defaultInputProcessorState,
} from '../../codecs/input-processors';
export interface SourceImage {
file: File | Fileish;
decoded: ImageData;
processed: ImageData;
vectorImage?: HTMLImageElement;
inputProcessorState: InputProcessorState;
}
interface SideSettings {
preprocessorState: PreprocessorState;
encoderState: EncoderState;
}
interface Side {
preprocessed?: ImageData;
file?: Fileish;
downloadUrl?: string;
data?: ImageData;
latestSettings: SideSettings;
encodedSettings?: SideSettings;
loading: boolean;
/** Counter of the latest bmp currently encoding */
loadingCounter: number;
/** Counter of the latest bmp encoded */
loadedCounter: number;
}
interface Props {
file: File | Fileish;
showSnack: SnackBarElement['showSnackbar'];
onBack: () => void;
}
interface State {
source?: SourceImage;
sides: [Side, Side];
/** Source image load */
loading: boolean;
loadingCounter: number;
error?: string;
mobileView: boolean;
}
interface UpdateImageOptions {
skipPreprocessing?: boolean;
}
async function processInput(
data: ImageData,
inputProcessData: InputProcessorState,
processor: Processor,
) {
let processedData = data;
if (inputProcessData.rotate.rotate !== 0) {
processedData = await processor.rotate(
processedData,
inputProcessData.rotate,
);
}
return processedData;
}
async function preprocessImage(
source: SourceImage,
preprocessData: PreprocessorState,
processor: Processor,
): Promise<ImageData> {
let result = source.processed;
if (preprocessData.resize.enabled) {
if (preprocessData.resize.method === 'vector' && source.vectorImage) {
result = processor.vectorResize(
source.vectorImage,
preprocessData.resize,
);
} else if (isHqx(preprocessData.resize)) {
// Hqx can only do x2, x3 or x4.
result = await processor.workerResize(result, preprocessData.resize);
// If the target size is not a clean x2, x3 or x4, use Catmull-Rom
// for the remaining scaling.
const pixelOpts = { ...preprocessData.resize, method: 'catrom' };
result = await processor.workerResize(
result,
pixelOpts as WorkerResizeOptions,
);
} else if (isWorkerResizeOptions(preprocessData.resize)) {
result = await processor.workerResize(result, preprocessData.resize);
} else {
result = processor.resize(
result,
preprocessData.resize as BrowserResizeOptions,
);
}
}
if (preprocessData.quantizer.enabled) {
result = await processor.imageQuant(result, preprocessData.quantizer);
}
return result;
}
async function compressImage(
image: ImageData,
encodeData: EncoderState,
sourceFilename: string,
processor: Processor,
): Promise<Fileish> {
const compressedData = await (() => {
switch (encodeData.type) {
case oxiPNG.type:
return processor.oxiPngEncode(image, encodeData.options);
case mozJPEG.type:
return processor.mozjpegEncode(image, encodeData.options);
case webP.type:
return processor.webpEncode(image, encodeData.options);
case avif.type:
return processor.avifEncode(image, encodeData.options);
case browserPNG.type:
return processor.browserPngEncode(image);
case browserJPEG.type:
return processor.browserJpegEncode(image, encodeData.options);
case browserWebP.type:
return processor.browserWebpEncode(image, encodeData.options);
case browserGIF.type:
return processor.browserGifEncode(image);
case browserTIFF.type:
return processor.browserTiffEncode(image);
case browserJP2.type:
return processor.browserJp2Encode(image);
case browserBMP.type:
return processor.browserBmpEncode(image);
case browserPDF.type:
return processor.browserPdfEncode(image);
default:
throw Error(`Unexpected encoder ${JSON.stringify(encodeData)}`);
}
})();
const encoder = encoderMap[encodeData.type];
return new Fileish(
[compressedData],
sourceFilename.replace(/.[^.]*$/, `.${encoder.extension}`),
{ type: encoder.mimeType },
);
}
function stateForNewSourceData(state: State, newSource: SourceImage): State {
let newState = { ...state };
for (const i of [0, 1]) {
// Ditch previous encodings
const downloadUrl = state.sides[i].downloadUrl;
if (downloadUrl) URL.revokeObjectURL(downloadUrl);
newState = cleanMerge(state, `sides.${i}`, {
preprocessed: undefined,
file: undefined,
downloadUrl: undefined,
data: undefined,
encodedSettings: undefined,
});
}
return newState;
}
async function processSvg(blob: Blob): Promise<HTMLImageElement> {
// Firefox throws if you try to draw an SVG to canvas that doesn't have width/height.
// In Chrome it loads, but drawImage behaves weirdly.
// This function sets width/height if it isn't already set.
const parser = new DOMParser();
const text = await blobToText(blob);
const document = parser.parseFromString(text, 'image/svg+xml');
const svg = document.documentElement!;
if (svg.hasAttribute('width') && svg.hasAttribute('height')) {
return blobToImg(blob);
}
const viewBox = svg.getAttribute('viewBox');
if (viewBox === null) throw Error('SVG must have width/height or viewBox');
const viewboxParts = viewBox.split(/\s+/);
svg.setAttribute('width', viewboxParts[2]);
svg.setAttribute('height', viewboxParts[3]);
const serializer = new XMLSerializer();
const newSource = serializer.serializeToString(document);
return blobToImg(new Blob([newSource], { type: 'image/svg+xml' }));
}
// These are only used in the mobile view
const resultTitles = ['Top', 'Bottom'];
// These are only used in the desktop view
const buttonPositions = ['download-left', 'download-right'] as (
| 'download-left'
| 'download-right'
)[];
const originalDocumentTitle = document.title;
export default class Compress extends Component<Props, State> {
widthQuery = window.matchMedia('(max-width: 599px)');
state: State = {
source: undefined,
loading: false,
loadingCounter: 0,
sides: [
{
latestSettings: {
preprocessorState: defaultPreprocessorState,
encoderState: {
type: identity.type,
options: identity.defaultOptions,
},
},
loadingCounter: 0,
loadedCounter: 0,
loading: false,
},
{
latestSettings: {
preprocessorState: defaultPreprocessorState,
encoderState: { type: mozJPEG.type, options: mozJPEG.defaultOptions },
},
loadingCounter: 0,
loadedCounter: 0,
loading: false,
},
],
mobileView: this.widthQuery.matches,
};
private readonly encodeCache = new ResultCache();
private readonly leftProcessor = new Processor();
private readonly rightProcessor = new Processor();
// For debouncing calls to updateImage for each side.
private readonly updateImageTimeoutIds: [number?, number?] = [
undefined,
undefined,
];
constructor(props: Props) {
super(props);
this.widthQuery.addListener(this.onMobileWidthChange);
this.updateFile(props.file);
import('../../lib/sw-bridge').then(({ mainAppLoaded }) => mainAppLoaded());
}
@bind
private onMobileWidthChange() {
this.setState({ mobileView: this.widthQuery.matches });
}
private onEncoderTypeChange(index: 0 | 1, newType: EncoderType): void {
this.setState({
sides: cleanSet(
this.state.sides,
`${index}.latestSettings.encoderState`,
{
type: newType,
options: encoderMap[newType].defaultOptions,
},
),
});
}
private onPreprocessorOptionsChange(
index: 0 | 1,
options: PreprocessorState,
): void {
this.setState({
sides: cleanSet(
this.state.sides,
`${index}.latestSettings.preprocessorState`,
options,
),
});
}
private onEncoderOptionsChange(index: 0 | 1, options: EncoderOptions): void {
this.setState({
sides: cleanSet(
this.state.sides,
`${index}.latestSettings.encoderState.options`,
options,
),
});
}
private updateDocumentTitle(filename: string = ''): void {
document.title = filename
? `${filename} - ${originalDocumentTitle}`
: originalDocumentTitle;
}
componentWillReceiveProps(nextProps: Props): void {
if (nextProps.file !== this.props.file) {
this.updateFile(nextProps.file);
}
}
componentWillUnmount(): void {
this.updateDocumentTitle();
}
componentDidUpdate(prevProps: Props, prevState: State): void {
const { source, sides } = this.state;
const sourceDataChanged =
// Has the source object become set/unset?
!!source !== !!prevState.source ||
// Or has the processed data changed?
(source &&
prevState.source &&
source.processed !== prevState.source.processed);
for (const [i, side] of sides.entries()) {
const prevSettings = prevState.sides[i].latestSettings;
const encoderChanged =
side.latestSettings.encoderState !== prevSettings.encoderState;
const preprocessorChanged =
side.latestSettings.preprocessorState !==
prevSettings.preprocessorState;
// The image only needs updated if the encoder/preprocessor settings have changed, or the
// source has changed.
if (sourceDataChanged || encoderChanged || preprocessorChanged) {
this.queueUpdateImage(i, {
skipPreprocessing: !sourceDataChanged && !preprocessorChanged,
});
}
}
}
private async onCopyToOtherClick(index: 0 | 1) {
const otherIndex = (index + 1) % 2;
const oldSettings = this.state.sides[otherIndex];
const newSettings = { ...this.state.sides[index] };
// Create a new object URL for the new settings. This avoids both sides sharing a URL, which
// means it can be safely revoked without impacting the other side.
if (newSettings.file)
newSettings.downloadUrl = URL.createObjectURL(newSettings.file);
this.setState({
sides: cleanSet(this.state.sides, otherIndex, newSettings),
});
const result = await this.props.showSnack('Settings copied across', {
timeout: 5000,
actions: ['undo', 'dismiss'],
});
if (result !== 'undo') return;
this.setState({
sides: cleanSet(this.state.sides, otherIndex, oldSettings),
});
}
@bind
private async onInputProcessorChange(
options: InputProcessorState,
): Promise<void> {
const source = this.state.source;
if (!source) return;
const oldRotate = source.inputProcessorState.rotate.rotate;
const newRotate = options.rotate.rotate;
const orientationChanged = oldRotate % 180 !== newRotate % 180;
const loadingCounter = this.state.loadingCounter + 1;
// Either processor is good enough here.
const processor = this.leftProcessor;
this.setState({
loadingCounter,
loading: true,
source: cleanSet(source, 'inputProcessorState', options),
});
// Abort any current encode jobs, as they're redundant now.
this.leftProcessor.abortCurrent();
this.rightProcessor.abortCurrent();
try {
const processed = await processInput(source.decoded, options, processor);
// Another file has been opened/processed before this one processed.
if (this.state.loadingCounter !== loadingCounter) return;
let newState = { ...this.state, loading: false };
newState = cleanSet(newState, 'source.processed', processed);
newState = stateForNewSourceData(newState, newState.source!);
if (orientationChanged) {
// If orientation has changed, we should flip the resize values.
for (const i of [0, 1]) {
const resizeSettings =
newState.sides[i].latestSettings.preprocessorState.resize;
newState = cleanMerge(
newState,
`sides.${i}.latestSettings.preprocessorState.resize`,
{
width: resizeSettings.height,
height: resizeSettings.width,
},
);
}
}
this.setState(newState);
} catch (err) {
if (err.name === 'AbortError') return;
console.error(err);
// Another file has been opened/processed before this one processed.
if (this.state.loadingCounter !== loadingCounter) return;
this.props.showSnack('Processing error');
this.setState({ loading: false });
}
}
@bind
private async updateFile(file: File | Fileish) {
const loadingCounter = this.state.loadingCounter + 1;
// Either processor is good enough here.
const processor = this.leftProcessor;
this.setState({ loadingCounter, loading: true });
// Abort any current encode jobs, as they're redundant now.
this.leftProcessor.abortCurrent();
this.rightProcessor.abortCurrent();
try {
let decoded: ImageData;
let vectorImage: HTMLImageElement | undefined;
// Special-case SVG. We need to avoid createImageBitmap because of
// https://bugs.chromium.org/p/chromium/issues/detail?id=606319.
// Also, we cache the HTMLImageElement so we can perform vector resizing later.
if (file.type.startsWith('image/svg+xml')) {
vectorImage = await processSvg(file);
decoded = drawableToImageData(vectorImage);
} else {
// Either processor is good enough here.
decoded = await decodeImage(file, processor);
}
const processed = await processInput(
decoded,
defaultInputProcessorState,
processor,
);
// Another file has been opened/processed before this one processed.
if (this.state.loadingCounter !== loadingCounter) return;
let newState: State = {
...this.state,
source: {
decoded,
file,
vectorImage,
processed,
inputProcessorState: defaultInputProcessorState,
},
loading: false,
};
newState = stateForNewSourceData(newState, newState.source!);
for (const i of [0, 1]) {
// Default resize values come from the image:
newState = cleanMerge(
newState,
`sides.${i}.latestSettings.preprocessorState.resize`,
{
width: processed.width,
height: processed.height,
method: vectorImage ? 'vector' : 'lanczos3',
},
);
}
this.updateDocumentTitle(file.name);
this.setState(newState);
} catch (err) {
if (err.name === 'AbortError') return;
console.error(err);
// Another file has been opened/processed before this one processed.
if (this.state.loadingCounter !== loadingCounter) return;
this.props.showSnack('Invalid image');
this.setState({ loading: false });
}
}
/**
* Debounce the heavy lifting of updateImage.
* Otherwise, the thrashing causes jank, and sometimes crashes iOS Safari.
*/
private queueUpdateImage(
index: number,
options: UpdateImageOptions = {},
): void {
// Call updateImage after this delay, unless queueUpdateImage is called again, in which case the
// timeout is reset.
const delay = 100;
clearTimeout(this.updateImageTimeoutIds[index]);
this.updateImageTimeoutIds[index] = self.setTimeout(() => {
this.updateImage(index, options).catch((err) => {
console.error(err);
});
}, delay);
}
private async updateImage(
index: number,
options: UpdateImageOptions = {},
): Promise<void> {
const { skipPreprocessing = false } = options;
const { source } = this.state;
if (!source) return;
// Each time we trigger an async encode, the counter changes.
const loadingCounter = this.state.sides[index].loadingCounter + 1;
let sides = cleanMerge(this.state.sides, index, {
loadingCounter,
loading: true,
});
this.setState({ sides });
const side = sides[index];
const settings = side.latestSettings;
let file: File | Fileish | undefined;
let preprocessed: ImageData | undefined;
let data: ImageData | undefined;
const cacheResult = this.encodeCache.match(
source.processed,
settings.preprocessorState,
settings.encoderState,
);
const processor = index === 0 ? this.leftProcessor : this.rightProcessor;
// Abort anything the processor is currently doing.
// Although the processor will abandon current tasks when a new one is called,
// we might not call another task here. Eg, we might get the result from the cache.
processor.abortCurrent();
if (cacheResult) {
({ file, preprocessed, data } = cacheResult);
} else {
try {
// Special case for identity
if (settings.encoderState.type === identity.type) {
file = source.file;
data = source.processed;
} else {
preprocessed =
skipPreprocessing && side.preprocessed
? side.preprocessed
: await preprocessImage(
source,
settings.preprocessorState,
processor,
);
file = await compressImage(
preprocessed,
settings.encoderState,
source.file.name,
processor,
);
data = await decodeImage(file, processor);
this.encodeCache.add({
data,
preprocessed,
file,
sourceData: source.processed,
encoderState: settings.encoderState,
preprocessorState: settings.preprocessorState,
});
}
} catch (err) {
if (err.name === 'AbortError') return;
this.props.showSnack(
`Processing error (type=${settings.encoderState.type}): ${err}`,
);
throw err;
}
}
const latestData = this.state.sides[index];
// If a later encode has landed before this one, return.
if (loadingCounter < latestData.loadedCounter) {
return;
}
if (latestData.downloadUrl) URL.revokeObjectURL(latestData.downloadUrl);
sides = cleanMerge(this.state.sides, index, {
file,
data,
preprocessed,
downloadUrl: URL.createObjectURL(file),
loading: sides[index].loadingCounter !== loadingCounter,
loadedCounter: loadingCounter,
encodedSettings: settings,
});
this.setState({ sides });
}
render({ onBack }: Props, { loading, sides, source, mobileView }: State) {
const [leftSide, rightSide] = sides;
const [leftImageData, rightImageData] = sides.map((i) => i.data);
const options = sides.map((side, index) => (
// tslint:disable-next-line:jsx-key
<Options
source={source}
mobileView={mobileView}
preprocessorState={side.latestSettings.preprocessorState}
encoderState={side.latestSettings.encoderState}
onEncoderTypeChange={this.onEncoderTypeChange.bind(
this,
index as 0 | 1,
)}
onEncoderOptionsChange={this.onEncoderOptionsChange.bind(
this,
index as 0 | 1,
)}
onPreprocessorOptionsChange={this.onPreprocessorOptionsChange.bind(
this,
index as 0 | 1,
)}
/>
));
const copyDirections = (mobileView
? ['down', 'up']
: ['right', 'left']) as CopyAcrossIconProps['copyDirection'][];
const results = sides.map((side, index) => (
// tslint:disable-next-line:jsx-key
<Results
downloadUrl={side.downloadUrl}
imageFile={side.file}
source={source}
loading={loading || side.loading}
copyDirection={copyDirections[index]}
onCopyToOtherClick={this.onCopyToOtherClick.bind(this, index as 0 | 1)}
buttonPosition={mobileView ? 'stack-right' : buttonPositions[index]}
>
{!mobileView
? null
: [
<ExpandIcon class={style.expandIcon} key="expand-icon" />,
`${resultTitles[index]} (${
encoderMap[side.latestSettings.encoderState.type].label
})`,
]}
</Results>
));
// For rendering, we ideally want the settings that were used to create the data, not the latest
// settings.
const leftDisplaySettings =
leftSide.encodedSettings || leftSide.latestSettings;
const rightDisplaySettings =
rightSide.encodedSettings || rightSide.latestSettings;
const leftImgContain =
leftDisplaySettings.preprocessorState.resize.enabled &&
leftDisplaySettings.preprocessorState.resize.fitMethod === 'contain';
const rightImgContain =
rightDisplaySettings.preprocessorState.resize.enabled &&
rightDisplaySettings.preprocessorState.resize.fitMethod === 'contain';
return (
<div class={style.compress}>
<Output
source={source}
mobileView={mobileView}
leftCompressed={leftImageData}
rightCompressed={rightImageData}
leftImgContain={leftImgContain}
rightImgContain={rightImgContain}
onBack={onBack}
inputProcessorState={source && source.inputProcessorState}
onInputProcessorChange={this.onInputProcessorChange}
/>
{mobileView ? (
<div class={style.options}>
<multi-panel class={style.multiPanel} open-one-only>
{results[0]}
{options[0]}
{results[1]}
{options[1]}
</multi-panel>
</div>
) : (
[
<div class={style.options} key="options0">
{options[0]}
{results[0]}
</div>,
<div class={style.options} key="options1">
{options[1]}
{results[1]}
</div>,
]
)}
</div>
);
}
}

View File

@@ -1,78 +0,0 @@
import { EncoderState } from '../../codecs/encoders';
import { Fileish } from '../../lib/initial-util';
import { shallowEqual } from '../../lib/util';
import { PreprocessorState } from '../../codecs/preprocessors';
import * as identity from '../../codecs/identity/encoder-meta';
interface CacheResult {
preprocessed: ImageData;
data: ImageData;
file: Fileish;
}
interface CacheEntry extends CacheResult {
preprocessorState: PreprocessorState;
encoderState: EncoderState;
sourceData: ImageData;
}
const SIZE = 5;
export default class ResultCache {
private readonly _entries: CacheEntry[] = [];
add(entry: CacheEntry) {
if (entry.encoderState.type === identity.type)
throw Error('Cannot cache identity encodes');
// Add the new entry to the start
this._entries.unshift(entry);
// Remove the last entry if we're now bigger than SIZE
if (this._entries.length > SIZE) this._entries.pop();
}
match(
sourceData: ImageData,
preprocessorState: PreprocessorState,
encoderState: EncoderState,
): CacheResult | undefined {
const matchingIndex = this._entries.findIndex((entry) => {
// Check for quick exits:
if (entry.sourceData !== sourceData) return false;
if (entry.encoderState.type !== encoderState.type) return false;
// Check that each set of options in the preprocessor are the same
for (const prop in preprocessorState) {
if (
!shallowEqual(
(preprocessorState as any)[prop],
(entry.preprocessorState as any)[prop],
)
)
return false;
}
// Check detailed encoder options
if (!shallowEqual(encoderState.options, entry.encoderState.options))
return false;
return true;
});
if (matchingIndex === -1) return undefined;
const matchingEntry = this._entries[matchingIndex];
if (matchingIndex !== 0) {
// Move the matched result to 1st position (LRU)
this._entries.splice(matchingIndex, 1);
this._entries.unshift(matchingEntry);
}
return {
data: matchingEntry.data,
preprocessed: matchingEntry.preprocessed,
file: matchingEntry.file,
};
}
}

View File

@@ -1,75 +0,0 @@
.compress {
width: 100%;
height: 100%;
contain: strict;
display: grid;
align-items: end;
align-content: end;
grid-template-rows: 1fr auto;
@media (min-width: 600px) {
grid-template-columns: 1fr auto;
grid-template-rows: 100%;
}
}
.options {
color: #fff;
opacity: 0.9;
font-size: 1.2rem;
display: flex;
flex-flow: column;
max-width: 400px;
margin: 0 auto;
width: calc(100% - 60px);
max-height: calc(100% - 104px);
overflow: hidden;
@media (min-width: 600px) {
max-height: calc(100% - 75px);
width: 300px;
margin: 0;
}
@media (min-width: 860px) {
max-height: calc(100% - 40px);
}
}
.multi-panel {
position: relative;
display: flex;
flex-flow: column;
overflow: hidden;
// Reorder so headings appear after content:
& > :nth-child(1) {
order: 2;
margin-bottom: 10px;
}
& > :nth-child(2) {
order: 1;
}
& > :nth-child(3) {
order: 4;
}
& > :nth-child(4) {
order: 3;
}
}
.expand-icon {
transform: rotate(180deg);
margin-left: -12px;
}
[content-expanded] .expand-icon {
transform: none;
}
:focus .expand-icon {
fill: #34B9EB;
}

View File

@@ -1,62 +0,0 @@
import * as styles from './styles.css';
/**
* A simple spinner. This custom element has no JS API. Just put it in the document, and it'll
* spin. You can configure the following using CSS custom properties:
*
* --size: Size of the spinner element (it's always square). Default: 28px.
* --color: Color of the spinner. Default: #4285f4.
* --stroke-width: Width of the stroke of the spinner. Default: 3px.
* --delay: Once the spinner enters the DOM, how long until it shows. This prevents the spinner
* appearing on the screen for short operations. Default: 300ms.
*/
export default class LoadingSpinner extends HTMLElement {
private _delayTimeout: number = 0;
constructor() {
super();
// Ideally we'd use shadow DOM here, but we're targeting browsers without shadow DOM support.
// You can't set attributes/content in a custom element constructor, so I'm waiting a microtask.
Promise.resolve().then(() => {
this.style.display = 'none';
this.innerHTML =
'' +
`<div class="${styles.spinnerContainer}">` +
`<div class="${styles.spinnerLayer}">` +
`<div class="${styles.spinnerCircleClipper} ${styles.spinnerLeft}">` +
`<div class="${styles.spinnerCircle}"></div>` +
'</div>' +
`<div class="${styles.spinnerGapPatch}">` +
`<div class="${styles.spinnerCircle}"></div>` +
'</div>' +
`<div class="${styles.spinnerCircleClipper} ${styles.spinnerRight}">` +
`<div class="${styles.spinnerCircle}"></div>` +
'</div>' +
'</div>' +
'</div>';
});
}
disconnectedCallback() {
this.style.display = 'none';
clearTimeout(this._delayTimeout);
}
connectedCallback() {
const delayStr = getComputedStyle(this).getPropertyValue('--delay').trim();
let delayNum = parseFloat(delayStr);
// If seconds…
if (/\ds$/.test(delayStr)) {
// Convert to ms.
delayNum *= 1000;
}
this._delayTimeout = self.setTimeout(() => {
this.style.display = '';
}, delayNum);
}
}
customElements.define('loading-spinner', LoadingSpinner);

View File

@@ -1,7 +0,0 @@
interface LoadingSpinner extends JSX.HTMLAttributes {}
declare namespace JSX {
interface IntrinsicElements {
'loading-spinner': LoadingSpinner;
}
}

View File

@@ -1,158 +0,0 @@
@keyframes spinner-left-spin {
from {
transform: rotate(130deg);
}
50% {
transform: rotate(-5deg);
}
to {
transform: rotate(130deg);
}
}
@keyframes spinner-right-spin {
from {
transform: rotate(-130deg);
}
50% {
transform: rotate(5deg);
}
to {
transform: rotate(-130deg);
}
}
@keyframes spinner-fade-out {
to {
opacity: 0;
}
}
@keyframes spinner-container-rotate {
to {
transform: rotate(360deg);
}
}
@keyframes spinner-fill-unfill-rotate {
12.5% {
transform: rotate(135deg);
} /* 0.5 * ARCSIZE */
25% {
transform: rotate(270deg);
} /* 1 * ARCSIZE */
37.5% {
transform: rotate(405deg);
} /* 1.5 * ARCSIZE */
50% {
transform: rotate(540deg);
} /* 2 * ARCSIZE */
62.5% {
transform: rotate(675deg);
} /* 2.5 * ARCSIZE */
75% {
transform: rotate(810deg);
} /* 3 * ARCSIZE */
87.5% {
transform: rotate(945deg);
} /* 3.5 * ARCSIZE */
to {
transform: rotate(1080deg);
} /* 4 * ARCSIZE */
}
loading-spinner {
--size: 28px;
--color: #4285f4;
--stroke-width: 3px;
--delay: 300ms;
pointer-events: none;
display: inline-block;
position: relative;
width: var(--size);
height: var(--size);
border-color: var(--color);
}
loading-spinner .spinner-circle {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
box-sizing: border-box;
height: 100%;
width: 200%;
border-width: var(--stroke-width);
border-style: solid;
border-color: inherit;
border-bottom-color: transparent !important;
border-radius: 50%;
}
/*
Patch the gap that appear between the two adjacent div.circle-clipper while the
spinner is rotating (appears on Chrome 38, Safari 7.1, and IE 11).
*/
loading-spinner .spinner-gap-patch {
position: absolute;
box-sizing: border-box;
top: 0;
left: 45%;
width: 10%;
height: 100%;
overflow: hidden;
border-color: inherit;
}
loading-spinner .spinner-gap-patch .spinner-circle {
width: 1000%;
left: -450%;
}
loading-spinner .spinner-circle-clipper {
display: inline-block;
position: relative;
width: 50%;
height: 100%;
overflow: hidden;
border-color: inherit;
}
loading-spinner .spinner-left .spinner-circle {
border-right-color: transparent !important;
transform: rotate(129deg);
animation: spinner-left-spin 1333ms cubic-bezier(0.4, 0, 0.2, 1) infinite both;
}
loading-spinner .spinner-right .spinner-circle {
left: -100%;
border-left-color: transparent !important;
transform: rotate(-129deg);
animation: spinner-right-spin 1333ms cubic-bezier(0.4, 0, 0.2, 1) infinite
both;
}
loading-spinner.spinner-fadeout {
animation: spinner-fade-out 400ms cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
loading-spinner .spinner-container {
width: 100%;
height: 100%;
border-color: inherit;
/* duration: 360 * ARCTIME / (ARCSTARTROT + (360-ARCSIZE)) */
animation: spinner-container-rotate 1568ms linear infinite;
}
loading-spinner .spinner-layer {
position: absolute;
width: 100%;
height: 100%;
border-color: inherit;
/* durations: 4 * ARCTIME */
animation: spinner-fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1)
infinite both;
}

View File

@@ -1,78 +0,0 @@
import { h, Component, ComponentChild, ComponentChildren } from 'preact';
import * as style from './style.scss';
import { transitionHeight } from '../../lib/util';
interface Props {
children: ComponentChildren;
}
interface State {
outgoingChildren: ComponentChild[];
}
export default class Expander extends Component<Props, State> {
state: State = {
outgoingChildren: [],
};
private lastElHeight: number = 0;
componentWillReceiveProps(nextProps: Props) {
const children = this.props.children as ComponentChild[];
const nextChildren = nextProps.children as ComponentChild[];
if (!nextChildren[0] && children[0]) {
// Cache the current children for the shrink animation.
this.setState({ outgoingChildren: children });
}
}
componentWillUpdate(nextProps: Props) {
const children = this.props.children as ComponentChild[];
const nextChildren = nextProps.children as ComponentChild[];
// Only interested if going from empty to not-empty, or not-empty to empty.
if ((children[0] && nextChildren[0]) || (!children[0] && !nextChildren[0]))
return;
this.lastElHeight = this.base!.getBoundingClientRect().height;
}
async componentDidUpdate(previousProps: Props) {
const children = this.props.children as ComponentChild[];
const previousChildren = previousProps.children as ComponentChild[];
// Only interested if going from empty to not-empty, or not-empty to empty.
if (
(children[0] && previousChildren[0]) ||
(!children[0] && !previousChildren[0])
)
return;
// What height do we need to transition to?
this.base!.style.height = '';
this.base!.style.overflow = 'hidden';
const newHeight = children[0]
? this.base!.getBoundingClientRect().height
: 0;
await transitionHeight(this.base!, {
duration: 300,
from: this.lastElHeight,
to: newHeight,
});
// Unset the height & overflow, so element changes do the right thing.
this.base!.style.height = '';
this.base!.style.overflow = '';
if (this.state.outgoingChildren[0]) this.setState({ outgoingChildren: [] });
}
render(props: Props, { outgoingChildren }: State) {
const children = props.children as ComponentChild[];
const childrenExiting = !children[0] && outgoingChildren[0];
return (
<div class={childrenExiting ? style.childrenExiting : ''}>
{children[0] ? children : outgoingChildren}
</div>
);
}
}

View File

@@ -1,5 +0,0 @@
.children-exiting {
& > * {
pointer-events: none;
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -1,260 +0,0 @@
import { h, Component } from 'preact';
import { bind, linkRef, Fileish } from '../../lib/initial-util';
import '../custom-els/LoadingSpinner';
import logo from './imgs/logo.svg';
import largePhoto from './imgs/demos/demo-large-photo.jpg';
import artwork from './imgs/demos/demo-artwork.jpg';
import deviceScreen from './imgs/demos/demo-device-screen.png';
import largePhotoIcon from './imgs/demos/icon-demo-large-photo.jpg';
import artworkIcon from './imgs/demos/icon-demo-artwork.jpg';
import deviceScreenIcon from './imgs/demos/icon-demo-device-screen.jpg';
import logoIcon from './imgs/demos/icon-demo-logo.png';
import * as style from './style.scss';
import SnackBarElement from '../../lib/SnackBar';
const demos = [
{
description: 'Large photo (2.8mb)',
filename: 'photo.jpg',
url: largePhoto,
iconUrl: largePhotoIcon,
},
{
description: 'Artwork (2.9mb)',
filename: 'art.jpg',
url: artwork,
iconUrl: artworkIcon,
},
{
description: 'Device screen (1.6mb)',
filename: 'pixel3.png',
url: deviceScreen,
iconUrl: deviceScreenIcon,
},
{
description: 'SVG icon (13k)',
filename: 'squoosh.svg',
url: logo,
iconUrl: logoIcon,
},
];
const installButtonSource = 'introInstallButton-Purple';
interface Props {
onFile: (file: File | Fileish) => void;
showSnack: SnackBarElement['showSnackbar'];
}
interface State {
fetchingDemoIndex?: number;
beforeInstallEvent?: BeforeInstallPromptEvent;
}
export default class Intro extends Component<Props, State> {
state: State = {};
private fileInput?: HTMLInputElement;
private installingViaButton = false;
constructor() {
super();
// Listen for beforeinstallprompt events, indicating Squoosh is installable.
window.addEventListener(
'beforeinstallprompt',
this.onBeforeInstallPromptEvent,
);
// Listen for the appinstalled event, indicating Squoosh has been installed.
window.addEventListener('appinstalled', this.onAppInstalled);
}
@bind
private resetFileInput() {
this.fileInput!.value = '';
}
@bind
private onFileChange(event: Event): void {
const fileInput = event.target as HTMLInputElement;
const file = fileInput.files && fileInput.files[0];
if (!file) return;
this.resetFileInput();
this.props.onFile(file);
}
@bind
private onButtonClick() {
this.fileInput!.click();
}
@bind
private async onDemoClick(index: number, event: Event) {
try {
this.setState({ fetchingDemoIndex: index });
const demo = demos[index];
const blob = await fetch(demo.url).then((r) => r.blob());
// Firefox doesn't like content types like 'image/png; charset=UTF-8', which Webpack's dev
// server returns. https://bugzilla.mozilla.org/show_bug.cgi?id=1497925.
const type = /[^;]*/.exec(blob.type)![0];
const file = new Fileish([blob], demo.filename, { type });
this.props.onFile(file);
} catch (err) {
this.setState({ fetchingDemoIndex: undefined });
this.props.showSnack("Couldn't fetch demo image");
}
}
@bind
private onBeforeInstallPromptEvent(event: BeforeInstallPromptEvent) {
// Don't show the mini-infobar on mobile
event.preventDefault();
// Save the beforeinstallprompt event so it can be called later.
this.setState({ beforeInstallEvent: event });
// Log the event.
const gaEventInfo = {
eventCategory: 'pwa-install',
eventAction: 'promo-shown',
nonInteraction: true,
};
ga('send', 'event', gaEventInfo);
}
@bind
private async onInstallClick(event: Event) {
// Get the deferred beforeinstallprompt event
const beforeInstallEvent = this.state.beforeInstallEvent;
// If there's no deferred prompt, bail.
if (!beforeInstallEvent) return;
this.installingViaButton = true;
// Show the browser install prompt
beforeInstallEvent.prompt();
// Wait for the user to accept or dismiss the install prompt
const { outcome } = await beforeInstallEvent.userChoice;
// Send the analytics data
const gaEventInfo = {
eventCategory: 'pwa-install',
eventAction: 'promo-clicked',
eventLabel: installButtonSource,
eventValue: outcome === 'accepted' ? 1 : 0,
};
ga('send', 'event', gaEventInfo);
// If the prompt was dismissed, we aren't going to install via the button.
if (outcome === 'dismissed') {
this.installingViaButton = false;
}
}
@bind
private onAppInstalled() {
// We don't need the install button, if it's shown
this.setState({ beforeInstallEvent: undefined });
// Don't log analytics if page is not visible
if (document.hidden) {
return;
}
// Try to get the install, if it's not set, use 'browser'
const source = this.installingViaButton ? installButtonSource : 'browser';
ga('send', 'event', 'pwa-install', 'installed', source);
// Clear the install method property
this.installingViaButton = false;
}
render({}: Props, { fetchingDemoIndex, beforeInstallEvent }: State) {
return (
<div class={style.intro}>
<div>
<div class={style.logoSizer}>
<div class={style.logoContainer}>
<img
src={logo}
class={style.logo}
alt="Squoosh"
decoding="async"
/>
</div>
</div>
<p class={style.openImageGuide}>
Drag &amp; drop or{' '}
<button class={style.selectButton} onClick={this.onButtonClick}>
select an image
</button>
<input
class={style.hide}
ref={linkRef(this, 'fileInput')}
type="file"
onChange={this.onFileChange}
/>
</p>
<p>Or try one of these:</p>
<ul class={style.demos}>
{demos.map((demo, i) => (
<li key={demo.url} class={style.demoItem}>
<button
class={style.demoButton}
onClick={this.onDemoClick.bind(this, i)}
>
<div class={style.demo}>
<div class={style.demoImgContainer}>
<div class={style.demoImgAspect}>
<img
class={style.demoIcon}
src={demo.iconUrl}
alt=""
decoding="async"
/>
{fetchingDemoIndex === i && (
<div class={style.demoLoading}>
<loading-spinner class={style.demoLoadingSpinner} />
</div>
)}
</div>
</div>
<div class={style.demoDescription}>{demo.description}</div>
</div>
</button>
</li>
))}
</ul>
</div>
{beforeInstallEvent && (
<button
type="button"
class={style.installButton}
onClick={this.onInstallClick}
>
Install
</button>
)}
<ul class={style.relatedLinks}>
<li>
<a href="https://github.com/GoogleChromeLabs/squoosh/">
View the code
</a>
</li>
<li>
<a href="https://github.com/GoogleChromeLabs/squoosh/issues">
Report a bug
</a>
</li>
<li>
<a href="https://github.com/GoogleChromeLabs/squoosh/blob/dev/README.md#privacy">
Privacy
</a>
</li>
</ul>
</div>
);
}
}

Some files were not shown because too many files have changed in this diff Show More