diff --git a/src/codecs/input-processors.ts b/src/codecs/input-processors.ts new file mode 100644 index 00000000..ff3a654b --- /dev/null +++ b/src/codecs/input-processors.ts @@ -0,0 +1,9 @@ +import { defaultOptions as rotateDefaultOptions } from './rotate/processor-meta'; + +export interface InputProcessorState { + rotate: import('./rotate/processor-meta').RotateOptions; +} + +export const defaultInputProcessorState: InputProcessorState = { + rotate: rotateDefaultOptions, +}; diff --git a/src/codecs/processor-worker/index.ts b/src/codecs/processor-worker/index.ts index fb3e27e8..d5367b46 100644 --- a/src/codecs/processor-worker/index.ts +++ b/src/codecs/processor-worker/index.ts @@ -1,11 +1,7 @@ import { expose } from 'comlink'; -import { EncodeOptions as MozJPEGEncoderOptions } from '../mozjpeg/encoder-meta'; -import { QuantizeOptions } from '../imagequant/processor-meta'; -import { EncodeOptions as OptiPNGEncoderOptions } from '../optipng/encoder-meta'; -import { EncodeOptions as WebPEncoderOptions } from '../webp/encoder-meta'; async function mozjpegEncode( - data: ImageData, options: MozJPEGEncoderOptions, + data: ImageData, options: import('../mozjpeg/encoder-meta').EncodeOptions, ): Promise { const { encode } = await import( /* webpackChunkName: "process-mozjpeg-enc" */ @@ -14,7 +10,9 @@ async function mozjpegEncode( return encode(data, options); } -async function quantize(data: ImageData, opts: QuantizeOptions): Promise { +async function quantize( + data: ImageData, opts: import('../imagequant/processor-meta').QuantizeOptions, +): Promise { const { process } = await import( /* webpackChunkName: "process-imagequant" */ '../imagequant/processor', @@ -22,8 +20,19 @@ async function quantize(data: ImageData, opts: QuantizeOptions): Promise { + const { rotate } = await import( + /* webpackChunkName: "process-rotate" */ + '../rotate/processor', + ); + + return rotate(data, opts); +} + async function optiPngEncode( - data: BufferSource, options: OptiPNGEncoderOptions, + data: BufferSource, options: import('../optipng/encoder-meta').EncodeOptions, ): Promise { const { compress } = await import( /* webpackChunkName: "process-optipng" */ @@ -33,7 +42,7 @@ async function optiPngEncode( } async function webpEncode( - data: ImageData, options: WebPEncoderOptions, + data: ImageData, options: import('../webp/encoder-meta').EncodeOptions, ): Promise { const { encode } = await import( /* webpackChunkName: "process-webp-enc" */ @@ -50,7 +59,7 @@ async function webpDecode(data: ArrayBuffer): Promise { return decode(data); } -const exports = { mozjpegEncode, quantize, optiPngEncode, webpEncode, webpDecode }; +const exports = { mozjpegEncode, quantize, rotate, optiPngEncode, webpEncode, webpDecode }; export type ProcessorWorkerApi = typeof exports; expose(exports, self); diff --git a/src/codecs/processor.ts b/src/codecs/processor.ts index 45cccc63..05be113a 100644 --- a/src/codecs/processor.ts +++ b/src/codecs/processor.ts @@ -118,12 +118,18 @@ export default class Processor { } // Off main thread jobs: - @Processor._processingJob({ needsWorker: true }) imageQuant(data: ImageData, opts: QuantizeOptions): Promise { return this._workerApi!.quantize(data, opts); } + @Processor._processingJob({ needsWorker: true }) + rotate( + data: ImageData, opts: import('./rotate/processor-meta').RotateOptions, + ): Promise { + return this._workerApi!.rotate(data, opts); + } + @Processor._processingJob({ needsWorker: true }) mozjpegEncode( data: ImageData, opts: MozJPEGEncoderOptions, diff --git a/src/codecs/resize/options.tsx b/src/codecs/resize/options.tsx index 42a984dd..a6624434 100644 --- a/src/codecs/resize/options.tsx +++ b/src/codecs/resize/options.tsx @@ -135,7 +135,7 @@ export default class ResizerOptions extends Component { onChange={this.onChange} > - + } diff --git a/src/codecs/resize/processor-meta.ts b/src/codecs/resize/processor-meta.ts index bc2c5f1e..fad86f72 100644 --- a/src/codecs/resize/processor-meta.ts +++ b/src/codecs/resize/processor-meta.ts @@ -4,7 +4,7 @@ export interface ResizeOptions { width: number; height: number; method: 'vector' | BitmapResizeMethods; - fitMethod: 'stretch' | 'cover'; + fitMethod: 'stretch' | 'contain'; } export interface BitmapResizeOptions extends ResizeOptions { diff --git a/src/codecs/resize/processor.ts b/src/codecs/resize/processor.ts index 55cbe319..5db7aae8 100644 --- a/src/codecs/resize/processor.ts +++ b/src/codecs/resize/processor.ts @@ -1,7 +1,7 @@ import { nativeResize, NativeResizeMethod, drawableToImageData } from '../../lib/util'; import { BitmapResizeOptions, VectorResizeOptions } from './processor-meta'; -function getCoverOffsets(sw: number, sh: number, dw: number, dh: number) { +function getContainOffsets(sw: number, sh: number, dw: number, dh: number) { const currentAspect = sw / sh; const endAspect = dw / dh; @@ -22,8 +22,8 @@ export function resize(data: ImageData, opts: BitmapResizeOptions): ImageData { let sw = data.width; let sh = data.height; - if (opts.fitMethod === 'cover') { - ({ sx, sy, sw, sh } = getCoverOffsets(sw, sh, opts.width, opts.height)); + if (opts.fitMethod === 'contain') { + ({ sx, sy, sw, sh } = getContainOffsets(sw, sh, opts.width, opts.height)); } return nativeResize( @@ -38,8 +38,8 @@ export function vectorResize(data: HTMLImageElement, opts: VectorResizeOptions): let sw = data.width; let sh = data.height; - if (opts.fitMethod === 'cover') { - ({ sx, sy, sw, sh } = getCoverOffsets(sw, sh, opts.width, opts.height)); + if (opts.fitMethod === 'contain') { + ({ sx, sy, sw, sh } = getContainOffsets(sw, sh, opts.width, opts.height)); } return drawableToImageData(data, { diff --git a/src/codecs/rotate/processor-meta.ts b/src/codecs/rotate/processor-meta.ts new file mode 100644 index 00000000..854bcafa --- /dev/null +++ b/src/codecs/rotate/processor-meta.ts @@ -0,0 +1,5 @@ +export interface RotateOptions { + rotate: 0 | 90 | 180 | 270; +} + +export const defaultOptions: RotateOptions = { rotate: 0 }; diff --git a/src/codecs/rotate/processor.ts b/src/codecs/rotate/processor.ts new file mode 100644 index 00000000..2e8c167b --- /dev/null +++ b/src/codecs/rotate/processor.ts @@ -0,0 +1,80 @@ +import { RotateOptions } from './processor-meta'; + +const bpp = 4; + +export function rotate(data: ImageData, opts: RotateOptions): ImageData { + const { rotate } = opts; + + // Early exit if there's no transform. + if (rotate === 0) return data; + + const flipDimensions = rotate % 180 !== 0; + const { width: inputWidth, height: inputHeight } = data; + const outputWidth = flipDimensions ? inputHeight : inputWidth; + const outputHeight = flipDimensions ? inputWidth : inputHeight; + const out = new ImageData(outputWidth, outputHeight); + let i = 0; + + // In the straight-copy case, d1 is x, d2 is y. + // x starts at 0 and increases. + // y starts at 0 and increases. + let d1Start = 0; + let d1Limit = inputWidth; + let d1Advance = 1; + let d1Multiplier = 1; + let d2Start = 0; + let d2Limit = inputHeight; + let d2Advance = 1; + let d2Multiplier = inputWidth; + + if (rotate === 90) { + // d1 is y, d2 is x. + // y starts at its max value and decreases. + // x starts at 0 and increases. + d1Start = inputHeight - 1; + d1Limit = inputHeight; + d1Advance = -1; + d1Multiplier = inputWidth; + d2Start = 0; + d2Limit = inputWidth; + d2Advance = 1; + d2Multiplier = 1; + } else if (rotate === 180) { + // d1 is x, d2 is y. + // x starts at its max and decreases. + // y starts at its max and decreases. + d1Start = inputWidth - 1; + d1Limit = inputWidth; + d1Advance = -1; + d1Multiplier = 1; + d2Start = inputHeight - 1; + d2Limit = inputHeight; + d2Advance = -1; + d2Multiplier = inputWidth; + } else if (rotate === 270) { + // d1 is y, d2 is x. + // y starts at 0 and increases. + // x starts at its max and decreases. + d1Start = 0; + d1Limit = inputHeight; + d1Advance = 1; + d1Multiplier = inputWidth; + d2Start = inputWidth - 1; + d2Limit = inputWidth; + d2Advance = -1; + d2Multiplier = 1; + } + + for (let d2 = d2Start; d2 >= 0 && d2 < d2Limit; d2 += d2Advance) { + for (let d1 = d1Start; d1 >= 0 && d1 < d1Limit; d1 += d1Advance) { + // Iterate over channels: + const start = ((d1 * d1Multiplier) + (d2 * d2Multiplier)) * bpp; + for (let j = 0; j < bpp; j += 1) { + out.data[i] = data.data[start + j]; + i += 1; + } + } + } + + return out; +} diff --git a/src/components/App/index.tsx b/src/components/App/index.tsx index 8a1eb498..a0477733 100644 --- a/src/components/App/index.tsx +++ b/src/components/App/index.tsx @@ -18,12 +18,6 @@ const offlinerPromise = import( '../../lib/offliner', ); -export interface SourceImage { - file: File | Fileish; - data: ImageData; - vectorImage?: HTMLImageElement; -} - interface Props {} interface State { diff --git a/src/components/Options/index.tsx b/src/components/Options/index.tsx index d4fed3db..98783a07 100644 --- a/src/components/Options/index.tsx +++ b/src/components/Options/index.tsx @@ -35,7 +35,7 @@ import { import { QuantizeOptions } from '../../codecs/imagequant/processor-meta'; import { ResizeOptions } from '../../codecs/resize/processor-meta'; import { PreprocessorState } from '../../codecs/preprocessors'; -import { SourceImage } from '../App'; +import { SourceImage } from '../compress'; import Checkbox from '../checkbox'; import Expander from '../expander'; import Select from '../select'; @@ -81,7 +81,7 @@ export default class Options extends Component { } @bind - onEncoderTypeChange(event: Event) { + private onEncoderTypeChange(event: Event) { const el = event.currentTarget as HTMLSelectElement; // The select element only has values matching encoder types, @@ -91,7 +91,7 @@ export default class Options extends Component { } @bind - onPreprocessorEnabledChange(event: Event) { + private onPreprocessorEnabledChange(event: Event) { const el = event.currentTarget as HTMLInputElement; const preprocessor = el.name.split('.')[0] as keyof PreprocessorState; @@ -101,14 +101,14 @@ export default class Options extends Component { } @bind - onQuantizerOptionsChange(opts: QuantizeOptions) { + private onQuantizerOptionsChange(opts: QuantizeOptions) { this.props.onPreprocessorOptionsChange( cleanMerge(this.props.preprocessorState, 'quantizer', opts), ); } @bind - onResizeOptionsChange(opts: ResizeOptions) { + private onResizeOptionsChange(opts: ResizeOptions) { this.props.onPreprocessorOptionsChange( cleanMerge(this.props.preprocessorState, 'resize', opts), ); @@ -144,7 +144,7 @@ export default class Options extends Component { {preprocessorState.resize.enabled ? diff --git a/src/components/Output/index.tsx b/src/components/Output/index.tsx index a0481083..a52d2702 100644 --- a/src/components/Output/index.tsx +++ b/src/components/Output/index.tsx @@ -5,17 +5,29 @@ import './custom-els/TwoUp'; import * as style from './style.scss'; import { bind, linkRef } from '../../lib/initial-util'; import { shallowEqual, drawDataToCanvas } from '../../lib/util'; -import { ToggleIcon, AddIcon, RemoveIcon, BackIcon } from '../../lib/icons'; +import { + ToggleBackgroundIcon, + AddIcon, + RemoveIcon, + BackIcon, + ToggleBackgroundActiveIcon, + RotateIcon, +} from '../../lib/icons'; import { twoUpHandle } from './custom-els/TwoUp/styles.css'; +import { InputProcessorState } from '../../codecs/input-processors'; +import { cleanSet } from '../../lib/clean-modify'; +import { SourceImage } from '../compress'; interface Props { - originalImage?: ImageData; + source?: SourceImage; + inputProcessorState?: InputProcessorState; mobileView: boolean; leftCompressed?: ImageData; rightCompressed?: ImageData; leftImgContain: boolean; rightImgContain: boolean; onBack: () => void; + onInputProcessorChange: (newState: InputProcessorState) => void; } interface State { @@ -70,6 +82,38 @@ export default class Output extends Component { const prevRightDraw = this.rightDrawable(prevProps); const leftDraw = this.leftDrawable(); const rightDraw = this.rightDrawable(); + const sourceFileChanged = + // Has the value become (un)defined? + (!!this.props.source !== !!prevProps.source) || + // Or has the file changed? + (this.props.source && prevProps.source && this.props.source.file !== prevProps.source.file); + + const oldSourceData = prevProps.source && prevProps.source.processed; + const newSourceData = this.props.source && this.props.source.processed; + const pinchZoom = this.pinchZoomLeft!; + + if (sourceFileChanged) { + // New image? Reset the pinch-zoom. + pinchZoom.setTransform({ + allowChangeEvent: true, + x: 0, + y: 0, + scale: 1, + }); + } else if (oldSourceData && newSourceData && oldSourceData !== newSourceData) { + // Since the pinch zoom transform origin is the top-left of the content, we need to flip + // things around a bit when the content size changes, so the new content appears as if it were + // central to the previous content. + const scaleChange = 1 - pinchZoom.scale; + const oldXScaleOffset = oldSourceData.width / 2 * scaleChange; + const oldYScaleOffset = oldSourceData.height / 2 * scaleChange; + + pinchZoom.setTransform({ + allowChangeEvent: true, + x: pinchZoom.x - oldXScaleOffset + oldYScaleOffset, + y: pinchZoom.y - oldYScaleOffset + oldXScaleOffset, + }); + } if (leftDraw && leftDraw !== prevLeftDraw && this.canvasLeft) { drawDataToCanvas(this.canvasLeft, leftDraw); @@ -77,16 +121,6 @@ export default class Output extends Component { 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, - }); - } } shouldComponentUpdate(nextProps: Props, nextState: State) { @@ -94,11 +128,11 @@ export default class Output extends Component { } private leftDrawable(props: Props = this.props): ImageData | undefined { - return props.leftCompressed || props.originalImage; + return props.leftCompressed || (props.source && props.source.processed); } private rightDrawable(props: Props = this.props): ImageData | undefined { - return props.rightCompressed || props.originalImage; + return props.rightCompressed || (props.source && props.source.processed); } @bind @@ -122,6 +156,20 @@ export default class Output extends Component { this.pinchZoomLeft.scaleTo(this.state.scale / 1.25, scaleToOpts); } + @bind + private onRotateClick() { + const { inputProcessorState } = this.props; + if (!inputProcessorState) return; + + const newState = cleanSet( + inputProcessorState, + 'rotate.rotate', + (inputProcessorState.rotate.rotate + 90) % 360, + ); + + this.props.onInputProcessorChange(newState); + } + @bind private onScaleValueFocus() { this.setState({ editingScale: true }, () => { @@ -201,11 +249,13 @@ export default class Output extends Component { } render( - { mobileView, leftImgContain, rightImgContain, originalImage, onBack }: Props, + { mobileView, leftImgContain, rightImgContain, source, onBack }: Props, { scale, editingScale, altBackground }: State, ) { const leftDraw = this.leftDrawable(); const rightDraw = this.rightDrawable(); + // To keep position stable, the output is put in a square using the longest dimension. + const originalImage = source && source.processed; return (
@@ -227,7 +277,7 @@ export default class Output extends Component { ref={linkRef(this, 'pinchZoomLeft')} > { {
- + diff --git a/src/components/Output/style.scss b/src/components/Output/style.scss index 6d5286d2..e8e780da 100644 --- a/src/components/Output/style.scss +++ b/src/components/Output/style.scss @@ -31,6 +31,13 @@ align-items: center; } +.pinch-target { + // This fixes a severe painting bug in Chrome. + // We should try to remove this once the issue is fixed. + // https://bugs.chromium.org/p/chromium/issues/detail?id=870222#c10 + will-change: auto; +} + .controls { position: absolute; display: flex; @@ -87,6 +94,7 @@ white-space: nowrap; height: 36px; padding: 0 8px; + cursor: pointer; @media (min-width: 600px) { height: 48px; @@ -101,15 +109,20 @@ } .button { - text-transform: uppercase; color: var(--button-fg); - cursor: pointer; - text-indent: 6px; - font-size: 110%; &:hover { background-color: #eee; } + + &.active { + background: #34B9EB; + color: #fff; + + &:hover { + background: #32a3ce; + } + } } .zoom { @@ -133,14 +146,6 @@ border-bottom: 1px dashed #999; } -.output-canvas { - flex-shrink: 0; - // This fixes a severe painting bug in Chrome. - // We should try to remove this once the issue is fixed. - // https://bugs.chromium.org/p/chromium/issues/detail?id=870222#c10 - will-change: auto; -} - .back { position: absolute; top: 0; diff --git a/src/components/compress/index.tsx b/src/components/compress/index.tsx index acd7d392..4e094163 100644 --- a/src/components/compress/index.tsx +++ b/src/components/compress/index.tsx @@ -35,21 +35,29 @@ import { VectorResizeOptions, BitmapResizeOptions } from '../../codecs/resize/pr import './custom-els/MultiPanel'; import Results from '../results'; import { ExpandIcon, CopyAcrossIconProps } from '../../lib/icons'; -import SnackBarElement from 'src/lib/SnackBar'; +import SnackBarElement from '../../lib/SnackBar'; +import { InputProcessorState, defaultInputProcessorState } from '../../codecs/input-processors'; export interface SourceImage { file: File | Fileish; - data: ImageData; + decoded: ImageData; + processed: ImageData; vectorImage?: HTMLImageElement; + inputProcessorState: InputProcessorState; } -interface EncodedImage { +interface SideSettings { + preprocessorState: PreprocessorState; + encoderState: EncoderState; +} + +interface Side { preprocessed?: ImageData; file?: Fileish; downloadUrl?: string; data?: ImageData; - preprocessorState: PreprocessorState; - encoderState: EncoderState; + latestSettings: SideSettings; + encodedSettings?: SideSettings; loading: boolean; /** Counter of the latest bmp currently encoding */ loadingCounter: number; @@ -65,7 +73,7 @@ interface Props { interface State { source?: SourceImage; - images: [EncodedImage, EncodedImage]; + sides: [Side, Side]; /** Source image load */ loading: boolean; loadingCounter: number; @@ -77,12 +85,21 @@ interface UpdateImageOptions { skipPreprocessing?: boolean; } +function processInput( + data: ImageData, + inputProcessData: InputProcessorState, + processor: Processor, +) { + return processor.rotate(data, inputProcessData.rotate); +} + async function preprocessImage( source: SourceImage, preprocessData: PreprocessorState, processor: Processor, ): Promise { - let result = source.data; + let result = source.processed; + if (preprocessData.resize.enabled) { if (preprocessData.resize.method === 'vector' && source.vectorImage) { result = processor.vectorResize( @@ -131,6 +148,26 @@ async function compressImage( ); } +function stateForNewSourceData(state: State, newSource: SourceImage): State { + let newState = { ...state }; + + for (const i of [0, 1]) { + // Ditch previous encodings + const downloadUrl = state.sides[i].downloadUrl; + if (downloadUrl) URL.revokeObjectURL(downloadUrl!); + + newState = cleanMerge(state, `sides.${i}`, { + preprocessed: undefined, + file: undefined, + downloadUrl: undefined, + data: undefined, + encodedSettings: undefined, + }); + } + + return newState; +} + async function processSvg(blob: Blob): Promise { // 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. @@ -171,17 +208,21 @@ export default class Compress extends Component { source: undefined, loading: false, loadingCounter: 0, - images: [ + sides: [ { - preprocessorState: defaultPreprocessorState, - encoderState: { type: identity.type, options: identity.defaultOptions }, + latestSettings: { + preprocessorState: defaultPreprocessorState, + encoderState: { type: identity.type, options: identity.defaultOptions }, + }, loadingCounter: 0, loadedCounter: 0, loading: false, }, { - preprocessorState: defaultPreprocessorState, - encoderState: { type: mozJPEG.type, options: mozJPEG.defaultOptions }, + latestSettings: { + preprocessorState: defaultPreprocessorState, + encoderState: { type: mozJPEG.type, options: mozJPEG.defaultOptions }, + }, loadingCounter: 0, loadedCounter: 0, loading: false, @@ -209,7 +250,7 @@ export default class Compress extends Component { private onEncoderTypeChange(index: 0 | 1, newType: EncoderType): void { this.setState({ - images: cleanSet(this.state.images, `${index}.encoderState`, { + sides: cleanSet(this.state.sides, `${index}.latestSettings.encoderState`, { type: newType, options: encoderMap[newType].defaultOptions, }), @@ -218,20 +259,18 @@ export default class Compress extends Component { private onPreprocessorOptionsChange(index: 0 | 1, options: PreprocessorState): void { this.setState({ - images: cleanSet(this.state.images, `${index}.preprocessorState`, options), + sides: cleanSet(this.state.sides, `${index}.latestSettings.preprocessorState`, options), }); } private onEncoderOptionsChange(index: 0 | 1, options: EncoderOptions): void { this.setState({ - images: cleanSet(this.state.images, `${index}.encoderState.options`, options), + sides: cleanSet(this.state.sides, `${index}.latestSettings.encoderState.options`, options), }); } - private updateDocumentTitle(filename: string = '') { - const newTitle: string = filename ? `${filename} - ${originalDocumentTitle}` : originalDocumentTitle; - - document.title = newTitle; + private updateDocumentTitle(filename: string = ''): void { + document.title = filename ? `${filename} - ${originalDocumentTitle}` : originalDocumentTitle; } componentWillReceiveProps(nextProps: Props): void { @@ -245,20 +284,25 @@ export default class Compress extends Component { } componentDidUpdate(prevProps: Props, prevState: State): void { - const { source, images } = this.state; + const { source, sides } = 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; + const sourceDataChanged = + // Has the source object become set/unset? + !!source !== !!prevState.source || + // Or has the processed data changed? + (source && prevState.source && source.processed !== prevState.source.processed); + + for (const [i, side] of sides.entries()) { + const prevSettings = prevState.sides[i].latestSettings; + const encoderChanged = side.latestSettings.encoderState !== prevSettings.encoderState; + const preprocessorChanged = + side.latestSettings.preprocessorState !== prevSettings.preprocessorState; // The image only needs updated if the encoder/preprocessor settings have changed, or the // source has changed. - if (sourceChanged || encoderChanged || preprocessorChanged) { - if (prevImage.downloadUrl) URL.revokeObjectURL(prevImage.downloadUrl); + if (sourceDataChanged || encoderChanged || preprocessorChanged) { this.updateImage(i, { - skipPreprocessing: !sourceChanged && !preprocessorChanged, + skipPreprocessing: !sourceDataChanged && !preprocessorChanged, }).catch((err) => { console.error(err); }); @@ -268,10 +312,10 @@ export default class Compress extends Component { private async onCopyToOtherClick(index: 0 | 1) { const otherIndex = (index + 1) % 2; - const oldSettings = this.state.images[otherIndex]; + const oldSettings = this.state.sides[otherIndex]; this.setState({ - images: cleanSet(this.state.images, otherIndex, this.state.images[index]), + sides: cleanSet(this.state.sides, otherIndex, this.state.sides[index]), }); const result = await this.props.showSnack('Settings copied across', { @@ -282,13 +326,67 @@ export default class Compress extends Component { if (result !== 'undo') return; this.setState({ - images: cleanSet(this.state.images, otherIndex, oldSettings), + sides: cleanSet(this.state.sides, otherIndex, oldSettings), }); } + @bind + private async onInputProcessorChange(options: InputProcessorState): Promise { + const source = this.state.source; + if (!source) return; + + const oldRotate = source.inputProcessorState.rotate.rotate; + const newRotate = options.rotate.rotate; + const orientationChanged = oldRotate % 180 !== newRotate % 180; + const loadingCounter = this.state.loadingCounter + 1; + // Either processor is good enough here. + const processor = this.leftProcessor; + + this.setState({ + loadingCounter, loading: true, + source: cleanSet(source, 'inputProcessorState', options), + }); + + // Abort any current encode jobs, as they're redundant now. + this.leftProcessor.abortCurrent(); + this.rightProcessor.abortCurrent(); + + try { + const processed = await processInput(source.decoded, options, processor); + + // Another file has been opened/processed before this one processed. + if (this.state.loadingCounter !== loadingCounter) return; + + let newState = { ...this.state, loading: false }; + newState = cleanSet(newState, 'source.processed', processed); + newState = stateForNewSourceData(newState, newState.source!); + + if (orientationChanged) { + // If orientation has changed, we should flip the resize values. + for (const i of [0, 1]) { + const resizeSettings = newState.sides[i].latestSettings.preprocessorState.resize; + newState = cleanMerge(newState, `sides.${i}.latestSettings.preprocessorState.resize`, { + width: resizeSettings.height, + height: resizeSettings.width, + }); + } + } + this.setState(newState); + } catch (err) { + if (err.name === 'AbortError') return; + console.error(err); + // Another file has been opened/processed before this one processed. + if (this.state.loadingCounter !== loadingCounter) return; + this.props.showSnack('Processing error'); + this.setState({ loading: false }); + } + } + @bind private async updateFile(file: File | Fileish) { const loadingCounter = this.state.loadingCounter + 1; + // Either processor is good enough here. + const processor = this.leftProcessor; this.setState({ loadingCounter, loading: true }); @@ -297,7 +395,7 @@ export default class Compress extends Component { this.rightProcessor.abortCurrent(); try { - let data: ImageData; + let decoded: ImageData; let vectorImage: HTMLImageElement | undefined; // Special-case SVG. We need to avoid createImageBitmap because of @@ -305,37 +403,33 @@ export default class Compress extends Component { // 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); + decoded = drawableToImageData(vectorImage); } else { // Either processor is good enough here. - data = await decodeImage(file, this.leftProcessor); + decoded = await decodeImage(file, processor); } - // Another file has been opened before this one processed. + const processed = await processInput(decoded, defaultInputProcessorState, processor); + + // Another file has been opened/processed before this one processed. if (this.state.loadingCounter !== loadingCounter) return; let newState: State = { ...this.state, - source: { data, file, vectorImage }, + source: { + decoded, file, vectorImage, processed, + inputProcessorState: defaultInputProcessorState, + }, loading: false, }; + newState = stateForNewSourceData(newState, newState.source!); + 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, + newState = cleanMerge(newState, `sides.${i}.latestSettings.preprocessorState.resize`, { + width: processed.width, + height: processed.height, method: vectorImage ? 'vector' : 'browser-high', }); } @@ -345,7 +439,7 @@ export default class Compress extends Component { } catch (err) { if (err.name === 'AbortError') return; console.error(err); - // Another file has been opened before this one processed. + // Another file has been opened/processed before this one processed. if (this.state.loadingCounter !== loadingCounter) return; this.props.showSnack('Invalid image'); this.setState({ loading: false }); @@ -353,26 +447,31 @@ export default class Compress extends Component { } private async updateImage(index: number, options: UpdateImageOptions = {}): Promise { - const { skipPreprocessing = false } = options; + 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; + const loadingCounter = this.state.sides[index].loadingCounter + 1; - let images = cleanMerge(this.state.images, index, { + let sides = cleanMerge(this.state.sides, index, { loadingCounter, loading: true, }); - this.setState({ images }); + this.setState({ sides }); - const image = images[index]; + const side = sides[index]; + const settings = side.latestSettings; let file: File | Fileish | undefined; let preprocessed: ImageData | undefined; let data: ImageData | undefined; - const cacheResult = this.encodeCache.match(source, image.preprocessorState, image.encoderState); + const cacheResult = this.encodeCache.match( + source.processed, settings.preprocessorState, settings.encoderState, + ); const processor = (index === 0) ? this.leftProcessor : this.rightProcessor; // Abort anything the processor is currently doing. @@ -385,60 +484,66 @@ export default class Compress extends Component { } else { try { // Special case for identity - if (image.encoderState.type === identity.type) { - ({ file, data } = source); + if (settings.encoderState.type === identity.type) { + file = source.file; + data = source.processed; } else { - preprocessed = (skipPreprocessing && image.preprocessed) - ? image.preprocessed - : await preprocessImage(source, image.preprocessorState, processor); + preprocessed = (skipPreprocessing && side.preprocessed) + ? side.preprocessed + : await preprocessImage(source, settings.preprocessorState, processor); - file = await compressImage(preprocessed, image.encoderState, source.file.name, processor); + file = await compressImage( + preprocessed, settings.encoderState, source.file.name, processor, + ); data = await decodeImage(file, processor); this.encodeCache.add({ - source, data, preprocessed, file, - encoderState: image.encoderState, - preprocessorState: image.preprocessorState, + sourceData: source.processed, + encoderState: settings.encoderState, + preprocessorState: settings.preprocessorState, }); } } catch (err) { if (err.name === 'AbortError') return; - this.props.showSnack(`Processing error (type=${image.encoderState.type}): ${err}`); + this.props.showSnack(`Processing error (type=${settings.encoderState.type}): ${err}`); throw err; } } - const latestImage = this.state.images[index]; + const latestData = this.state.sides[index]; // If a later encode has landed before this one, return. - if (loadingCounter < latestImage.loadedCounter) { + if (loadingCounter < latestData.loadedCounter) { return; } - images = cleanMerge(this.state.images, index, { + if (latestData.downloadUrl) URL.revokeObjectURL(latestData.downloadUrl); + + sides = cleanMerge(this.state.sides, index, { file, data, preprocessed, downloadUrl: URL.createObjectURL(file), - loading: images[index].loadingCounter !== loadingCounter, + loading: sides[index].loadingCounter !== loadingCounter, loadedCounter: loadingCounter, + encodedSettings: settings, }); - this.setState({ images }); + this.setState({ sides }); } - render({ onBack }: Props, { loading, images, source, mobileView }: State) { - const [leftImage, rightImage] = images; - const [leftImageData, rightImageData] = images.map(i => i.data); + render({ onBack }: Props, { loading, sides, source, mobileView }: State) { + const [leftSide, rightSide] = sides; + const [leftImageData, rightImageData] = sides.map(i => i.data); - const options = images.map((image, index) => ( + const options = sides.map((side, index) => ( { const copyDirections = (mobileView ? ['down', 'up'] : ['right', 'left']) as CopyAcrossIconProps['copyDirection'][]; - const results = images.map((image, index) => ( + const results = sides.map((side, index) => ( {!mobileView ? null : [ , - `${resultTitles[index]} (${encoderMap[image.encoderState.type].label})`, + `${resultTitles[index]} (${encoderMap[side.latestSettings.encoderState.type].label})`, ]} )); + // For rendering, we ideally want the settings that were used to create the data, not the latest + // settings. + const leftDisplaySettings = leftSide.encodedSettings || leftSide.latestSettings; + const rightDisplaySettings = rightSide.encodedSettings || rightSide.latestSettings; + const leftImgContain = leftDisplaySettings.preprocessorState.resize.enabled && + leftDisplaySettings.preprocessorState.resize.fitMethod === 'contain'; + const rightImgContain = rightDisplaySettings.preprocessorState.resize.enabled && + rightDisplaySettings.preprocessorState.resize.fitMethod === 'contain'; + return (
{mobileView ? ( diff --git a/src/components/compress/result-cache.ts b/src/components/compress/result-cache.ts index 1f06e6c6..aa966e61 100644 --- a/src/components/compress/result-cache.ts +++ b/src/components/compress/result-cache.ts @@ -1,7 +1,6 @@ import { EncoderState } from '../../codecs/encoders'; import { Fileish } from '../../lib/initial-util'; import { shallowEqual } from '../../lib/util'; -import { SourceImage } from '.'; import { PreprocessorState } from '../../codecs/preprocessors'; import * as identity from '../../codecs/identity/encoder-meta'; @@ -15,7 +14,7 @@ interface CacheResult { interface CacheEntry extends CacheResult { preprocessorState: PreprocessorState; encoderState: EncoderState; - source: SourceImage; + sourceData: ImageData; } const SIZE = 5; @@ -32,13 +31,13 @@ export default class ResultCache { } match( - source: SourceImage, + sourceData: ImageData, preprocessorState: PreprocessorState, encoderState: EncoderState, ): CacheResult | undefined { const matchingIndex = this._entries.findIndex((entry) => { // Check for quick exits: - if (entry.source !== source) return false; + if (entry.sourceData !== sourceData) return false; if (entry.encoderState.type !== encoderState.type) return false; // Check that each set of options in the preprocessor are the same diff --git a/src/lib/icons.tsx b/src/lib/icons.tsx index f0202d77..94b9836b 100644 --- a/src/lib/icons.tsx +++ b/src/lib/icons.tsx @@ -12,9 +12,21 @@ export const DownloadIcon = (props: JSX.HTMLAttributes) => ( ); -export const ToggleIcon = (props: JSX.HTMLAttributes) => ( +export const ToggleBackgroundIcon = (props: JSX.HTMLAttributes) => ( - + + +); + +export const ToggleBackgroundActiveIcon = (props: JSX.HTMLAttributes) => ( + + + +); + +export const RotateIcon = (props: JSX.HTMLAttributes) => ( + + );