Removing old code, some bugfixes
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 6.7 KiB |
@@ -1,7 +0,0 @@
|
||||
export const name = 'WASM AVIF Decoder';
|
||||
|
||||
const supportedMimeTypes = ['image/avif'];
|
||||
|
||||
export function canHandleMimeType(mimeType: string): boolean {
|
||||
return supportedMimeTypes.includes(mimeType);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 can’t run on SharedArrayBuffers, so we hard-cast to ArrayBuffer.
|
||||
return result.buffer as ArrayBuffer;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -1,6 +0,0 @@
|
||||
import { mimeType } from './encoder-meta';
|
||||
import { canvasEncode } from '../../lib/util';
|
||||
|
||||
export function encode(data: ImageData) {
|
||||
return canvasEncode(data, mimeType);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -1,6 +0,0 @@
|
||||
import { mimeType } from './encoder-meta';
|
||||
import { canvasEncode } from '../../lib/util';
|
||||
|
||||
export function encode(data: ImageData) {
|
||||
return canvasEncode(data, mimeType);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -1,6 +0,0 @@
|
||||
import { mimeType } from './encoder-meta';
|
||||
import { canvasEncode } from '../../lib/util';
|
||||
|
||||
export function encode(data: ImageData) {
|
||||
return canvasEncode(data, mimeType);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import qualityOption from '../generic/quality-option';
|
||||
|
||||
export default qualityOption({ min: 0, max: 1, step: 0.01 });
|
||||
@@ -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);
|
||||
@@ -1,6 +0,0 @@
|
||||
import { mimeType } from './encoder-meta';
|
||||
import { canvasEncode } from '../../lib/util';
|
||||
|
||||
export function encode(data: ImageData) {
|
||||
return canvasEncode(data, mimeType);
|
||||
}
|
||||
@@ -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 = {};
|
||||
@@ -1,6 +0,0 @@
|
||||
import { mimeType } from './encoder-meta';
|
||||
import { canvasEncode } from '../../lib/util';
|
||||
|
||||
export function encode(data: ImageData) {
|
||||
return canvasEncode(data, mimeType);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -1,6 +0,0 @@
|
||||
import { mimeType } from './encoder-meta';
|
||||
import { canvasEncode } from '../../lib/util';
|
||||
|
||||
export function encode(data: ImageData) {
|
||||
return canvasEncode(data, mimeType);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import qualityOption from '../generic/quality-option';
|
||||
|
||||
export default qualityOption({ min: 0, max: 1, step: 0.01 });
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export interface HqxOptions {
|
||||
factor: 2 | 3 | 4;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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 = {};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
export interface QuantizeOptions {
|
||||
zx: number;
|
||||
maxNumColors: number;
|
||||
dither: number;
|
||||
}
|
||||
|
||||
export const defaultOptions: QuantizeOptions = {
|
||||
zx: 0,
|
||||
maxNumColors: 256,
|
||||
dither: 1.0,
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 can’t run on SharedArrayBuffers, so we hard-cast to ArrayBuffer.
|
||||
return resultView.buffer as ArrayBuffer;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
|
Before Width: | Height: | Size: 303 B |
|
Before Width: | Height: | Size: 38 B |
@@ -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": "."
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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 let’s 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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export const name = 'WASM WebP Decoder';
|
||||
|
||||
const supportedMimeTypes = ['image/webp'];
|
||||
|
||||
export function canHandleMimeType(mimeType: string): boolean {
|
||||
return supportedMimeTypes.includes(mimeType);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 can’t run on SharedArrayBuffers, so we hard-cast to ArrayBuffer.
|
||||
return result.buffer as ArrayBuffer;
|
||||
}
|
||||
@@ -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 RGB→YUV 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
// don’t 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);
|
||||
@@ -1,9 +0,0 @@
|
||||
interface MultiPanelAttributes extends JSX.HTMLAttributes {
|
||||
'open-one-only'?: boolean;
|
||||
}
|
||||
|
||||
declare namespace JSX {
|
||||
interface IntrinsicElements {
|
||||
'multi-panel': MultiPanelAttributes;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
.panel-heading {
|
||||
background: gray;
|
||||
}
|
||||
.panel-content {
|
||||
height: 0px;
|
||||
overflow: auto;
|
||||
}
|
||||
.panel-content[aria-expanded='true'] {
|
||||
height: auto;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -1,7 +0,0 @@
|
||||
interface LoadingSpinner extends JSX.HTMLAttributes {}
|
||||
|
||||
declare namespace JSX {
|
||||
interface IntrinsicElements {
|
||||
'loading-spinner': LoadingSpinner;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
.children-exiting {
|
||||
& > * {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 2.8 MiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 2.7 MiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 13 KiB |
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
}
|
||||