mirror of
https://github.com/GoogleChromeLabs/squoosh.git
synced 2025-11-12 08:47:31 +00:00
Lazy-loading the main part of the app (#197)
* Splitting main part of app out of the main bundle. Also improving the transition from intro to compressor. * Showing error if app fails to load. * lol these aren't async * Please don't tell anyone I did this * Spinner if user selects a file before the app has loaded. (#208)
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { h, Component } from 'preact';
|
import { h, Component } from 'preact';
|
||||||
import { bind } from '../../lib/util';
|
import { bind } from '../../lib/initial-util';
|
||||||
import '../../custom-els/RangeInput';
|
import '../../custom-els/RangeInput';
|
||||||
|
|
||||||
interface EncodeOptions {
|
interface EncodeOptions {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { h, Component } from 'preact';
|
import { h, Component } from 'preact';
|
||||||
import { bind, inputFieldValueAsNumber, konami } from '../../lib/util';
|
import { bind } from '../../lib/initial-util';
|
||||||
|
import { inputFieldValueAsNumber, konami } from '../../lib/util';
|
||||||
import { QuantizeOptions } from './quantizer';
|
import { QuantizeOptions } from './quantizer';
|
||||||
|
|
||||||
const konamiPromise = konami();
|
const konamiPromise = konami();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { h, Component } from 'preact';
|
import { h, Component } from 'preact';
|
||||||
import { bind, inputFieldChecked, inputFieldValueAsNumber } from '../../lib/util';
|
import { bind } from '../../lib/initial-util';
|
||||||
|
import { inputFieldChecked, inputFieldValueAsNumber } from '../../lib/util';
|
||||||
import { EncodeOptions, MozJpegColorSpace } from './encoder';
|
import { EncodeOptions, MozJpegColorSpace } from './encoder';
|
||||||
import '../../custom-els/RangeInput';
|
import '../../custom-els/RangeInput';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { h, Component } from 'preact';
|
import { h, Component } from 'preact';
|
||||||
import { bind, inputFieldValueAsNumber } from '../../lib/util';
|
import { bind } from '../../lib/initial-util';
|
||||||
|
import { inputFieldValueAsNumber } from '../../lib/util';
|
||||||
import { EncodeOptions } from './encoder';
|
import { EncodeOptions } from './encoder';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { h, Component } from 'preact';
|
import { h, Component } from 'preact';
|
||||||
import linkState from 'linkstate';
|
import linkState from 'linkstate';
|
||||||
import { bind, inputFieldValueAsNumber } from '../../lib/util';
|
import { bind } from '../../lib/initial-util';
|
||||||
|
import { inputFieldValueAsNumber } from '../../lib/util';
|
||||||
import { ResizeOptions } from './resize';
|
import { ResizeOptions } from './resize';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { h, Component } from 'preact';
|
import { h, Component } from 'preact';
|
||||||
import { bind, inputFieldCheckedAsNumber, inputFieldValueAsNumber } from '../../lib/util';
|
import { bind } from '../../lib/initial-util';
|
||||||
|
import { inputFieldCheckedAsNumber, inputFieldValueAsNumber } from '../../lib/util';
|
||||||
import { EncodeOptions, WebPImageHint } from './encoder';
|
import { EncodeOptions, WebPImageHint } from './encoder';
|
||||||
import * as styles from './styles.scss';
|
import * as styles from './styles.scss';
|
||||||
import '../../custom-els/RangeInput';
|
import '../../custom-els/RangeInput';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { bind } from '../../../../lib/util';
|
import { bind } from '../../../../lib/initial-util';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
// tslint:disable-next-line:max-line-length
|
// tslint:disable-next-line:max-line-length
|
||||||
|
|||||||
@@ -1,46 +1,16 @@
|
|||||||
import { h, Component } from 'preact';
|
import { h, Component } from 'preact';
|
||||||
|
|
||||||
import { bind, linkRef, Fileish, blobToImg, drawableToImageData, blobToText } from '../../lib/util';
|
import { bind, linkRef, Fileish } from '../../lib/initial-util';
|
||||||
import * as style from './style.scss';
|
import * as style from './style.scss';
|
||||||
import Output from '../Output';
|
|
||||||
import Options from '../Options';
|
|
||||||
import { FileDropEvent } from './custom-els/FileDrop';
|
import { FileDropEvent } from './custom-els/FileDrop';
|
||||||
import './custom-els/FileDrop';
|
import './custom-els/FileDrop';
|
||||||
import ResultCache from './result-cache';
|
|
||||||
|
|
||||||
import * as quantizer from '../../codecs/imagequant/quantizer';
|
|
||||||
import * as optiPNG from '../../codecs/optipng/encoder';
|
|
||||||
import * as resizer from '../../codecs/resize/resize';
|
|
||||||
import * as mozJPEG from '../../codecs/mozjpeg/encoder';
|
|
||||||
import * as webP from '../../codecs/webp/encoder';
|
|
||||||
import * as identity from '../../codecs/identity/encoder';
|
|
||||||
import * as browserPNG from '../../codecs/browser-png/encoder';
|
|
||||||
import * as browserJPEG from '../../codecs/browser-jpeg/encoder';
|
|
||||||
import * as browserWebP from '../../codecs/browser-webp/encoder';
|
|
||||||
import * as browserGIF from '../../codecs/browser-gif/encoder';
|
|
||||||
import * as browserTIFF from '../../codecs/browser-tiff/encoder';
|
|
||||||
import * as browserJP2 from '../../codecs/browser-jp2/encoder';
|
|
||||||
import * as browserBMP from '../../codecs/browser-bmp/encoder';
|
|
||||||
import * as browserPDF from '../../codecs/browser-pdf/encoder';
|
|
||||||
import {
|
|
||||||
EncoderState,
|
|
||||||
EncoderType,
|
|
||||||
EncoderOptions,
|
|
||||||
encoderMap,
|
|
||||||
} from '../../codecs/encoders';
|
|
||||||
import SnackBarElement from '../../lib/SnackBar';
|
import SnackBarElement from '../../lib/SnackBar';
|
||||||
import '../../lib/SnackBar';
|
import '../../lib/SnackBar';
|
||||||
|
|
||||||
import {
|
|
||||||
PreprocessorState,
|
|
||||||
defaultPreprocessorState,
|
|
||||||
} from '../../codecs/preprocessors';
|
|
||||||
|
|
||||||
import { decodeImage } from '../../codecs/decoders';
|
|
||||||
import { cleanMerge, cleanSet } from '../../lib/clean-modify';
|
|
||||||
import Intro from '../intro';
|
import Intro from '../intro';
|
||||||
|
import '../custom-els/LoadingSpinner';
|
||||||
|
|
||||||
type Orientation = 'horizontal' | 'vertical';
|
// This is imported for TypeScript only. It isn't used.
|
||||||
|
import Compress from '../compress';
|
||||||
|
|
||||||
export interface SourceImage {
|
export interface SourceImage {
|
||||||
file: File | Fileish;
|
file: File | Fileish;
|
||||||
@@ -48,140 +18,30 @@ export interface SourceImage {
|
|||||||
vectorImage?: HTMLImageElement;
|
vectorImage?: HTMLImageElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EncodedImage {
|
|
||||||
preprocessed?: ImageData;
|
|
||||||
file?: Fileish;
|
|
||||||
downloadUrl?: string;
|
|
||||||
data?: ImageData;
|
|
||||||
preprocessorState: PreprocessorState;
|
|
||||||
encoderState: EncoderState;
|
|
||||||
loading: boolean;
|
|
||||||
/** Counter of the latest bmp currently encoding */
|
|
||||||
loadingCounter: number;
|
|
||||||
/** Counter of the latest bmp encoded */
|
|
||||||
loadedCounter: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {}
|
interface Props {}
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
source?: SourceImage;
|
file?: File | Fileish;
|
||||||
images: [EncodedImage, EncodedImage];
|
Compress?: typeof Compress;
|
||||||
loading: boolean;
|
|
||||||
error?: string;
|
|
||||||
orientation: Orientation;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UpdateImageOptions {
|
|
||||||
skipPreprocessing?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function preprocessImage(
|
|
||||||
source: SourceImage,
|
|
||||||
preprocessData: PreprocessorState,
|
|
||||||
): Promise<ImageData> {
|
|
||||||
let result = source.data;
|
|
||||||
if (preprocessData.resize.enabled) {
|
|
||||||
if (preprocessData.resize.method === 'vector' && source.vectorImage) {
|
|
||||||
result = resizer.vectorResize(
|
|
||||||
source.vectorImage,
|
|
||||||
preprocessData.resize as resizer.VectorResizeOptions,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
result = resizer.resize(result, preprocessData.resize as resizer.BitmapResizeOptions);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (preprocessData.quantizer.enabled) {
|
|
||||||
result = await quantizer.quantize(result, preprocessData.quantizer);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function compressImage(
|
|
||||||
image: ImageData,
|
|
||||||
encodeData: EncoderState,
|
|
||||||
sourceFilename: string,
|
|
||||||
): Promise<Fileish> {
|
|
||||||
const compressedData = await (() => {
|
|
||||||
switch (encodeData.type) {
|
|
||||||
case optiPNG.type: return optiPNG.encode(image, encodeData.options);
|
|
||||||
case mozJPEG.type: return mozJPEG.encode(image, encodeData.options);
|
|
||||||
case webP.type: return webP.encode(image, encodeData.options);
|
|
||||||
case browserPNG.type: return browserPNG.encode(image, encodeData.options);
|
|
||||||
case browserJPEG.type: return browserJPEG.encode(image, encodeData.options);
|
|
||||||
case browserWebP.type: return browserWebP.encode(image, encodeData.options);
|
|
||||||
case browserGIF.type: return browserGIF.encode(image, encodeData.options);
|
|
||||||
case browserTIFF.type: return browserTIFF.encode(image, encodeData.options);
|
|
||||||
case browserJP2.type: return browserJP2.encode(image, encodeData.options);
|
|
||||||
case browserBMP.type: return browserBMP.encode(image, encodeData.options);
|
|
||||||
case browserPDF.type: return browserPDF.encode(image, encodeData.options);
|
|
||||||
default: throw Error(`Unexpected encoder ${JSON.stringify(encodeData)}`);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
const encoder = encoderMap[encodeData.type];
|
|
||||||
|
|
||||||
return new Fileish(
|
|
||||||
[compressedData],
|
|
||||||
sourceFilename.replace(/.[^.]*$/, `.${encoder.extension}`),
|
|
||||||
{ type: encoder.mimeType },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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' }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class App extends Component<Props, State> {
|
export default class App extends Component<Props, State> {
|
||||||
widthQuery = window.matchMedia('(min-width: 500px)');
|
|
||||||
|
|
||||||
state: State = {
|
state: State = {
|
||||||
loading: false,
|
file: undefined,
|
||||||
images: [
|
Compress: undefined,
|
||||||
{
|
|
||||||
preprocessorState: defaultPreprocessorState,
|
|
||||||
encoderState: { type: identity.type, options: identity.defaultOptions },
|
|
||||||
loadingCounter: 0,
|
|
||||||
loadedCounter: 0,
|
|
||||||
loading: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
preprocessorState: defaultPreprocessorState,
|
|
||||||
encoderState: { type: mozJPEG.type, options: mozJPEG.defaultOptions },
|
|
||||||
loadingCounter: 0,
|
|
||||||
loadedCounter: 0,
|
|
||||||
loading: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
orientation: this.widthQuery.matches ? 'horizontal' : 'vertical',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
snackbar?: SnackBarElement;
|
snackbar?: SnackBarElement;
|
||||||
readonly encodeCache = new ResultCache();
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
|
import('../compress').then((module) => {
|
||||||
|
this.setState({ Compress: module.default });
|
||||||
|
}).catch(() => {
|
||||||
|
this.showError('Failed to load app');
|
||||||
|
});
|
||||||
|
|
||||||
// In development, persist application state across hot reloads:
|
// In development, persist application state across hot reloads:
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
this.setState(window.STATE);
|
this.setState(window.STATE);
|
||||||
@@ -191,232 +51,39 @@ export default class App extends Component<Props, State> {
|
|||||||
window.STATE = this.state;
|
window.STATE = this.state;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
this.widthQuery.addListener(this.onMobileWidthChange);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@bind
|
@bind
|
||||||
onMobileWidthChange() {
|
private onFileDrop(event: FileDropEvent) {
|
||||||
this.setState({ orientation: this.widthQuery.matches ? 'horizontal' : 'vertical' });
|
|
||||||
}
|
|
||||||
|
|
||||||
onEncoderTypeChange(index: 0 | 1, newType: EncoderType): void {
|
|
||||||
this.setState({
|
|
||||||
images: cleanSet(this.state.images, `${index}.encoderState`, {
|
|
||||||
type: newType,
|
|
||||||
options: encoderMap[newType].defaultOptions,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onPreprocessorOptionsChange(index: 0 | 1, options: PreprocessorState): void {
|
|
||||||
this.setState({
|
|
||||||
images: cleanSet(this.state.images, `${index}.preprocessorState`, options),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onEncoderOptionsChange(index: 0 | 1, options: EncoderOptions): void {
|
|
||||||
this.setState({
|
|
||||||
images: cleanSet(this.state.images, `${index}.encoderState.options`, options),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps: Props, prevState: State): void {
|
|
||||||
const { source, images } = this.state;
|
|
||||||
|
|
||||||
for (const [i, image] of images.entries()) {
|
|
||||||
const prevImage = prevState.images[i];
|
|
||||||
const sourceChanged = source !== prevState.source;
|
|
||||||
const encoderChanged = image.encoderState !== prevImage.encoderState;
|
|
||||||
const preprocessorChanged = image.preprocessorState !== prevImage.preprocessorState;
|
|
||||||
|
|
||||||
// The image only needs updated if the encoder/preprocessor settings have changed, or the
|
|
||||||
// source has changed.
|
|
||||||
if (sourceChanged || encoderChanged || preprocessorChanged) {
|
|
||||||
if (prevImage.downloadUrl) URL.revokeObjectURL(prevImage.downloadUrl);
|
|
||||||
this.updateImage(i, {
|
|
||||||
skipPreprocessing: !sourceChanged && !preprocessorChanged,
|
|
||||||
}).catch((err) => {
|
|
||||||
console.error(err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@bind
|
|
||||||
async onFileDrop(event: FileDropEvent) {
|
|
||||||
const { file } = event;
|
const { file } = event;
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
await this.updateFile(file);
|
this.setState({ file });
|
||||||
}
|
|
||||||
|
|
||||||
onCopyToOtherClick(index: 0 | 1) {
|
|
||||||
const otherIndex = (index + 1) % 2;
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
images: cleanSet(this.state.images, otherIndex, this.state.images[index]),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@bind
|
@bind
|
||||||
async updateFile(file: File | Fileish) {
|
private onIntroPickFile(file: File | Fileish) {
|
||||||
this.setState({ loading: true });
|
this.setState({ file });
|
||||||
try {
|
|
||||||
let data: 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);
|
|
||||||
data = drawableToImageData(vectorImage);
|
|
||||||
} else {
|
|
||||||
data = await decodeImage(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
let newState: State = {
|
|
||||||
...this.state,
|
|
||||||
source: { data, file, vectorImage },
|
|
||||||
loading: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Default resize values come from the image:
|
|
||||||
for (const i of [0, 1]) {
|
|
||||||
newState = cleanMerge(newState, `images.${i}.preprocessorState.resize`, {
|
|
||||||
width: data.width,
|
|
||||||
height: data.height,
|
|
||||||
method: vectorImage ? 'vector' : 'browser-high',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setState(newState);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
this.showError('Invalid image');
|
|
||||||
this.setState({ loading: false });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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.images[index].loadingCounter + 1;
|
|
||||||
|
|
||||||
let images = cleanMerge(this.state.images, index, {
|
|
||||||
loadingCounter,
|
|
||||||
loading: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.setState({ images });
|
|
||||||
|
|
||||||
const image = images[index];
|
|
||||||
|
|
||||||
let file: File | Fileish | undefined;
|
|
||||||
let preprocessed: ImageData | undefined;
|
|
||||||
let data: ImageData | undefined;
|
|
||||||
const cacheResult = this.encodeCache.match(source, image.preprocessorState, image.encoderState);
|
|
||||||
|
|
||||||
if (cacheResult) {
|
|
||||||
({ file, preprocessed, data } = cacheResult);
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
// Special case for identity
|
|
||||||
if (image.encoderState.type === identity.type) {
|
|
||||||
({ file, data } = source);
|
|
||||||
} else {
|
|
||||||
preprocessed = (skipPreprocessing && image.preprocessed)
|
|
||||||
? image.preprocessed
|
|
||||||
: await preprocessImage(source, image.preprocessorState);
|
|
||||||
|
|
||||||
file = await compressImage(preprocessed, image.encoderState, source.file.name);
|
|
||||||
data = await decodeImage(file);
|
|
||||||
|
|
||||||
this.encodeCache.add({
|
|
||||||
source,
|
|
||||||
data,
|
|
||||||
preprocessed,
|
|
||||||
file,
|
|
||||||
encoderState: image.encoderState,
|
|
||||||
preprocessorState: image.preprocessorState,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
this.showError(`Processing error (type=${image.encoderState.type}): ${err}`);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const latestImage = this.state.images[index];
|
|
||||||
// If a later encode has landed before this one, return.
|
|
||||||
if (loadingCounter < latestImage.loadedCounter) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
images = cleanMerge(this.state.images, index, {
|
|
||||||
file,
|
|
||||||
data,
|
|
||||||
preprocessed,
|
|
||||||
downloadUrl: URL.createObjectURL(file),
|
|
||||||
loading: images[index].loadingCounter !== loadingCounter,
|
|
||||||
loadedCounter: loadingCounter,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.setState({ images });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@bind
|
@bind
|
||||||
showError (error: string) {
|
private showError(error: string) {
|
||||||
if (!this.snackbar) throw Error('Snackbar missing');
|
if (!this.snackbar) throw Error('Snackbar missing');
|
||||||
this.snackbar.showSnackbar({ message: error });
|
this.snackbar.showSnackbar({ message: error });
|
||||||
}
|
}
|
||||||
|
|
||||||
render({ }: Props, { loading, images, source, orientation }: State) {
|
render({}: Props, { file, Compress }: State) {
|
||||||
const [leftImage, rightImage] = images;
|
|
||||||
const [leftImageData, rightImageData] = images.map(i => i.data);
|
|
||||||
const anyLoading = loading || images.some(image => image.loading);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<file-drop accept="image/*" onfiledrop={this.onFileDrop}>
|
<div id="app" class={style.app}>
|
||||||
<div id="app" class={`${style.app} ${style[orientation]}`}>
|
<file-drop accept="image/*" onfiledrop={this.onFileDrop}>
|
||||||
{source
|
{(!file)
|
||||||
?
|
? <Intro onFile={this.onIntroPickFile} onError={this.showError} />
|
||||||
<div class={`${style.optionPair} ${style[orientation]}`}>
|
: (Compress)
|
||||||
<Output
|
? <Compress file={file} onError={this.showError} />
|
||||||
orientation={orientation}
|
: <loading-spinner class={style.appLoader}/>
|
||||||
imgWidth={source.data.width}
|
|
||||||
imgHeight={source.data.height}
|
|
||||||
leftImg={leftImageData || source.data}
|
|
||||||
rightImg={rightImageData || source.data}
|
|
||||||
leftImgContain={leftImage.preprocessorState.resize.fitMethod === 'cover'}
|
|
||||||
rightImgContain={rightImage.preprocessorState.resize.fitMethod === 'cover'}
|
|
||||||
/>
|
|
||||||
{images.map((image, index) => (
|
|
||||||
<Options
|
|
||||||
source={source}
|
|
||||||
orientation={orientation}
|
|
||||||
imageIndex={index}
|
|
||||||
imageFile={image.file}
|
|
||||||
downloadUrl={image.downloadUrl}
|
|
||||||
preprocessorState={image.preprocessorState}
|
|
||||||
encoderState={image.encoderState}
|
|
||||||
onEncoderTypeChange={this.onEncoderTypeChange.bind(this, index)}
|
|
||||||
onEncoderOptionsChange={this.onEncoderOptionsChange.bind(this, index)}
|
|
||||||
onPreprocessorOptionsChange={this.onPreprocessorOptionsChange.bind(this, index)}
|
|
||||||
onCopyToOtherClick={this.onCopyToOtherClick.bind(this, index)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
:
|
|
||||||
<Intro onFile={this.updateFile} onError={this.showError} />
|
|
||||||
}
|
}
|
||||||
{anyLoading && <span style={{ position: 'fixed', top: 0, left: 0 }}>Loading...</span>}
|
|
||||||
<snack-bar ref={linkRef(this, 'snackbar')} />
|
<snack-bar ref={linkRef(this, 'snackbar')} />
|
||||||
</div>
|
</file-drop>
|
||||||
</file-drop>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,3 +69,12 @@ Note: These styles are temporary. They will be replaced before going live.
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-loader {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
--size: 225px;
|
||||||
|
--stroke-width: 26px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { h, Component } from 'preact';
|
import { h, Component } from 'preact';
|
||||||
|
|
||||||
import * as style from './style.scss';
|
import * as style from './style.scss';
|
||||||
import { bind, Fileish } from '../../lib/util';
|
import { bind, Fileish } from '../../lib/initial-util';
|
||||||
import { cleanSet, cleanMerge } from '../../lib/clean-modify';
|
import { cleanSet, cleanMerge } from '../../lib/clean-modify';
|
||||||
import OptiPNGEncoderOptions from '../../codecs/optipng/options';
|
import OptiPNGEncoderOptions from '../../codecs/optipng/options';
|
||||||
import MozJpegEncoderOptions from '../../codecs/mozjpeg/options';
|
import MozJpegEncoderOptions from '../../codecs/mozjpeg/options';
|
||||||
@@ -63,7 +63,7 @@ const titles = {
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
orientation: 'horizontal' | 'vertical';
|
orientation: 'horizontal' | 'vertical';
|
||||||
source: SourceImage;
|
source?: SourceImage;
|
||||||
imageIndex: number;
|
imageIndex: number;
|
||||||
imageFile?: Fileish;
|
imageFile?: Fileish;
|
||||||
downloadUrl?: string;
|
downloadUrl?: string;
|
||||||
@@ -177,8 +177,8 @@ export default class Options extends Component<Props, State> {
|
|||||||
</label>
|
</label>
|
||||||
{preprocessorState.resize.enabled &&
|
{preprocessorState.resize.enabled &&
|
||||||
<ResizeOptionsComponent
|
<ResizeOptionsComponent
|
||||||
isVector={Boolean(source.vectorImage)}
|
isVector={Boolean(source && source.vectorImage)}
|
||||||
aspect={source.data.width / source.data.height}
|
aspect={source ? (source.data.width / source.data.height) : 1}
|
||||||
options={preprocessorState.resize}
|
options={preprocessorState.resize}
|
||||||
onChange={this.onResizeOptionsChange}
|
onChange={this.onResizeOptionsChange}
|
||||||
/>
|
/>
|
||||||
@@ -223,7 +223,7 @@ export default class Options extends Component<Props, State> {
|
|||||||
increaseClass={style.increase}
|
increaseClass={style.increase}
|
||||||
decreaseClass={style.decrease}
|
decreaseClass={style.decrease}
|
||||||
file={imageFile}
|
file={imageFile}
|
||||||
compareTo={imageFile === source.file ? undefined : source.file}
|
compareTo={(source && imageFile !== source.file) ? source.file : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{(downloadUrl && imageFile) && (
|
{(downloadUrl && imageFile) && (
|
||||||
|
|||||||
@@ -272,10 +272,7 @@ export default class PinchZoom extends HTMLElement {
|
|||||||
private _stageElChange() {
|
private _stageElChange() {
|
||||||
this._positioningEl = undefined;
|
this._positioningEl = undefined;
|
||||||
|
|
||||||
if (this.children.length === 0) {
|
if (this.children.length === 0) return;
|
||||||
console.warn('There should be at least one child in <pinch-zoom>.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._positioningEl = this.children[0];
|
this._positioningEl = this.children[0];
|
||||||
|
|
||||||
|
|||||||
@@ -3,16 +3,16 @@ import PinchZoom, { ScaleToOpts } from './custom-els/PinchZoom';
|
|||||||
import './custom-els/PinchZoom';
|
import './custom-els/PinchZoom';
|
||||||
import './custom-els/TwoUp';
|
import './custom-els/TwoUp';
|
||||||
import * as style from './style.scss';
|
import * as style from './style.scss';
|
||||||
import { bind, shallowEqual, drawDataToCanvas, linkRef } from '../../lib/util';
|
import { bind, linkRef } from '../../lib/initial-util';
|
||||||
|
import { shallowEqual, drawDataToCanvas } from '../../lib/util';
|
||||||
import { ToggleIcon, AddIcon, RemoveIcon } from '../../lib/icons';
|
import { ToggleIcon, AddIcon, RemoveIcon } from '../../lib/icons';
|
||||||
import { twoUpHandle } from './custom-els/TwoUp/styles.css';
|
import { twoUpHandle } from './custom-els/TwoUp/styles.css';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
originalImage?: ImageData;
|
||||||
orientation: 'horizontal' | 'vertical';
|
orientation: 'horizontal' | 'vertical';
|
||||||
leftImg: ImageData;
|
leftCompressed?: ImageData;
|
||||||
rightImg: ImageData;
|
rightCompressed?: ImageData;
|
||||||
imgWidth: number;
|
|
||||||
imgHeight: number;
|
|
||||||
leftImgContain: boolean;
|
leftImgContain: boolean;
|
||||||
rightImgContain: boolean;
|
rightImgContain: boolean;
|
||||||
}
|
}
|
||||||
@@ -44,20 +44,38 @@ export default class Output extends Component<Props, State> {
|
|||||||
retargetedEvents = new WeakSet<Event>();
|
retargetedEvents = new WeakSet<Event>();
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
if (this.canvasLeft) {
|
const leftDraw = this.leftDrawable();
|
||||||
drawDataToCanvas(this.canvasLeft, this.props.leftImg);
|
const rightDraw = this.rightDrawable();
|
||||||
|
|
||||||
|
if (this.canvasLeft && leftDraw) {
|
||||||
|
drawDataToCanvas(this.canvasLeft, leftDraw);
|
||||||
}
|
}
|
||||||
if (this.canvasRight) {
|
if (this.canvasRight && rightDraw) {
|
||||||
drawDataToCanvas(this.canvasRight, this.props.rightImg);
|
drawDataToCanvas(this.canvasRight, rightDraw);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps: Props, prevState: State) {
|
componentDidUpdate(prevProps: Props, prevState: State) {
|
||||||
if (prevProps.leftImg !== this.props.leftImg && this.canvasLeft) {
|
const prevLeftDraw = this.leftDrawable(prevProps);
|
||||||
drawDataToCanvas(this.canvasLeft, this.props.leftImg);
|
const prevRightDraw = this.rightDrawable(prevProps);
|
||||||
|
const leftDraw = this.leftDrawable();
|
||||||
|
const rightDraw = this.rightDrawable();
|
||||||
|
|
||||||
|
if (leftDraw && leftDraw !== prevLeftDraw && this.canvasLeft) {
|
||||||
|
drawDataToCanvas(this.canvasLeft, leftDraw);
|
||||||
}
|
}
|
||||||
if (prevProps.rightImg !== this.props.rightImg && this.canvasRight) {
|
if (rightDraw && rightDraw !== prevRightDraw && this.canvasRight) {
|
||||||
drawDataToCanvas(this.canvasRight, this.props.rightImg);
|
drawDataToCanvas(this.canvasRight, rightDraw);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.props.originalImage !== prevProps.originalImage && this.pinchZoomLeft) {
|
||||||
|
// New image? Reset the pinch-zoom.
|
||||||
|
this.pinchZoomLeft.setTransform({
|
||||||
|
allowChangeEvent: true,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
scale: 1,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,41 +83,49 @@ export default class Output extends Component<Props, State> {
|
|||||||
return !shallowEqual(this.props, nextProps) || !shallowEqual(this.state, nextState);
|
return !shallowEqual(this.props, nextProps) || !shallowEqual(this.state, nextState);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private leftDrawable(props: Props = this.props): ImageData | undefined {
|
||||||
|
return props.leftCompressed || props.originalImage;
|
||||||
|
}
|
||||||
|
|
||||||
|
private rightDrawable(props: Props = this.props): ImageData | undefined {
|
||||||
|
return props.rightCompressed || props.originalImage;
|
||||||
|
}
|
||||||
|
|
||||||
@bind
|
@bind
|
||||||
toggleBackground() {
|
private toggleBackground() {
|
||||||
this.setState({
|
this.setState({
|
||||||
altBackground: !this.state.altBackground,
|
altBackground: !this.state.altBackground,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@bind
|
@bind
|
||||||
zoomIn() {
|
private zoomIn() {
|
||||||
if (!this.pinchZoomLeft) throw Error('Missing pinch-zoom element');
|
if (!this.pinchZoomLeft) throw Error('Missing pinch-zoom element');
|
||||||
|
|
||||||
this.pinchZoomLeft.scaleTo(this.state.scale * 1.25, scaleToOpts);
|
this.pinchZoomLeft.scaleTo(this.state.scale * 1.25, scaleToOpts);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bind
|
@bind
|
||||||
zoomOut() {
|
private zoomOut() {
|
||||||
if (!this.pinchZoomLeft) throw Error('Missing pinch-zoom element');
|
if (!this.pinchZoomLeft) throw Error('Missing pinch-zoom element');
|
||||||
|
|
||||||
this.pinchZoomLeft.scaleTo(this.state.scale / 1.25, scaleToOpts);
|
this.pinchZoomLeft.scaleTo(this.state.scale / 1.25, scaleToOpts);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bind
|
@bind
|
||||||
editScale() {
|
private editScale() {
|
||||||
this.setState({ editingScale: true }, () => {
|
this.setState({ editingScale: true }, () => {
|
||||||
if (this.scaleInput) this.scaleInput.focus();
|
if (this.scaleInput) this.scaleInput.focus();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@bind
|
@bind
|
||||||
cancelEditScale() {
|
private cancelEditScale() {
|
||||||
this.setState({ editingScale: false });
|
this.setState({ editingScale: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
@bind
|
@bind
|
||||||
onScaleInputChanged(event: Event) {
|
private onScaleInputChanged(event: Event) {
|
||||||
const target = event.target as HTMLInputElement;
|
const target = event.target as HTMLInputElement;
|
||||||
const percent = parseFloat(target.value);
|
const percent = parseFloat(target.value);
|
||||||
if (isNaN(percent)) return;
|
if (isNaN(percent)) return;
|
||||||
@@ -109,7 +135,7 @@ export default class Output extends Component<Props, State> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@bind
|
@bind
|
||||||
onPinchZoomLeftChange(event: Event) {
|
private onPinchZoomLeftChange(event: Event) {
|
||||||
if (!this.pinchZoomRight || !this.pinchZoomLeft) throw Error('Missing pinch-zoom element');
|
if (!this.pinchZoomRight || !this.pinchZoomLeft) throw Error('Missing pinch-zoom element');
|
||||||
this.setState({
|
this.setState({
|
||||||
scale: this.pinchZoomLeft.scale,
|
scale: this.pinchZoomLeft.scale,
|
||||||
@@ -130,7 +156,7 @@ export default class Output extends Component<Props, State> {
|
|||||||
* @param event Event to redirect
|
* @param event Event to redirect
|
||||||
*/
|
*/
|
||||||
@bind
|
@bind
|
||||||
onRetargetableEvent(event: Event) {
|
private onRetargetableEvent(event: Event) {
|
||||||
const targetEl = event.target as HTMLElement;
|
const targetEl = event.target as HTMLElement;
|
||||||
if (!this.pinchZoomLeft) throw Error('Missing pinch-zoom element');
|
if (!this.pinchZoomLeft) throw Error('Missing pinch-zoom element');
|
||||||
// If the event is on the handle of the two-up, let it through,
|
// If the event is on the handle of the two-up, let it through,
|
||||||
@@ -149,9 +175,15 @@ export default class Output extends Component<Props, State> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render(
|
render(
|
||||||
{ orientation, leftImg, rightImg, imgWidth, imgHeight, leftImgContain, rightImgContain }: Props,
|
{
|
||||||
|
orientation, leftCompressed, rightCompressed, leftImgContain, rightImgContain,
|
||||||
|
originalImage,
|
||||||
|
}: Props,
|
||||||
{ scale, editingScale, altBackground }: State,
|
{ scale, editingScale, altBackground }: State,
|
||||||
) {
|
) {
|
||||||
|
const leftDraw = this.leftDrawable();
|
||||||
|
const rightDraw = this.rightDrawable();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={`${style.output} ${altBackground ? style.altBackground : ''}`}>
|
<div class={`${style.output} ${altBackground ? style.altBackground : ''}`}>
|
||||||
<two-up
|
<two-up
|
||||||
@@ -172,11 +204,11 @@ export default class Output extends Component<Props, State> {
|
|||||||
<canvas
|
<canvas
|
||||||
class={style.outputCanvas}
|
class={style.outputCanvas}
|
||||||
ref={linkRef(this, 'canvasLeft')}
|
ref={linkRef(this, 'canvasLeft')}
|
||||||
width={leftImg.width}
|
width={leftDraw && leftDraw.width}
|
||||||
height={leftImg.height}
|
height={leftDraw && leftDraw.height}
|
||||||
style={{
|
style={{
|
||||||
width: imgWidth,
|
width: originalImage && originalImage.width,
|
||||||
height: imgHeight,
|
height: originalImage && originalImage.height,
|
||||||
objectFit: leftImgContain ? 'contain' : '',
|
objectFit: leftImgContain ? 'contain' : '',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -185,11 +217,11 @@ export default class Output extends Component<Props, State> {
|
|||||||
<canvas
|
<canvas
|
||||||
class={style.outputCanvas}
|
class={style.outputCanvas}
|
||||||
ref={linkRef(this, 'canvasRight')}
|
ref={linkRef(this, 'canvasRight')}
|
||||||
width={rightImg.width}
|
width={rightDraw && rightDraw.width}
|
||||||
height={rightImg.height}
|
height={rightDraw && rightDraw.height}
|
||||||
style={{
|
style={{
|
||||||
width: imgWidth,
|
width: originalImage && originalImage.width,
|
||||||
height: imgHeight,
|
height: originalImage && originalImage.height,
|
||||||
objectFit: rightImgContain ? 'contain' : '',
|
objectFit: rightImgContain ? 'contain' : '',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
408
src/components/compress/index.tsx
Normal file
408
src/components/compress/index.tsx
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
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 quantizer from '../../codecs/imagequant/quantizer';
|
||||||
|
import * as optiPNG from '../../codecs/optipng/encoder';
|
||||||
|
import * as resizer from '../../codecs/resize/resize';
|
||||||
|
import * as mozJPEG from '../../codecs/mozjpeg/encoder';
|
||||||
|
import * as webP from '../../codecs/webp/encoder';
|
||||||
|
import * as identity from '../../codecs/identity/encoder';
|
||||||
|
import * as browserPNG from '../../codecs/browser-png/encoder';
|
||||||
|
import * as browserJPEG from '../../codecs/browser-jpeg/encoder';
|
||||||
|
import * as browserWebP from '../../codecs/browser-webp/encoder';
|
||||||
|
import * as browserGIF from '../../codecs/browser-gif/encoder';
|
||||||
|
import * as browserTIFF from '../../codecs/browser-tiff/encoder';
|
||||||
|
import * as browserJP2 from '../../codecs/browser-jp2/encoder';
|
||||||
|
import * as browserBMP from '../../codecs/browser-bmp/encoder';
|
||||||
|
import * as browserPDF from '../../codecs/browser-pdf/encoder';
|
||||||
|
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';
|
||||||
|
|
||||||
|
type Orientation = 'horizontal' | 'vertical';
|
||||||
|
|
||||||
|
export interface SourceImage {
|
||||||
|
file: File | Fileish;
|
||||||
|
data: ImageData;
|
||||||
|
vectorImage?: HTMLImageElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EncodedImage {
|
||||||
|
preprocessed?: ImageData;
|
||||||
|
file?: Fileish;
|
||||||
|
downloadUrl?: string;
|
||||||
|
data?: ImageData;
|
||||||
|
preprocessorState: PreprocessorState;
|
||||||
|
encoderState: EncoderState;
|
||||||
|
loading: boolean;
|
||||||
|
/** Counter of the latest bmp currently encoding */
|
||||||
|
loadingCounter: number;
|
||||||
|
/** Counter of the latest bmp encoded */
|
||||||
|
loadedCounter: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
file: File | Fileish;
|
||||||
|
onError: (msg: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
source?: SourceImage;
|
||||||
|
images: [EncodedImage, EncodedImage];
|
||||||
|
loading: boolean;
|
||||||
|
error?: string;
|
||||||
|
orientation: Orientation;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateImageOptions {
|
||||||
|
skipPreprocessing?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function preprocessImage(
|
||||||
|
source: SourceImage,
|
||||||
|
preprocessData: PreprocessorState,
|
||||||
|
): Promise<ImageData> {
|
||||||
|
let result = source.data;
|
||||||
|
if (preprocessData.resize.enabled) {
|
||||||
|
if (preprocessData.resize.method === 'vector' && source.vectorImage) {
|
||||||
|
result = resizer.vectorResize(
|
||||||
|
source.vectorImage,
|
||||||
|
preprocessData.resize as resizer.VectorResizeOptions,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
result = resizer.resize(result, preprocessData.resize as resizer.BitmapResizeOptions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (preprocessData.quantizer.enabled) {
|
||||||
|
result = await quantizer.quantize(result, preprocessData.quantizer);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function compressImage(
|
||||||
|
image: ImageData,
|
||||||
|
encodeData: EncoderState,
|
||||||
|
sourceFilename: string,
|
||||||
|
): Promise<Fileish> {
|
||||||
|
const compressedData = await (() => {
|
||||||
|
switch (encodeData.type) {
|
||||||
|
case optiPNG.type: return optiPNG.encode(image, encodeData.options);
|
||||||
|
case mozJPEG.type: return mozJPEG.encode(image, encodeData.options);
|
||||||
|
case webP.type: return webP.encode(image, encodeData.options);
|
||||||
|
case browserPNG.type: return browserPNG.encode(image, encodeData.options);
|
||||||
|
case browserJPEG.type: return browserJPEG.encode(image, encodeData.options);
|
||||||
|
case browserWebP.type: return browserWebP.encode(image, encodeData.options);
|
||||||
|
case browserGIF.type: return browserGIF.encode(image, encodeData.options);
|
||||||
|
case browserTIFF.type: return browserTIFF.encode(image, encodeData.options);
|
||||||
|
case browserJP2.type: return browserJP2.encode(image, encodeData.options);
|
||||||
|
case browserBMP.type: return browserBMP.encode(image, encodeData.options);
|
||||||
|
case browserPDF.type: return browserPDF.encode(image, encodeData.options);
|
||||||
|
default: throw Error(`Unexpected encoder ${JSON.stringify(encodeData)}`);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
const encoder = encoderMap[encodeData.type];
|
||||||
|
|
||||||
|
return new Fileish(
|
||||||
|
[compressedData],
|
||||||
|
sourceFilename.replace(/.[^.]*$/, `.${encoder.extension}`),
|
||||||
|
{ type: encoder.mimeType },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class Compress extends Component<Props, State> {
|
||||||
|
widthQuery = window.matchMedia('(min-width: 500px)');
|
||||||
|
|
||||||
|
state: State = {
|
||||||
|
source: undefined,
|
||||||
|
loading: false,
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
preprocessorState: defaultPreprocessorState,
|
||||||
|
encoderState: { type: identity.type, options: identity.defaultOptions },
|
||||||
|
loadingCounter: 0,
|
||||||
|
loadedCounter: 0,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
preprocessorState: defaultPreprocessorState,
|
||||||
|
encoderState: { type: mozJPEG.type, options: mozJPEG.defaultOptions },
|
||||||
|
loadingCounter: 0,
|
||||||
|
loadedCounter: 0,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
orientation: this.widthQuery.matches ? 'horizontal' : 'vertical',
|
||||||
|
};
|
||||||
|
|
||||||
|
readonly encodeCache = new ResultCache();
|
||||||
|
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
this.widthQuery.addListener(this.onMobileWidthChange);
|
||||||
|
this.updateFile(props.file);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bind
|
||||||
|
private onMobileWidthChange() {
|
||||||
|
this.setState({ orientation: this.widthQuery.matches ? 'horizontal' : 'vertical' });
|
||||||
|
}
|
||||||
|
|
||||||
|
private onEncoderTypeChange(index: 0 | 1, newType: EncoderType): void {
|
||||||
|
this.setState({
|
||||||
|
images: cleanSet(this.state.images, `${index}.encoderState`, {
|
||||||
|
type: newType,
|
||||||
|
options: encoderMap[newType].defaultOptions,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private onPreprocessorOptionsChange(index: 0 | 1, options: PreprocessorState): void {
|
||||||
|
this.setState({
|
||||||
|
images: cleanSet(this.state.images, `${index}.preprocessorState`, options),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private onEncoderOptionsChange(index: 0 | 1, options: EncoderOptions): void {
|
||||||
|
this.setState({
|
||||||
|
images: cleanSet(this.state.images, `${index}.encoderState.options`, options),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillReceiveProps(nextProps: Props): void {
|
||||||
|
if (nextProps.file !== this.props.file) {
|
||||||
|
this.updateFile(nextProps.file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps: Props, prevState: State): void {
|
||||||
|
const { source, images } = this.state;
|
||||||
|
|
||||||
|
for (const [i, image] of images.entries()) {
|
||||||
|
const prevImage = prevState.images[i];
|
||||||
|
const sourceChanged = source !== prevState.source;
|
||||||
|
const encoderChanged = image.encoderState !== prevImage.encoderState;
|
||||||
|
const preprocessorChanged = image.preprocessorState !== prevImage.preprocessorState;
|
||||||
|
|
||||||
|
// The image only needs updated if the encoder/preprocessor settings have changed, or the
|
||||||
|
// source has changed.
|
||||||
|
if (sourceChanged || encoderChanged || preprocessorChanged) {
|
||||||
|
if (prevImage.downloadUrl) URL.revokeObjectURL(prevImage.downloadUrl);
|
||||||
|
this.updateImage(i, {
|
||||||
|
skipPreprocessing: !sourceChanged && !preprocessorChanged,
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onCopyToOtherClick(index: 0 | 1) {
|
||||||
|
const otherIndex = (index + 1) % 2;
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
images: cleanSet(this.state.images, otherIndex, this.state.images[index]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@bind
|
||||||
|
private async updateFile(file: File | Fileish) {
|
||||||
|
this.setState({ loading: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
let data: 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);
|
||||||
|
data = drawableToImageData(vectorImage);
|
||||||
|
} else {
|
||||||
|
data = await decodeImage(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
let newState: State = {
|
||||||
|
...this.state,
|
||||||
|
source: { data, file, vectorImage },
|
||||||
|
loading: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const i of [0, 1]) {
|
||||||
|
// Ditch previous encodings
|
||||||
|
const downloadUrl = this.state.images[i].downloadUrl;
|
||||||
|
if (downloadUrl) URL.revokeObjectURL(downloadUrl!);
|
||||||
|
|
||||||
|
newState = cleanMerge(newState, `images.${i}`, {
|
||||||
|
preprocessed: undefined,
|
||||||
|
file: undefined,
|
||||||
|
downloadUrl: undefined,
|
||||||
|
data: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Default resize values come from the image:
|
||||||
|
newState = cleanMerge(newState, `images.${i}.preprocessorState.resize`, {
|
||||||
|
width: data.width,
|
||||||
|
height: data.height,
|
||||||
|
method: vectorImage ? 'vector' : 'browser-high',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState(newState);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
this.props.onError('Invalid image');
|
||||||
|
this.setState({ loading: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.images[index].loadingCounter + 1;
|
||||||
|
|
||||||
|
let images = cleanMerge(this.state.images, index, {
|
||||||
|
loadingCounter,
|
||||||
|
loading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setState({ images });
|
||||||
|
|
||||||
|
const image = images[index];
|
||||||
|
|
||||||
|
let file: File | Fileish | undefined;
|
||||||
|
let preprocessed: ImageData | undefined;
|
||||||
|
let data: ImageData | undefined;
|
||||||
|
const cacheResult = this.encodeCache.match(source, image.preprocessorState, image.encoderState);
|
||||||
|
|
||||||
|
if (cacheResult) {
|
||||||
|
({ file, preprocessed, data } = cacheResult);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
// Special case for identity
|
||||||
|
if (image.encoderState.type === identity.type) {
|
||||||
|
({ file, data } = source);
|
||||||
|
} else {
|
||||||
|
preprocessed = (skipPreprocessing && image.preprocessed)
|
||||||
|
? image.preprocessed
|
||||||
|
: await preprocessImage(source, image.preprocessorState);
|
||||||
|
|
||||||
|
file = await compressImage(preprocessed, image.encoderState, source.file.name);
|
||||||
|
data = await decodeImage(file);
|
||||||
|
|
||||||
|
this.encodeCache.add({
|
||||||
|
source,
|
||||||
|
data,
|
||||||
|
preprocessed,
|
||||||
|
file,
|
||||||
|
encoderState: image.encoderState,
|
||||||
|
preprocessorState: image.preprocessorState,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.props.onError(`Processing error (type=${image.encoderState.type}): ${err}`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestImage = this.state.images[index];
|
||||||
|
// If a later encode has landed before this one, return.
|
||||||
|
if (loadingCounter < latestImage.loadedCounter) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
images = cleanMerge(this.state.images, index, {
|
||||||
|
file,
|
||||||
|
data,
|
||||||
|
preprocessed,
|
||||||
|
downloadUrl: URL.createObjectURL(file),
|
||||||
|
loading: images[index].loadingCounter !== loadingCounter,
|
||||||
|
loadedCounter: loadingCounter,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setState({ images });
|
||||||
|
}
|
||||||
|
|
||||||
|
render({ }: Props, { loading, images, source, orientation }: State) {
|
||||||
|
const [leftImage, rightImage] = images;
|
||||||
|
const [leftImageData, rightImageData] = images.map(i => i.data);
|
||||||
|
const anyLoading = loading || images.some(image => image.loading);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={style.compress}>
|
||||||
|
<Output
|
||||||
|
originalImage={source && source.data}
|
||||||
|
orientation={orientation}
|
||||||
|
leftCompressed={leftImageData}
|
||||||
|
rightCompressed={rightImageData}
|
||||||
|
leftImgContain={leftImage.preprocessorState.resize.fitMethod === 'cover'}
|
||||||
|
rightImgContain={rightImage.preprocessorState.resize.fitMethod === 'cover'}
|
||||||
|
/>
|
||||||
|
<div class={`${style.optionPair} ${style[orientation]}`}>
|
||||||
|
{images.map((image, index) => (
|
||||||
|
<Options
|
||||||
|
source={source}
|
||||||
|
orientation={orientation}
|
||||||
|
imageIndex={index}
|
||||||
|
imageFile={image.file}
|
||||||
|
downloadUrl={image.downloadUrl}
|
||||||
|
preprocessorState={image.preprocessorState}
|
||||||
|
encoderState={image.encoderState}
|
||||||
|
onEncoderTypeChange={this.onEncoderTypeChange.bind(this, index)}
|
||||||
|
onEncoderOptionsChange={this.onEncoderOptionsChange.bind(this, index)}
|
||||||
|
onPreprocessorOptionsChange={this.onPreprocessorOptionsChange.bind(this, index)}
|
||||||
|
onCopyToOtherClick={this.onCopyToOtherClick.bind(this, index)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{anyLoading && <span style={{ position: 'fixed', top: 0, left: 0 }}>Loading...</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { EncoderState } from '../../codecs/encoders';
|
import { EncoderState } from '../../codecs/encoders';
|
||||||
import { shallowEqual, Fileish } from '../../lib/util';
|
import { Fileish } from '../../lib/initial-util';
|
||||||
|
import { shallowEqual } from '../../lib/util';
|
||||||
import { SourceImage } from '.';
|
import { SourceImage } from '.';
|
||||||
import { PreprocessorState } from '../../codecs/preprocessors';
|
import { PreprocessorState } from '../../codecs/preprocessors';
|
||||||
|
|
||||||
19
src/components/compress/style.scss
Normal file
19
src/components/compress/style.scss
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
.compress {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { h, Component } from 'preact';
|
import { h, Component } from 'preact';
|
||||||
|
|
||||||
import { bind, linkRef, Fileish } from '../../lib/util';
|
import { bind, linkRef, Fileish } from '../../lib/initial-util';
|
||||||
import '../custom-els/LoadingSpinner';
|
import '../custom-els/LoadingSpinner';
|
||||||
|
|
||||||
import logo from './imgs/logo.svg';
|
import logo from './imgs/logo.svg';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { bind } from '../../lib/util';
|
import { bind } from '../../lib/initial-util';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
const RETARGETED_EVENTS = ['focus', 'blur'];
|
const RETARGETED_EVENTS = ['focus', 'blur'];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { bind } from '../util';
|
import { bind } from '../../lib/initial-util';
|
||||||
const enum Button { Left }
|
const enum Button { Left }
|
||||||
|
|
||||||
export class Pointer {
|
export class Pointer {
|
||||||
|
|||||||
53
src/lib/initial-util.ts
Normal file
53
src/lib/initial-util.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
// This file contains the utils that are needed for the very first rendering of the page. They're
|
||||||
|
// here because WebPack isn't quite smart enough to split things in the same file.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A decorator that binds values to their class instance.
|
||||||
|
* @example
|
||||||
|
* class C {
|
||||||
|
* @bind
|
||||||
|
* foo () {
|
||||||
|
* return this;
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* let f = new C().foo;
|
||||||
|
* f() instanceof C; // true
|
||||||
|
*/
|
||||||
|
export function bind(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
||||||
|
return {
|
||||||
|
// the first time the prototype property is accessed for an instance,
|
||||||
|
// define an instance property pointing to the bound function.
|
||||||
|
// This effectively "caches" the bound prototype method as an instance property.
|
||||||
|
get() {
|
||||||
|
const bound = descriptor.value.bind(this);
|
||||||
|
Object.defineProperty(this, propertyKey, {
|
||||||
|
value: bound,
|
||||||
|
});
|
||||||
|
return bound;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates a function ref that assigns its value to a given property of an object.
|
||||||
|
* @example
|
||||||
|
* // element is stored as `this.foo` when rendered.
|
||||||
|
* <div ref={linkRef(this, 'foo')} />
|
||||||
|
*/
|
||||||
|
export function linkRef<T>(obj: any, name: string) {
|
||||||
|
const refName = `$$ref_${name}`;
|
||||||
|
let ref = obj[refName];
|
||||||
|
if (!ref) {
|
||||||
|
ref = obj[refName] = (c: T) => {
|
||||||
|
obj[name] = c;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edge doesn't support `new File`, so here's a hacky alternative.
|
||||||
|
// https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/9551546/
|
||||||
|
export class Fileish extends Blob {
|
||||||
|
constructor(data: any[], public name: string, opts?: BlobPropertyBag) {
|
||||||
|
super(data, opts);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,30 +1,3 @@
|
|||||||
/**
|
|
||||||
* A decorator that binds values to their class instance.
|
|
||||||
* @example
|
|
||||||
* class C {
|
|
||||||
* @bind
|
|
||||||
* foo () {
|
|
||||||
* return this;
|
|
||||||
* }
|
|
||||||
* }
|
|
||||||
* let f = new C().foo;
|
|
||||||
* f() instanceof C; // true
|
|
||||||
*/
|
|
||||||
export function bind(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
|
||||||
return {
|
|
||||||
// the first time the prototype property is accessed for an instance,
|
|
||||||
// define an instance property pointing to the bound function.
|
|
||||||
// This effectively "caches" the bound prototype method as an instance property.
|
|
||||||
get() {
|
|
||||||
const bound = descriptor.value.bind(this);
|
|
||||||
Object.defineProperty(this, propertyKey, {
|
|
||||||
value: bound,
|
|
||||||
});
|
|
||||||
return bound;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Compare two objects, returning a boolean indicating if
|
/** Compare two objects, returning a boolean indicating if
|
||||||
* they have the same properties and strictly equal values.
|
* they have the same properties and strictly equal values.
|
||||||
*/
|
*/
|
||||||
@@ -34,22 +7,6 @@ export function shallowEqual(one: any, two: any) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Creates a function ref that assigns its value to a given property of an object.
|
|
||||||
* @example
|
|
||||||
* // element is stored as `this.foo` when rendered.
|
|
||||||
* <div ref={linkRef(this, 'foo')} />
|
|
||||||
*/
|
|
||||||
export function linkRef<T>(obj: any, name: string) {
|
|
||||||
const refName = `$$ref_${name}`;
|
|
||||||
let ref = obj[refName];
|
|
||||||
if (!ref) {
|
|
||||||
ref = obj[refName] = (c: T) => {
|
|
||||||
obj[name] = c;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return ref;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Replace the contents of a canvas with the given data */
|
/** Replace the contents of a canvas with the given data */
|
||||||
export function drawDataToCanvas(canvas: HTMLCanvasElement, data: ImageData) {
|
export function drawDataToCanvas(canvas: HTMLCanvasElement, data: ImageData) {
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
@@ -263,11 +220,3 @@ export function konami(): Promise<void> {
|
|||||||
window.addEventListener('keydown', listener);
|
window.addEventListener('keydown', listener);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Edge doesn't support `new File`, so here's a hacky alternative.
|
|
||||||
// https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/9551546/
|
|
||||||
export class Fileish extends Blob {
|
|
||||||
constructor(data: any[], public name: string, opts?: BlobPropertyBag) {
|
|
||||||
super(data, opts);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user