diff --git a/package-lock.json b/package-lock.json index fb39a8988..6142b4e3c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,7 +41,6 @@ "preact-render-to-string": "^5.1.11", "prettier": "^2.4.1", "rollup": "^2.38.0", - "rollup-plugin-terser": "^7.0.2", "serve": "^11.3.2", "typescript": "^4.4.4", "wasm-feature-detect": "^1.2.11", @@ -3051,20 +3050,6 @@ "node": ">=4" } }, - "node_modules/jest-worker": { - "version": "26.3.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.3.0.tgz", - "integrity": "sha512-Vmpn2F6IASefL+DVBhPzI2J9/GJUsqzomdeN+P+dK8/jKxbh8R3BtFnx3FIta7wYlPU62cpJMJQo4kuOowcMnw==", - "dev": true, - "dependencies": { - "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^7.0.0" - }, - "engines": { - "node": ">= 10.13.0" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -7740,18 +7725,6 @@ "fsevents": "~2.1.2" } }, - "node_modules/rollup-plugin-terser": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz", - "integrity": "sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.10.4", - "jest-worker": "^26.2.1", - "serialize-javascript": "^4.0.0", - "terser": "^5.0.0" - } - }, "node_modules/run-parallel": { "version": "1.1.9", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.1.9.tgz", @@ -7797,15 +7770,6 @@ "integrity": "sha1-De4hahyUGrN+nvsXiPavxf9VN/w=", "dev": true }, - "node_modules/serialize-javascript": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz", - "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==", - "dev": true, - "dependencies": { - "randombytes": "^2.1.0" - } - }, "node_modules/serve": { "version": "11.3.2", "resolved": "https://registry.npmjs.org/serve/-/serve-11.3.2.tgz", @@ -11259,17 +11223,6 @@ } } }, - "jest-worker": { - "version": "26.3.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.3.0.tgz", - "integrity": "sha512-Vmpn2F6IASefL+DVBhPzI2J9/GJUsqzomdeN+P+dK8/jKxbh8R3BtFnx3FIta7wYlPU62cpJMJQo4kuOowcMnw==", - "dev": true, - "requires": { - "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^7.0.0" - } - }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -15171,18 +15124,6 @@ "fsevents": "~2.1.2" } }, - "rollup-plugin-terser": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz", - "integrity": "sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ==", - "dev": true, - "requires": { - "@babel/code-frame": "^7.10.4", - "jest-worker": "^26.2.1", - "serialize-javascript": "^4.0.0", - "terser": "^5.0.0" - } - }, "run-parallel": { "version": "1.1.9", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.1.9.tgz", @@ -15222,15 +15163,6 @@ "integrity": "sha1-De4hahyUGrN+nvsXiPavxf9VN/w=", "dev": true }, - "serialize-javascript": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz", - "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==", - "dev": true, - "requires": { - "randombytes": "^2.1.0" - } - }, "serve": { "version": "11.3.2", "resolved": "https://registry.npmjs.org/serve/-/serve-11.3.2.tgz", diff --git a/src/client/initial-app/App/index.tsx b/src/client/initial-app/App/index.tsx index 36d92a8ac..3a133ad07 100644 --- a/src/client/initial-app/App/index.tsx +++ b/src/client/initial-app/App/index.tsx @@ -25,7 +25,7 @@ interface Props {} interface State { awaitingShareTarget: boolean; - file?: File; + files: File[]; isEditorOpen: Boolean; Compress?: typeof import('client/lazy-app/Compress').default; } @@ -36,7 +36,7 @@ export default class App extends Component { 'share-target', ), isEditorOpen: false, - file: undefined, + files: [], Compress: undefined, }; @@ -60,7 +60,7 @@ export default class App extends Component { // Remove the ?share-target from the URL history.replaceState('', '', '/'); this.openEditor(); - this.setState({ file, awaitingShareTarget: false }); + this.setState({ files: [file], awaitingShareTarget: false }); }); // Since iOS 10, Apple tries to prevent disabling pinch-zoom. This is great in theory, but @@ -76,14 +76,13 @@ export default class App extends Component { private onFileDrop = ({ files }: FileDropEvent) => { if (!files || files.length === 0) return; - const file = files[0]; this.openEditor(); - this.setState({ file }); + this.setState({ files }); }; - private onIntroPickFile = (file: File) => { + private onIntroPickFile = (files: File[]) => { this.openEditor(); - this.setState({ file }); + this.setState({ files }); }; private showSnack = ( @@ -109,21 +108,25 @@ export default class App extends Component { render( {}: Props, - { file, isEditorOpen, Compress, awaitingShareTarget }: State, + { files, isEditorOpen, Compress, awaitingShareTarget }: State, ) { const showSpinner = awaitingShareTarget || (isEditorOpen && !Compress); return (
- + {showSpinner ? ( ) : isEditorOpen ? ( Compress && ( - + ) ) : ( - + )} diff --git a/src/client/lazy-app/Compress/Output/index.tsx b/src/client/lazy-app/Compress/Output/index.tsx index 511ecfeae..1978ebfaf 100644 --- a/src/client/lazy-app/Compress/Output/index.tsx +++ b/src/client/lazy-app/Compress/Output/index.tsx @@ -1,26 +1,26 @@ -import { h, Component, Fragment } from 'preact'; -import type PinchZoom from './custom-els/PinchZoom'; -import type { ScaleToOpts } from './custom-els/PinchZoom'; -import './custom-els/PinchZoom'; -import './custom-els/TwoUp'; -import * as style from './style.css'; import 'add-css:./style.css'; -import { shallowEqual, isSafari } from '../../util'; +import { drawDataToCanvas } from 'client/lazy-app/util/canvas'; +import { Component, Fragment, h, JSX } from 'preact'; +import { linkRef } from 'shared/prerendered-app/util'; +import type { SourceImage } from '../../Compress'; +import type { PreprocessorState } from '../../feature-meta'; import { - ToggleAliasingIcon, - ToggleAliasingActiveIcon, - ToggleBackgroundIcon, AddIcon, RemoveIcon, - ToggleBackgroundActiveIcon, RotateIcon, + ToggleAliasingActiveIcon, + ToggleAliasingIcon, + ToggleBackgroundActiveIcon, + ToggleBackgroundIcon, } from '../../icons'; -import { twoUpHandle } from './custom-els/TwoUp/styles.css'; -import type { PreprocessorState } from '../../feature-meta'; +import { isSafari, shallowEqual } from '../../util'; import { cleanSet } from '../../util/clean-modify'; -import type { SourceImage } from '../../Compress'; -import { linkRef } from 'shared/prerendered-app/util'; -import { drawDataToCanvas } from 'client/lazy-app/util/canvas'; +import './custom-els/PinchZoom'; +import type PinchZoom from './custom-els/PinchZoom'; +import type { ScaleToOpts } from './custom-els/PinchZoom'; +import './custom-els/TwoUp'; +import { twoUpHandle } from './custom-els/TwoUp/styles.css'; +import * as style from './style.css'; interface Props { source?: SourceImage; preprocessorState?: PreprocessorState; @@ -30,6 +30,7 @@ interface Props { leftImgContain: boolean; rightImgContain: boolean; onPreprocessorChange: (newState: PreprocessorState) => void; + children?: JSX.Element; } interface State { @@ -361,6 +362,9 @@ export default class Output extends Component {
+ {this.props.children && ( +
{this.props.children}
+ )}
+ )} + + )); // For rendering, we ideally want the settings that were used to create the @@ -980,20 +1039,40 @@ export default class Compress extends Component { rightImgContain={rightImgContain} preprocessorState={preprocessorState} onPreprocessorChange={this.onPreprocessorChange} - /> - + > + {this.files.length > 1 ? ( + + ) : ( + + )} + +
+ +
{mobileView ? (
diff --git a/src/client/lazy-app/Compress/stages/compress-stage.ts b/src/client/lazy-app/Compress/stages/compress-stage.ts new file mode 100644 index 000000000..e4ae6188c --- /dev/null +++ b/src/client/lazy-app/Compress/stages/compress-stage.ts @@ -0,0 +1,31 @@ +import { encoderMap, EncoderState } from 'client/lazy-app/feature-meta'; +import { assertSignal, ImageMimeTypes } from 'client/lazy-app/util'; +import WorkerBridge from 'client/lazy-app/worker-bridge'; + +export async function compressImage( + signal: AbortSignal, + image: ImageData, + encodeData: EncoderState, + sourceFilename: string, + workerBridge: WorkerBridge, +): Promise { + assertSignal(signal); + + const encoder = encoderMap[encodeData.type]; + const compressedData = await encoder.encode( + signal, + workerBridge, + image, + // The type of encodeData.options is enforced via the previous line + encodeData.options as any, + ); + + // This type ensures the image mimetype is consistent with our mimetype sniffer + const type: ImageMimeTypes = encoder.meta.mimeType; + + return new File( + [compressedData], + sourceFilename.replace(/.[^.]*$/, `.${encoder.meta.extension}`), + { type }, + ); +} diff --git a/src/client/lazy-app/Compress/stages/decode-stage.ts b/src/client/lazy-app/Compress/stages/decode-stage.ts new file mode 100644 index 000000000..68a5ad3bb --- /dev/null +++ b/src/client/lazy-app/Compress/stages/decode-stage.ts @@ -0,0 +1,107 @@ +import { + abortable, + assertSignal, + blobToImg, + blobToText, + builtinDecode, + canDecodeImageType, + sniffMimeType, +} from 'client/lazy-app/util'; +import { drawableToImageData } from 'client/lazy-app/util/canvas'; +import WorkerBridge from 'client/lazy-app/worker-bridge'; + +async function processSvg( + signal: AbortSignal, + blob: Blob, +): Promise { + assertSignal(signal); + // 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 abortable(signal, 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 abortable( + signal, + blobToImg(new Blob([newSource], { type: 'image/svg+xml' })), + ); +} + +export async function decodeBitmap( + signal: AbortSignal, + blob: Blob, + workerBridge: WorkerBridge, +): Promise { + assertSignal(signal); + const mimeType = await abortable(signal, sniffMimeType(blob)); + const canDecode = await abortable(signal, canDecodeImageType(mimeType)); + + try { + if (!canDecode) { + if (mimeType === 'image/avif') { + return await workerBridge.avifDecode(signal, blob); + } + if (mimeType === 'image/webp') { + return await workerBridge.webpDecode(signal, blob); + } + if (mimeType === 'image/jxl') { + return await workerBridge.jxlDecode(signal, blob); + } + if (mimeType === 'image/webp2') { + return await workerBridge.wp2Decode(signal, blob); + } + if (mimeType === 'image/qoi') { + return await workerBridge.qoiDecode(signal, blob); + } + } + // Otherwise fall through and try built-in decoding for a laugh. + return await builtinDecode(signal, blob); + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') throw err; + console.log(err); + throw Error("Couldn't decode image"); + } +} + +/** + * Decode an svg or a bitmap image. + */ +export async function decodeImage( + signal: AbortSignal, + file: File, + workerBridge: WorkerBridge, +): Promise<{ + vectorImage?: HTMLImageElement; + decoded: ImageData; +}> { + assertSignal(signal); + // 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')) { + const vectorImage = await processSvg(signal, file); + const decoded = drawableToImageData(vectorImage); + return { + decoded, + vectorImage, + }; + } + const decoded = await decodeBitmap(signal, file, workerBridge); + + return { decoded }; +} diff --git a/src/client/lazy-app/Compress/stages/preprocess-stage.ts b/src/client/lazy-app/Compress/stages/preprocess-stage.ts new file mode 100644 index 000000000..78ee2f15b --- /dev/null +++ b/src/client/lazy-app/Compress/stages/preprocess-stage.ts @@ -0,0 +1,23 @@ +import { PreprocessorState } from 'client/lazy-app/feature-meta'; +import { assertSignal } from 'client/lazy-app/util'; +import WorkerBridge from 'client/lazy-app/worker-bridge'; + +export async function preprocessImage( + signal: AbortSignal, + data: ImageData, + preprocessorState: PreprocessorState, + workerBridge: WorkerBridge, +): Promise { + assertSignal(signal); + let processedData = data; + + if (preprocessorState.rotate.rotate !== 0) { + processedData = await workerBridge.rotate( + signal, + processedData, + preprocessorState.rotate, + ); + } + + return processedData; +} diff --git a/src/client/lazy-app/Compress/stages/process-stage.ts b/src/client/lazy-app/Compress/stages/process-stage.ts new file mode 100644 index 000000000..589f60de8 --- /dev/null +++ b/src/client/lazy-app/Compress/stages/process-stage.ts @@ -0,0 +1,27 @@ +import { ProcessorState } from 'client/lazy-app/feature-meta'; +import { assertSignal } from 'client/lazy-app/util'; +import WorkerBridge from 'client/lazy-app/worker-bridge'; +import { resize } from 'features/processors/resize/client'; +import { SourceImage } from '..'; + +export async function processImage( + signal: AbortSignal, + source: SourceImage, + processorState: ProcessorState, + workerBridge: WorkerBridge, +): Promise { + assertSignal(signal); + let result = source.preprocessed; + + if (processorState.resize.enabled) { + result = await resize(signal, source, processorState.resize, workerBridge); + } + if (processorState.quantize.enabled) { + result = await workerBridge.quantize( + signal, + result, + processorState.quantize, + ); + } + return result; +} diff --git a/src/client/lazy-app/Compress/style.css b/src/client/lazy-app/Compress/style.css index fa4076157..9f2ea1ea3 100644 --- a/src/client/lazy-app/Compress/style.css +++ b/src/client/lazy-app/Compress/style.css @@ -19,6 +19,54 @@ } } +.download-all-button-left { + background-color: black; + border: none; + padding: 10px 20px; + text-align: center; + text-decoration: none; + display: inline-block; + font-size: 16px; + margin: 4px 2px 20px; + cursor: pointer; + border-radius: 4px; + transition: background-color 0.3s ease; + color: white; + + @media (max-width: 599px) { + width: 100%; + margin: 8px 0; + } +} + +.download-all-button-right:hover { + background-color: rgba(95, 180, 228, 0.8); +} + +.download-all-button-right { + background-color: var(--blue); + border: none; + padding: 10px 20px; + text-align: center; + text-decoration: none; + display: inline-block; + font-size: 16px; + margin: 4px 8px 32px; + cursor: pointer; + border-radius: 4px; + transition: background-color 0.3s ease; + color: black; + + @media (max-width: 599px) { + width: 100%; + margin: 8px 0; + } +} + +.download-all-button-left:hover { + background-color: rgba(0, 0, 0, 0.8); +} + .options { position: relative; color: #fff; @@ -105,8 +153,6 @@ .back { composes: unbutton from global; - position: relative; - grid-area: header; margin: 9px; justify-self: start; align-self: start; @@ -131,6 +177,19 @@ } } +.top { + grid-area: header; + position: relative; + display: flex; + gap: 16px; + align-items: center; + + @media (max-width: 599px) { + align-items: start; + flex-direction: column; + } +} + @keyframes strokePulse { from { stroke-width: 8px; diff --git a/src/client/lazy-app/util/error.ts b/src/client/lazy-app/util/error.ts new file mode 100644 index 000000000..f39e8a6af --- /dev/null +++ b/src/client/lazy-app/util/error.ts @@ -0,0 +1,3 @@ +export function isAbortError(e: unknown) { + return e instanceof Error && e.name === 'AbortError'; +} diff --git a/src/client/lazy-app/worker-bridge/index.ts b/src/client/lazy-app/worker-bridge/index.ts index 1babcef81..6528d2f32 100644 --- a/src/client/lazy-app/worker-bridge/index.ts +++ b/src/client/lazy-app/worker-bridge/index.ts @@ -1,8 +1,8 @@ import { wrap } from 'comlink'; -import { BridgeMethods, methodNames } from './meta'; import workerURL from 'omt:../../../features-worker'; import type { ProcessorWorkerApi } from '../../../features-worker'; import { abortable } from '../util'; +import { BridgeMethods, methodNames } from './meta'; /** How long the worker should be idle before terminating. */ const workerTimeout = 10_000; diff --git a/src/features/processors/resize/client/index.tsx b/src/features/processors/resize/client/index.tsx index 4203fe1c5..4f10fe70c 100644 --- a/src/features/processors/resize/client/index.tsx +++ b/src/features/processors/resize/client/index.tsx @@ -1,31 +1,31 @@ +import type { SourceImage } from 'client/lazy-app/Compress'; +import Checkbox from 'client/lazy-app/Compress/Options/Checkbox'; +import Expander from 'client/lazy-app/Compress/Options/Expander'; +import Select from 'client/lazy-app/Compress/Options/Select'; +import * as style from 'client/lazy-app/Compress/Options/style.css'; +import { + inputFieldChecked, + inputFieldValue, + inputFieldValueAsNumber, + preventDefault, +} from 'client/lazy-app/util'; import { builtinResize, BuiltinResizeMethod, drawableToImageData, } from 'client/lazy-app/util/canvas'; +import type WorkerBridge from 'client/lazy-app/worker-bridge'; +import linkState from 'linkstate'; +import { Component, h } from 'preact'; +import { linkRef } from 'shared/prerendered-app/util'; import { BrowserResizeOptions, - VectorResizeOptions, - WorkerResizeOptions, Options as ResizeOptions, + VectorResizeOptions, workerResizeMethods, + WorkerResizeOptions, } from '../shared/meta'; import { getContainOffsets } from '../shared/util'; -import type { SourceImage } from 'client/lazy-app/Compress'; -import type WorkerBridge from 'client/lazy-app/worker-bridge'; -import { h, Component } from 'preact'; -import linkState from 'linkstate'; -import { - inputFieldValueAsNumber, - inputFieldValue, - preventDefault, - inputFieldChecked, -} from 'client/lazy-app/util'; -import * as style from 'client/lazy-app/Compress/Options/style.css'; -import { linkRef } from 'shared/prerendered-app/util'; -import Select from 'client/lazy-app/Compress/Options/Select'; -import Expander from 'client/lazy-app/Compress/Options/Expander'; -import Checkbox from 'client/lazy-app/Compress/Options/Checkbox'; /** * Return whether a set of options are worker resize options. diff --git a/src/shared/prerendered-app/Intro/index.tsx b/src/shared/prerendered-app/Intro/index.tsx index 462837e31..91bd4a35b 100644 --- a/src/shared/prerendered-app/Intro/index.tsx +++ b/src/shared/prerendered-app/Intro/index.tsx @@ -70,7 +70,7 @@ async function getImageClipboardItem( } interface Props { - onFile?: (file: File) => void; + onFiles?: (file: File[]) => void; showSnack?: SnackBarElement['showSnackbar']; } interface State { @@ -119,10 +119,16 @@ export default class Intro extends Component { private onFileChange = (event: Event): void => { const fileInput = event.target as HTMLInputElement; - const file = fileInput.files && fileInput.files[0]; - if (!file) return; - this.fileInput!.value = ''; - this.props.onFile!(file); + try { + if (!fileInput.files) return; + const files = [...fileInput.files]; + this.props.onFiles!(files); + } catch (e) { + console.error(`Something went wrong while picking files: ${e}`); + return; + } finally { + this.fileInput!.value = ''; + } }; private onOpenClick = () => { @@ -135,7 +141,7 @@ export default class Intro extends Component { const demo = demos[index]; const blob = await fetch(demo.url).then((r) => r.blob()); const file = new File([blob], demo.filename, { type: blob.type }); - this.props.onFile!(file); + this.props.onFiles!([file]); } catch (err) { this.setState({ fetchingDemoIndex: undefined }); this.props.showSnack!("Couldn't fetch demo image"); @@ -218,7 +224,7 @@ export default class Intro extends Component { return; } - this.props.onFile!(new File([blob], 'image.unknown')); + this.props.onFiles!([new File([blob], 'image.unknown')]); }; render( @@ -231,6 +237,7 @@ export default class Intro extends Component { class={style.hide} ref={linkRef(this, 'fileInput')} type="file" + multiple onChange={this.onFileChange} />