From e24d7865cee0868c1f9ed55e22bbd194cafe1a18 Mon Sep 17 00:00:00 2001 From: Jake Archibald Date: Sat, 20 Oct 2018 12:53:36 +0100 Subject: [PATCH] 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) --- src/codecs/generic/quality-option.tsx | 2 +- src/codecs/imagequant/options.tsx | 3 +- src/codecs/mozjpeg/options.tsx | 3 +- src/codecs/optipng/options.tsx | 3 +- src/codecs/resize/options.tsx | 3 +- src/codecs/webp/options.tsx | 3 +- .../App/custom-els/FileDrop/index.ts | 2 +- src/components/App/index.tsx | 393 ++--------------- src/components/App/style.scss | 9 + src/components/Options/index.tsx | 10 +- .../Output/custom-els/PinchZoom/index.ts | 5 +- src/components/Output/index.tsx | 92 ++-- src/components/compress/index.tsx | 408 ++++++++++++++++++ .../{App => compress}/result-cache.ts | 3 +- src/components/compress/style.scss | 19 + src/components/intro/index.tsx | 2 +- src/custom-els/RangeInput/index.ts | 2 +- src/lib/PointerTracker/index.ts | 2 +- src/lib/initial-util.ts | 53 +++ src/lib/util.ts | 51 --- 20 files changed, 604 insertions(+), 464 deletions(-) create mode 100644 src/components/compress/index.tsx rename src/components/{App => compress}/result-cache.ts (95%) create mode 100644 src/components/compress/style.scss create mode 100644 src/lib/initial-util.ts diff --git a/src/codecs/generic/quality-option.tsx b/src/codecs/generic/quality-option.tsx index a9193843..373bc2a2 100644 --- a/src/codecs/generic/quality-option.tsx +++ b/src/codecs/generic/quality-option.tsx @@ -1,5 +1,5 @@ import { h, Component } from 'preact'; -import { bind } from '../../lib/util'; +import { bind } from '../../lib/initial-util'; import '../../custom-els/RangeInput'; interface EncodeOptions { diff --git a/src/codecs/imagequant/options.tsx b/src/codecs/imagequant/options.tsx index 0cb841dc..28c4ff6c 100644 --- a/src/codecs/imagequant/options.tsx +++ b/src/codecs/imagequant/options.tsx @@ -1,5 +1,6 @@ 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'; const konamiPromise = konami(); diff --git a/src/codecs/mozjpeg/options.tsx b/src/codecs/mozjpeg/options.tsx index db4391cb..dd0eb9b2 100644 --- a/src/codecs/mozjpeg/options.tsx +++ b/src/codecs/mozjpeg/options.tsx @@ -1,5 +1,6 @@ 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 '../../custom-els/RangeInput'; diff --git a/src/codecs/optipng/options.tsx b/src/codecs/optipng/options.tsx index 9aebd704..bc6a9158 100644 --- a/src/codecs/optipng/options.tsx +++ b/src/codecs/optipng/options.tsx @@ -1,5 +1,6 @@ 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'; type Props = { diff --git a/src/codecs/resize/options.tsx b/src/codecs/resize/options.tsx index 8319bbb3..11027a6e 100644 --- a/src/codecs/resize/options.tsx +++ b/src/codecs/resize/options.tsx @@ -1,6 +1,7 @@ import { h, Component } from 'preact'; 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'; interface Props { diff --git a/src/codecs/webp/options.tsx b/src/codecs/webp/options.tsx index 3c39e859..81a1a901 100644 --- a/src/codecs/webp/options.tsx +++ b/src/codecs/webp/options.tsx @@ -1,5 +1,6 @@ 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 * as styles from './styles.scss'; import '../../custom-els/RangeInput'; diff --git a/src/components/App/custom-els/FileDrop/index.ts b/src/components/App/custom-els/FileDrop/index.ts index bf46bc41..5f298ebf 100644 --- a/src/components/App/custom-els/FileDrop/index.ts +++ b/src/components/App/custom-els/FileDrop/index.ts @@ -1,4 +1,4 @@ -import { bind } from '../../../../lib/util'; +import { bind } from '../../../../lib/initial-util'; import './styles.css'; // tslint:disable-next-line:max-line-length diff --git a/src/components/App/index.tsx b/src/components/App/index.tsx index 92d5868b..002c64d0 100644 --- a/src/components/App/index.tsx +++ b/src/components/App/index.tsx @@ -1,46 +1,16 @@ 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 Output from '../Output'; -import Options from '../Options'; import { FileDropEvent } from './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 '../../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 '../custom-els/LoadingSpinner'; -type Orientation = 'horizontal' | 'vertical'; +// This is imported for TypeScript only. It isn't used. +import Compress from '../compress'; export interface SourceImage { file: File | Fileish; @@ -48,140 +18,30 @@ export interface SourceImage { 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 State { - source?: SourceImage; - images: [EncodedImage, EncodedImage]; - loading: boolean; - error?: string; - orientation: Orientation; -} - -interface UpdateImageOptions { - skipPreprocessing?: boolean; -} - -async function preprocessImage( - source: SourceImage, - preprocessData: PreprocessorState, -): Promise { - 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 { - 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 { - // 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' })); + file?: File | Fileish; + Compress?: typeof Compress; } export default class App extends Component { - widthQuery = window.matchMedia('(min-width: 500px)'); - state: State = { - 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', + file: undefined, + Compress: undefined, }; snackbar?: SnackBarElement; - readonly encodeCache = new ResultCache(); constructor() { 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: if (process.env.NODE_ENV === 'development') { this.setState(window.STATE); @@ -191,232 +51,39 @@ export default class App extends Component { window.STATE = this.state; }; } - - this.widthQuery.addListener(this.onMobileWidthChange); } @bind - onMobileWidthChange() { - 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) { + private onFileDrop(event: FileDropEvent) { const { file } = event; if (!file) return; - await this.updateFile(file); - } - - onCopyToOtherClick(index: 0 | 1) { - const otherIndex = (index + 1) % 2; - - this.setState({ - images: cleanSet(this.state.images, otherIndex, this.state.images[index]), - }); + this.setState({ file }); } @bind - 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, - }; - - // 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 { - 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 }); + private onIntroPickFile(file: File | Fileish) { + this.setState({ file }); } @bind - showError (error: string) { + private showError(error: string) { if (!this.snackbar) throw Error('Snackbar missing'); this.snackbar.showSnackbar({ message: error }); } - 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); - + render({}: Props, { file, Compress }: State) { return ( - -
- {source - ? -
- - {images.map((image, index) => ( - - ))} -
- : - +
+ + {(!file) + ? + : (Compress) + ? + : } - {anyLoading && Loading...} -
- + +
); } } diff --git a/src/components/App/style.scss b/src/components/App/style.scss index 0cbb89f8..ef252881 100644 --- a/src/components/App/style.scss +++ b/src/components/App/style.scss @@ -69,3 +69,12 @@ Note: These styles are temporary. They will be replaced before going live. flex-direction: column; } } + +.app-loader { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + --size: 225px; + --stroke-width: 26px; +} diff --git a/src/components/Options/index.tsx b/src/components/Options/index.tsx index b5ce1780..abe37179 100644 --- a/src/components/Options/index.tsx +++ b/src/components/Options/index.tsx @@ -1,7 +1,7 @@ import { h, Component } from 'preact'; 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 OptiPNGEncoderOptions from '../../codecs/optipng/options'; import MozJpegEncoderOptions from '../../codecs/mozjpeg/options'; @@ -63,7 +63,7 @@ const titles = { interface Props { orientation: 'horizontal' | 'vertical'; - source: SourceImage; + source?: SourceImage; imageIndex: number; imageFile?: Fileish; downloadUrl?: string; @@ -177,8 +177,8 @@ export default class Options extends Component { {preprocessorState.resize.enabled && @@ -223,7 +223,7 @@ export default class Options extends Component { increaseClass={style.increase} decreaseClass={style.decrease} file={imageFile} - compareTo={imageFile === source.file ? undefined : source.file} + compareTo={(source && imageFile !== source.file) ? source.file : undefined} /> {(downloadUrl && imageFile) && ( diff --git a/src/components/Output/custom-els/PinchZoom/index.ts b/src/components/Output/custom-els/PinchZoom/index.ts index c8700cda..d36ad919 100644 --- a/src/components/Output/custom-els/PinchZoom/index.ts +++ b/src/components/Output/custom-els/PinchZoom/index.ts @@ -272,10 +272,7 @@ export default class PinchZoom extends HTMLElement { private _stageElChange() { this._positioningEl = undefined; - if (this.children.length === 0) { - console.warn('There should be at least one child in .'); - return; - } + if (this.children.length === 0) return; this._positioningEl = this.children[0]; diff --git a/src/components/Output/index.tsx b/src/components/Output/index.tsx index fe461e2d..454e8bc9 100644 --- a/src/components/Output/index.tsx +++ b/src/components/Output/index.tsx @@ -3,16 +3,16 @@ import PinchZoom, { ScaleToOpts } from './custom-els/PinchZoom'; import './custom-els/PinchZoom'; import './custom-els/TwoUp'; 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 { twoUpHandle } from './custom-els/TwoUp/styles.css'; interface Props { + originalImage?: ImageData; orientation: 'horizontal' | 'vertical'; - leftImg: ImageData; - rightImg: ImageData; - imgWidth: number; - imgHeight: number; + leftCompressed?: ImageData; + rightCompressed?: ImageData; leftImgContain: boolean; rightImgContain: boolean; } @@ -44,20 +44,38 @@ export default class Output extends Component { retargetedEvents = new WeakSet(); componentDidMount() { - if (this.canvasLeft) { - drawDataToCanvas(this.canvasLeft, this.props.leftImg); + const leftDraw = this.leftDrawable(); + const rightDraw = this.rightDrawable(); + + if (this.canvasLeft && leftDraw) { + drawDataToCanvas(this.canvasLeft, leftDraw); } - if (this.canvasRight) { - drawDataToCanvas(this.canvasRight, this.props.rightImg); + if (this.canvasRight && rightDraw) { + drawDataToCanvas(this.canvasRight, rightDraw); } } componentDidUpdate(prevProps: Props, prevState: State) { - if (prevProps.leftImg !== this.props.leftImg && this.canvasLeft) { - drawDataToCanvas(this.canvasLeft, this.props.leftImg); + const prevLeftDraw = this.leftDrawable(prevProps); + 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) { - drawDataToCanvas(this.canvasRight, this.props.rightImg); + if (rightDraw && rightDraw !== prevRightDraw && this.canvasRight) { + 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 { 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 - toggleBackground() { + private toggleBackground() { this.setState({ altBackground: !this.state.altBackground, }); } @bind - zoomIn() { + private zoomIn() { if (!this.pinchZoomLeft) throw Error('Missing pinch-zoom element'); this.pinchZoomLeft.scaleTo(this.state.scale * 1.25, scaleToOpts); } @bind - zoomOut() { + private zoomOut() { if (!this.pinchZoomLeft) throw Error('Missing pinch-zoom element'); this.pinchZoomLeft.scaleTo(this.state.scale / 1.25, scaleToOpts); } @bind - editScale() { + private editScale() { this.setState({ editingScale: true }, () => { if (this.scaleInput) this.scaleInput.focus(); }); } @bind - cancelEditScale() { + private cancelEditScale() { this.setState({ editingScale: false }); } @bind - onScaleInputChanged(event: Event) { + private onScaleInputChanged(event: Event) { const target = event.target as HTMLInputElement; const percent = parseFloat(target.value); if (isNaN(percent)) return; @@ -109,7 +135,7 @@ export default class Output extends Component { } @bind - onPinchZoomLeftChange(event: Event) { + private onPinchZoomLeftChange(event: Event) { if (!this.pinchZoomRight || !this.pinchZoomLeft) throw Error('Missing pinch-zoom element'); this.setState({ scale: this.pinchZoomLeft.scale, @@ -130,7 +156,7 @@ export default class Output extends Component { * @param event Event to redirect */ @bind - onRetargetableEvent(event: Event) { + 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, @@ -149,9 +175,15 @@ export default class Output extends Component { } render( - { orientation, leftImg, rightImg, imgWidth, imgHeight, leftImgContain, rightImgContain }: Props, + { + orientation, leftCompressed, rightCompressed, leftImgContain, rightImgContain, + originalImage, + }: Props, { scale, editingScale, altBackground }: State, ) { + const leftDraw = this.leftDrawable(); + const rightDraw = this.rightDrawable(); + return (
{ @@ -185,11 +217,11 @@ export default class Output extends Component { diff --git a/src/components/compress/index.tsx b/src/components/compress/index.tsx new file mode 100644 index 00000000..05177b9f --- /dev/null +++ b/src/components/compress/index.tsx @@ -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 { + 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 { + 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 { + // 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 { + 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 { + 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 ( +
+ +
+ {images.map((image, index) => ( + + ))} +
+ {anyLoading && Loading...} +
+ ); + } +} diff --git a/src/components/App/result-cache.ts b/src/components/compress/result-cache.ts similarity index 95% rename from src/components/App/result-cache.ts rename to src/components/compress/result-cache.ts index 0f183a0d..c01f290d 100644 --- a/src/components/App/result-cache.ts +++ b/src/components/compress/result-cache.ts @@ -1,5 +1,6 @@ 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 { PreprocessorState } from '../../codecs/preprocessors'; diff --git a/src/components/compress/style.scss b/src/components/compress/style.scss new file mode 100644 index 00000000..2643534f --- /dev/null +++ b/src/components/compress/style.scss @@ -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; + } +} diff --git a/src/components/intro/index.tsx b/src/components/intro/index.tsx index b239a306..f99d8b23 100644 --- a/src/components/intro/index.tsx +++ b/src/components/intro/index.tsx @@ -1,6 +1,6 @@ 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 logo from './imgs/logo.svg'; diff --git a/src/custom-els/RangeInput/index.ts b/src/custom-els/RangeInput/index.ts index d6c103e6..58ed63b5 100644 --- a/src/custom-els/RangeInput/index.ts +++ b/src/custom-els/RangeInput/index.ts @@ -1,4 +1,4 @@ -import { bind } from '../../lib/util'; +import { bind } from '../../lib/initial-util'; import './styles.css'; const RETARGETED_EVENTS = ['focus', 'blur']; diff --git a/src/lib/PointerTracker/index.ts b/src/lib/PointerTracker/index.ts index a885bc7b..10974669 100644 --- a/src/lib/PointerTracker/index.ts +++ b/src/lib/PointerTracker/index.ts @@ -1,4 +1,4 @@ -import { bind } from '../util'; +import { bind } from '../../lib/initial-util'; const enum Button { Left } export class Pointer { diff --git a/src/lib/initial-util.ts b/src/lib/initial-util.ts new file mode 100644 index 00000000..51d6b3f4 --- /dev/null +++ b/src/lib/initial-util.ts @@ -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. + *
+ */ +export function linkRef(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); + } +} diff --git a/src/lib/util.ts b/src/lib/util.ts index b9cbb374..a42aa14c 100644 --- a/src/lib/util.ts +++ b/src/lib/util.ts @@ -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 * they have the same properties and strictly equal values. */ @@ -34,22 +7,6 @@ export function shallowEqual(one: any, two: any) { 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. - *
- */ -export function linkRef(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 */ export function drawDataToCanvas(canvas: HTMLCanvasElement, data: ImageData) { const ctx = canvas.getContext('2d'); @@ -263,11 +220,3 @@ export function konami(): Promise { 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); - } -}