Compare commits

..

1 Commits

Author SHA1 Message Date
Surma
ce4010e52b Add analytics script (fixes #174) 2018-11-09 10:35:43 -08:00
46 changed files with 2706 additions and 4592 deletions

13
.babelrc Normal file
View File

@@ -0,0 +1,13 @@
{
"plugins": [
"transform-class-properties",
"transform-react-constant-elements",
"transform-react-remove-prop-types",
[
"transform-react-jsx",
{
"pragma": "h"
}
]
]
}

View File

@@ -1,36 +0,0 @@
---
name: Bug report
about: Something is not working as expected
labels:
---
**Before you start**
Please take a look at the [FAQ](https://github.com/GoogleChromeLabs/squoosh/wiki/FAQ) as well as the already opened issues! If nothing fits your problem, go ahead and fill out the following template:
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Version:**
- OS w/ version: [e.g. iOS 12]
- Browser w/ version [e.g. Chrome 70]
- Node version: [e.g. 10.11.0]
- npm version: [e.g. 6.4.1]
**Is your issue related to the quality of image compression?**
Please attach original and output images (you can drag & drop to attach).
- Original image
- Output image from Squoosh
**Additional context, screenshots, screencasts**
Add any other context about the problem here.

View File

@@ -1,18 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
labels:
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Does other service/app have this feature?**
Add any service you know/use that has this feature (We want to know for research)
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@@ -1,7 +1,5 @@
language: node_js language: node_js
node_js: node_js:
- node - node
- 10
- 8
cache: npm cache: npm
script: npm run build script: npm run build || npm run build # scss ts definitions need to be generated before an actual build

View File

@@ -1,31 +1,5 @@
# [Squoosh]! # Squoosh!
[Squoosh] is an image compression web app that allows you to dive into the advanced options provided Squoosh will be an image compression web app that allows you to dive into the
by various image compressors. advanced options provided by various image compressors.
# Privacy
Google Analytics is used to record the following:
* [Basic visit data](https://support.google.com/analytics/answer/6004245?ref_topic=2919631).
* Before and after image size once an image is downloaded. These values are rounded to the nearest
kilobyte.
Image compression is handled locally; no additional data is sent to the server.
# Building locally
Clone the repo, and:
```sh
npm install
npm run build
```
You can run the development server with:
```sh
npm start
```
[Squoosh]: https://squoosh.app

View File

@@ -1,18 +0,0 @@
# Long-term cache by default.
/*
Cache-Control: max-age=31536000
# And here are the exceptions:
/
Cache-Control: no-cache
/serviceworker.js
Cache-Control: no-cache
/manifest.json
Cache-Control: must-revalidate, max-age=3600
# URLs in /assets do not include a hash and are mutable.
# But it isn't a big deal if the user gets an old version.
/assets/*
Cache-Control: must-revalidate, max-age=3600

View File

@@ -11,6 +11,6 @@ $ npm install
$ npm run build $ npm run build
``` ```
This will build two files: `<codec name>_<enc or dec>.js` and `<codec name>_<enc or dec>.wasm`. It will most likely be necessary to set [`Module["locateFile"]`](https://kripken.github.io/emscripten-site/docs/api_reference/module.html#affecting-execution) to successfully load the `.wasm` file. When the `.js` file is loaded, a global `<codec name>_<enc or dec>` is created with the same API as an [Emscripten `Module`](https://kripken.github.io/emscripten-site/docs/api_reference/module.html). This will build two files: `<codec name>_<enc or dec>.js` and `<codec name>_<enc or dec>.wasm`. It will most likely be necessary to set [`Module["locateFile"]`](https://kripken.github.io/emscripten-site/docs/api_reference/module.html#affecting-execution) to sucessfully load the `.wasm` file. When the `.js` file is loaded, a global `<codec name>_<enc or dec>` is created with the same API as an [Emscripten `Module`](https://kripken.github.io/emscripten-site/docs/api_reference/module.html).
Each codec will document its API in its README. Each codec will document its API in its README.

View File

@@ -1,74 +0,0 @@
const DtsCreator = require('typed-css-modules');
const chokidar = require('chokidar');
const util = require('util');
const sass = require('node-sass');
const sassRender = util.promisify(sass.render);
async function sassToCss(path) {
const result = await sassRender({ file: path });
return result.css;
}
/**
* @typedef {Object} Opts
* @property {boolean} watch Watch for changes
*/
/**
* Create typing files for CSS & SCSS.
*
* @param {string[]} rootPaths Paths to search within
* @param {Opts} [opts={}] Options.
*/
function addCssTypes(rootPaths, opts = {}) {
return new Promise((resolve) => {
const { watch = false } = opts;
const paths = [];
const preReadyPromises = [];
let ready = false;
for (const rootPath of rootPaths) {
// Look for scss & css in each path.
paths.push(rootPath + '/**/*.scss');
paths.push(rootPath + '/**/*.css');
}
// For simplicity, the watcher is used even if we're not watching.
// If we're not watching, we stop the watcher after the initial files are found.
const watcher = chokidar.watch(paths, {
// Avoid processing already-processed files.
ignored: '*.d.*',
// Without this, travis and netlify builds never complete. I'm not sure why, but it might be
// related to https://github.com/paulmillr/chokidar/pull/758
persistent: watch,
});
function change(path) {
const promise = (async function() {
const creator = new DtsCreator({ camelCase: true });
const result = path.endsWith('.scss') ?
await creator.create(path, await sassToCss(path)) :
await creator.create(path);
await result.writeFile();
})();
if (!ready) preReadyPromises.push(promise);
}
watcher.on('change', change);
watcher.on('add', change);
// 'ready' is when events have been fired for file discovery.
watcher.on('ready', () => {
ready = true;
// Wait for the current set of processing to finish.
Promise.all(preReadyPromises).then(resolve);
// And if we're not watching, close the watcher.
if (!watch) watcher.close();
});
})
}
module.exports = addCssTypes;

View File

@@ -1,47 +0,0 @@
const path = require('path');
const fs = require('fs');
const ejs = require('ejs');
const AssetsPlugin = require('assets-webpack-plugin');
module.exports = class AssetTemplatePlugin extends AssetsPlugin {
constructor(options) {
options = options || {};
if (!options.template) throw Error('AssetTemplatePlugin: template option is required.');
super({
useCompilerPath: true,
filename: options.filename,
processOutput: files => this._processOutput(files)
});
this._template = path.resolve(process.cwd(), options.template);
const ignore = options.ignore || /(manifest\.json|\.DS_Store)$/;
this._ignore = typeof ignore === 'function' ? ({ test: ignore }) : ignore;
}
_processOutput(files) {
const mapping = {
all: [],
byType: {},
entries: {}
};
for (const entryName in files) {
// non-entry-point-derived assets are collected under an empty string key
// since that's a bit awkward, we'll call them "assets"
const name = entryName === '' ? 'assets' : entryName;
const listing = files[entryName];
const entry = mapping.entries[name] = {
all: [],
byType: {}
};
for (let type in listing) {
const list = [].concat(listing[type]).filter(file => !this._ignore.test(file));
if (!list.length) continue;
mapping.all = mapping.all.concat(list);
mapping.byType[type] = (mapping.byType[type] || []).concat(list);
entry.all = entry.all.concat(list);
entry.byType[type] = (entry.byType[type] || []).concat(list);
}
}
mapping.files = mapping.all;
return ejs.render(fs.readFileSync(this._template, 'utf8'), mapping);
}
};

3
global.d.ts vendored
View File

@@ -6,8 +6,7 @@ declare interface NodeModule {
} }
declare interface Window { declare interface Window {
STATE: any; STATE: any
ga: typeof ga;
} }
declare namespace JSX { declare namespace JSX {

6176
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,13 @@
{ {
"private": true, "private": true,
"name": "squoosh", "name": "squoosh",
"version": "1.2.2", "version": "0.1.0",
"license": "apache-2.0", "license": "apache-2.0",
"scripts": { "scripts": {
"start": "webpack-dev-server --host 0.0.0.0 --hot", "start": "webpack serve --host 0.0.0.0 --hot",
"build": "webpack -p", "build": "webpack -p",
"lint": "tslint -c tslint.json -p tsconfig.json -t verbose 'src/**/*.{ts,tsx,js,jsx}'", "lint": "tslint -c tslint.json -t verbose 'src/**/*.{ts,tsx,js,jsx}'",
"lintfix": "tslint -c tslint.json -p tsconfig.json -t verbose --fix 'src/**/*.{ts,tsx,js,jsx}'" "lintfix": "tslint -c tslint.json -t verbose --fix 'src/**/*.{ts,tsx,js,jsx}'"
}, },
"husky": { "husky": {
"hooks": { "hooks": {
@@ -15,55 +15,62 @@
} }
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^10.12.6", "@types/node": "^9.6.35",
"@types/pretty-bytes": "^5.1.0", "@types/pretty-bytes": "^5.1.0",
"@types/webassembly-js-api": "0.0.1", "@types/webassembly-js-api": "0.0.1",
"@webcomponents/custom-elements": "^1.2.1", "@webcomponents/custom-elements": "^1.2.1",
"@webpack-cli/serve": "^0.1.2", "babel-loader": "^7.1.5",
"assets-webpack-plugin": "^3.9.7", "babel-plugin-jsx-pragmatic": "^1.0.2",
"chokidar": "^2.0.4", "babel-plugin-syntax-dynamic-import": "^6.18.0",
"babel-plugin-transform-class-properties": "^6.24.1",
"babel-plugin-transform-decorators-legacy": "^1.3.5",
"babel-plugin-transform-object-rest-spread": "^6.26.0",
"babel-plugin-transform-react-constant-elements": "^6.23.0",
"babel-plugin-transform-react-jsx": "^6.24.1",
"babel-plugin-transform-react-remove-prop-types": "^0.4.19",
"babel-preset-env": "^1.7.0",
"babel-register": "^6.26.0",
"classnames": "^2.2.6", "classnames": "^2.2.6",
"clean-webpack-plugin": "^1.0.0", "clean-webpack-plugin": "^0.1.19",
"comlink": "^3.0.3", "comlink": "^3.0.3",
"copy-webpack-plugin": "^4.6.0", "copy-webpack-plugin": "^4.5.3",
"critters-webpack-plugin": "^2.0.1", "critters-webpack-plugin": "^2.0.1",
"css-loader": "^1.0.1", "css-loader": "^0.28.11",
"ejs": "^2.6.1",
"exports-loader": "^0.7.0", "exports-loader": "^0.7.0",
"file-drop-element": "^0.0.9", "file-drop-element": "^0.0.9",
"file-loader": "^2.0.0", "file-loader": "^1.1.11",
"html-webpack-plugin": "^3.2.0", "html-webpack-plugin": "^3.2.0",
"husky": "^1.1.4", "husky": "^1.1.2",
"idb-keyval": "^3.1.0", "idb-keyval": "^3.1.0",
"if-env": "^1.0.4",
"linkstate": "^1.1.1", "linkstate": "^1.1.1",
"loader-utils": "^1.1.0", "loader-utils": "^1.1.0",
"mini-css-extract-plugin": "^0.4.4",
"minimatch": "^3.0.4",
"node-sass": "^4.11.0",
"optimize-css-assets-webpack-plugin": "^5.0.1",
"pointer-tracker": "^2.0.3", "pointer-tracker": "^2.0.3",
"minimatch": "^3.0.4",
"mini-css-extract-plugin": "^0.4.4",
"node-sass": "^4.9.4",
"optimize-css-assets-webpack-plugin": "^4.0.3",
"preact": "^8.3.1", "preact": "^8.3.1",
"prerender-loader": "^1.2.0", "prerender-loader": "^1.2.0",
"pretty-bytes": "^5.1.0", "pretty-bytes": "^5.1.0",
"progress-bar-webpack-plugin": "^1.11.0", "progress-bar-webpack-plugin": "^1.11.0",
"raw-loader": "^0.5.1", "raw-loader": "^0.5.1",
"sass-loader": "^7.1.0", "sass-loader": "^7.1.0",
"script-ext-html-webpack-plugin": "^2.1.3", "script-ext-html-webpack-plugin": "^2.0.1",
"source-map-loader": "^0.2.4", "source-map-loader": "^0.2.3",
"style-loader": "^0.23.1", "style-loader": "^0.22.1",
"terser-webpack-plugin": "^1.1.0", "ts-loader": "^4.4.2",
"ts-loader": "^5.3.0",
"tslint": "^5.11.0", "tslint": "^5.11.0",
"tslint-config-airbnb": "^5.11.0", "tslint-config-airbnb": "^5.9.2",
"tslint-config-semistandard": "^7.0.0", "tslint-config-semistandard": "^7.0.0",
"tslint-react": "^3.6.0", "tslint-react": "^3.6.0",
"typed-css-modules": "^0.3.7", "typescript": "^2.9.2",
"typescript": "^3.1.6", "typings-for-css-modules-loader": "^1.7.0",
"url-loader": "^1.1.2", "url-loader": "^1.1.2",
"webpack": "^4.25.1", "webpack": "^4.19.1",
"webpack-bundle-analyzer": "^3.0.3", "webpack-bundle-analyzer": "^2.13.1",
"webpack-cli": "^3.1.2", "webpack-cli": "^2.1.5",
"webpack-dev-server": "^3.1.10", "webpack-dev-server": "^3.1.5",
"worker-plugin": "^1.1.1" "worker-plugin": "^1.1.1"
} }
} }

View File

@@ -1,6 +1,6 @@
import { h, Component } from 'preact'; import { h, Component } from 'preact';
import { bind } from '../../lib/initial-util'; import { bind } from '../../lib/initial-util';
import { inputFieldValueAsNumber, konami, preventDefault } from '../../lib/util'; import { inputFieldValueAsNumber, konami } from '../../lib/util';
import { QuantizeOptions } from './processor-meta'; import { QuantizeOptions } from './processor-meta';
import * as style from '../../components/Options/style.scss'; import * as style from '../../components/Options/style.scss';
import Expander from '../../components/expander'; import Expander from '../../components/expander';
@@ -42,7 +42,7 @@ export default class QuantizerOptions extends Component<Props, State> {
render({ options }: Props, { extendedSettings }: State) { render({ options }: Props, { extendedSettings }: State) {
return ( return (
<form class={style.optionsSection} onSubmit={preventDefault}> <form class={style.optionsSection}>
<Expander> <Expander>
{extendedSettings ? {extendedSettings ?
<label class={style.optionTextFirst}> <label class={style.optionTextFirst}>

View File

@@ -1,9 +0,0 @@
import { defaultOptions as rotateDefaultOptions } from './rotate/processor-meta';
export interface InputProcessorState {
rotate: import('./rotate/processor-meta').RotateOptions;
}
export const defaultInputProcessorState: InputProcessorState = {
rotate: rotateDefaultOptions,
};

View File

@@ -1,6 +1,6 @@
import { h, Component } from 'preact'; import { h, Component } from 'preact';
import { bind } from '../../lib/initial-util'; import { bind } from '../../lib/initial-util';
import { inputFieldChecked, inputFieldValueAsNumber, preventDefault } from '../../lib/util'; import { inputFieldChecked, inputFieldValueAsNumber } from '../../lib/util';
import { EncodeOptions, MozJpegColorSpace } from './encoder-meta'; import { EncodeOptions, MozJpegColorSpace } from './encoder-meta';
import * as style from '../../components/Options/style.scss'; import * as style from '../../components/Options/style.scss';
import Checkbox from '../../components/checkbox'; import Checkbox from '../../components/checkbox';
@@ -58,7 +58,7 @@ export default class MozJPEGEncoderOptions extends Component<Props, State> {
// I'm rendering both lossy and lossless forms, as it becomes much easier when // I'm rendering both lossy and lossless forms, as it becomes much easier when
// gathering the data. // gathering the data.
return ( return (
<form class={style.optionsSection} onSubmit={preventDefault}> <form class={style.optionsSection}>
<div class={style.optionOneCell}> <div class={style.optionOneCell}>
<Range <Range
name="quality" name="quality"

View File

@@ -1,6 +1,6 @@
import { h, Component } from 'preact'; import { h, Component } from 'preact';
import { bind } from '../../lib/initial-util'; import { bind } from '../../lib/initial-util';
import { inputFieldValueAsNumber, preventDefault } from '../../lib/util'; import { inputFieldValueAsNumber } from '../../lib/util';
import { EncodeOptions } from './encoder-meta'; import { EncodeOptions } from './encoder-meta';
import Range from '../../components/range'; import Range from '../../components/range';
import * as style from '../../components/Options/style.scss'; import * as style from '../../components/Options/style.scss';
@@ -23,7 +23,7 @@ export default class OptiPNGEncoderOptions extends Component<Props, {}> {
render({ options }: Props) { render({ options }: Props) {
return ( return (
<form class={style.optionsSection} onSubmit={preventDefault}> <form class={style.optionsSection}>
<div class={style.optionOneCell}> <div class={style.optionOneCell}>
<Range <Range
name="level" name="level"

View File

@@ -1,52 +1,43 @@
import { expose } from 'comlink'; 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( async function mozjpegEncode(
data: ImageData, options: import('../mozjpeg/encoder-meta').EncodeOptions, data: ImageData, options: MozJPEGEncoderOptions,
): Promise<ArrayBuffer> { ): Promise<ArrayBuffer> {
const { encode } = await import( const { encode } = await import(
/* webpackChunkName: "process-mozjpeg-enc" */ /* webpackChunkName: "process-mozjpeg-enc" */
'../mozjpeg/encoder', './mozjpeg/encoder',
); );
return encode(data, options); return encode(data, options);
} }
async function quantize( async function quantize(data: ImageData, opts: QuantizeOptions): Promise<ImageData> {
data: ImageData, opts: import('../imagequant/processor-meta').QuantizeOptions,
): Promise<ImageData> {
const { process } = await import( const { process } = await import(
/* webpackChunkName: "process-imagequant" */ /* webpackChunkName: "process-imagequant" */
'../imagequant/processor', './imagequant/processor',
); );
return process(data, opts); return process(data, opts);
} }
async function rotate(
data: ImageData, opts: import('../rotate/processor-meta').RotateOptions,
): Promise<ImageData> {
const { rotate } = await import(
/* webpackChunkName: "process-rotate" */
'../rotate/processor',
);
return rotate(data, opts);
}
async function optiPngEncode( async function optiPngEncode(
data: BufferSource, options: import('../optipng/encoder-meta').EncodeOptions, data: BufferSource, options: OptiPNGEncoderOptions,
): Promise<ArrayBuffer> { ): Promise<ArrayBuffer> {
const { compress } = await import( const { compress } = await import(
/* webpackChunkName: "process-optipng" */ /* webpackChunkName: "process-optipng" */
'../optipng/encoder', './optipng/encoder',
); );
return compress(data, options); return compress(data, options);
} }
async function webpEncode( async function webpEncode(
data: ImageData, options: import('../webp/encoder-meta').EncodeOptions, data: ImageData, options: WebPEncoderOptions,
): Promise<ArrayBuffer> { ): Promise<ArrayBuffer> {
const { encode } = await import( const { encode } = await import(
/* webpackChunkName: "process-webp-enc" */ /* webpackChunkName: "process-webp-enc" */
'../webp/encoder', './webp/encoder',
); );
return encode(data, options); return encode(data, options);
} }
@@ -54,12 +45,12 @@ async function webpEncode(
async function webpDecode(data: ArrayBuffer): Promise<ImageData> { async function webpDecode(data: ArrayBuffer): Promise<ImageData> {
const { decode } = await import( const { decode } = await import(
/* webpackChunkName: "process-webp-dec" */ /* webpackChunkName: "process-webp-dec" */
'../webp/decoder', './webp/decoder',
); );
return decode(data); return decode(data);
} }
const exports = { mozjpegEncode, quantize, rotate, optiPngEncode, webpEncode, webpDecode }; const exports = { mozjpegEncode, quantize, optiPngEncode, webpEncode, webpDecode };
export type ProcessorWorkerApi = typeof exports; export type ProcessorWorkerApi = typeof exports;
expose(exports, self); expose(exports, self);

View File

@@ -1,18 +0,0 @@
{
"compileOnSave": false,
"compilerOptions": {
"strict": true,
"target": "esnext",
"module": "esnext",
"lib": [
"webworker",
"esnext"
],
"moduleResolution": "node",
"experimentalDecorators": true,
"noUnusedLocals": true,
"sourceMap": true,
"allowJs": false,
"baseUrl": "."
}
}

View File

@@ -1,5 +1,6 @@
import { proxy } from 'comlink'; import { proxy } from 'comlink';
import { QuantizeOptions } from './imagequant/processor-meta'; import { QuantizeOptions } from './imagequant/processor-meta';
import { ProcessorWorkerApi } from './processor-worker';
import { canvasEncode, blobToArrayBuffer } from '../lib/util'; import { canvasEncode, blobToArrayBuffer } from '../lib/util';
import { EncodeOptions as MozJPEGEncoderOptions } from './mozjpeg/encoder-meta'; import { EncodeOptions as MozJPEGEncoderOptions } from './mozjpeg/encoder-meta';
import { EncodeOptions as OptiPNGEncoderOptions } from './optipng/encoder-meta'; import { EncodeOptions as OptiPNGEncoderOptions } from './optipng/encoder-meta';
@@ -17,8 +18,6 @@ import * as browserTIFF from './browser-tiff/encoder';
import * as browserJP2 from './browser-jp2/encoder'; import * as browserJP2 from './browser-jp2/encoder';
import * as browserPDF from './browser-pdf/encoder'; import * as browserPDF from './browser-pdf/encoder';
type ProcessorWorkerApi = import('./processor-worker').ProcessorWorkerApi;
/** How long the worker should be idle before terminating. */ /** How long the worker should be idle before terminating. */
const workerTimeout = 1000; const workerTimeout = 1000;
@@ -63,7 +62,7 @@ export default class Processor {
// @ts-ignore - Typescript doesn't know about the 2nd param to new Worker, and the // @ts-ignore - Typescript doesn't know about the 2nd param to new Worker, and the
// definition can't be overwritten. // definition can't be overwritten.
this._worker = new Worker( this._worker = new Worker(
'./processor-worker', './processor-worker.ts',
{ name: 'processor-worker', type: 'module' }, { name: 'processor-worker', type: 'module' },
) as Worker; ) as Worker;
// Need to do some TypeScript trickery to make the type match. // Need to do some TypeScript trickery to make the type match.
@@ -118,18 +117,12 @@ export default class Processor {
} }
// Off main thread jobs: // Off main thread jobs:
@Processor._processingJob({ needsWorker: true }) @Processor._processingJob({ needsWorker: true })
imageQuant(data: ImageData, opts: QuantizeOptions): Promise<ImageData> { imageQuant(data: ImageData, opts: QuantizeOptions): Promise<ImageData> {
return this._workerApi!.quantize(data, opts); return this._workerApi!.quantize(data, opts);
} }
@Processor._processingJob({ needsWorker: true })
rotate(
data: ImageData, opts: import('./rotate/processor-meta').RotateOptions,
): Promise<ImageData> {
return this._workerApi!.rotate(data, opts);
}
@Processor._processingJob({ needsWorker: true }) @Processor._processingJob({ needsWorker: true })
mozjpegEncode( mozjpegEncode(
data: ImageData, opts: MozJPEGEncoderOptions, data: ImageData, opts: MozJPEGEncoderOptions,

View File

@@ -1,7 +1,7 @@
import { h, Component } from 'preact'; import { h, Component } from 'preact';
import linkState from 'linkstate'; import linkState from 'linkstate';
import { bind, linkRef } from '../../lib/initial-util'; import { bind, linkRef } from '../../lib/initial-util';
import { inputFieldValueAsNumber, inputFieldValue, preventDefault } from '../../lib/util'; import { inputFieldValueAsNumber, inputFieldValue } from '../../lib/util';
import { ResizeOptions } from './processor-meta'; import { ResizeOptions } from './processor-meta';
import * as style from '../../components/Options/style.scss'; import * as style from '../../components/Options/style.scss';
import Checkbox from '../../components/checkbox'; import Checkbox from '../../components/checkbox';
@@ -78,7 +78,7 @@ export default class ResizerOptions extends Component<Props, State> {
render({ options, isVector }: Props, { maintainAspect }: State) { render({ options, isVector }: Props, { maintainAspect }: State) {
return ( return (
<form ref={linkRef(this, 'form')} class={style.optionsSection} onSubmit={preventDefault}> <form ref={linkRef(this, 'form')} class={style.optionsSection}>
<label class={style.optionTextFirst}> <label class={style.optionTextFirst}>
Method: Method:
<Select <Select
@@ -135,7 +135,7 @@ export default class ResizerOptions extends Component<Props, State> {
onChange={this.onChange} onChange={this.onChange}
> >
<option value="stretch">Stretch</option> <option value="stretch">Stretch</option>
<option value="contain">Contain</option> <option value="cover">Cover</option>
</Select> </Select>
</label> </label>
} }

View File

@@ -4,7 +4,7 @@ export interface ResizeOptions {
width: number; width: number;
height: number; height: number;
method: 'vector' | BitmapResizeMethods; method: 'vector' | BitmapResizeMethods;
fitMethod: 'stretch' | 'contain'; fitMethod: 'stretch' | 'cover';
} }
export interface BitmapResizeOptions extends ResizeOptions { export interface BitmapResizeOptions extends ResizeOptions {

View File

@@ -1,7 +1,7 @@
import { nativeResize, NativeResizeMethod, drawableToImageData } from '../../lib/util'; import { nativeResize, NativeResizeMethod, drawableToImageData } from '../../lib/util';
import { BitmapResizeOptions, VectorResizeOptions } from './processor-meta'; import { BitmapResizeOptions, VectorResizeOptions } from './processor-meta';
function getContainOffsets(sw: number, sh: number, dw: number, dh: number) { function getCoverOffsets(sw: number, sh: number, dw: number, dh: number) {
const currentAspect = sw / sh; const currentAspect = sw / sh;
const endAspect = dw / dh; const endAspect = dw / dh;
@@ -22,8 +22,8 @@ export function resize(data: ImageData, opts: BitmapResizeOptions): ImageData {
let sw = data.width; let sw = data.width;
let sh = data.height; let sh = data.height;
if (opts.fitMethod === 'contain') { if (opts.fitMethod === 'cover') {
({ sx, sy, sw, sh } = getContainOffsets(sw, sh, opts.width, opts.height)); ({ sx, sy, sw, sh } = getCoverOffsets(sw, sh, opts.width, opts.height));
} }
return nativeResize( return nativeResize(
@@ -38,8 +38,8 @@ export function vectorResize(data: HTMLImageElement, opts: VectorResizeOptions):
let sw = data.width; let sw = data.width;
let sh = data.height; let sh = data.height;
if (opts.fitMethod === 'contain') { if (opts.fitMethod === 'cover') {
({ sx, sy, sw, sh } = getContainOffsets(sw, sh, opts.width, opts.height)); ({ sx, sy, sw, sh } = getCoverOffsets(sw, sh, opts.width, opts.height));
} }
return drawableToImageData(data, { return drawableToImageData(data, {

View File

@@ -1,5 +0,0 @@
export interface RotateOptions {
rotate: 0 | 90 | 180 | 270;
}
export const defaultOptions: RotateOptions = { rotate: 0 };

View File

@@ -1,80 +0,0 @@
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;
}

View File

@@ -1,6 +1,6 @@
import { h, Component } from 'preact'; import { h, Component } from 'preact';
import { bind } from '../../lib/initial-util'; import { bind } from '../../lib/initial-util';
import { inputFieldCheckedAsNumber, inputFieldValueAsNumber, preventDefault } from '../../lib/util'; import { inputFieldCheckedAsNumber, inputFieldValueAsNumber } from '../../lib/util';
import { EncodeOptions, WebPImageHint } from './encoder-meta'; import { EncodeOptions, WebPImageHint } from './encoder-meta';
import * as style from '../../components/Options/style.scss'; import * as style from '../../components/Options/style.scss';
import Checkbox from '../../components/checkbox'; import Checkbox from '../../components/checkbox';
@@ -319,7 +319,7 @@ export default class WebPEncoderOptions extends Component<Props, State> {
// I'm rendering both lossy and lossless forms, as it becomes much easier when // I'm rendering both lossy and lossless forms, as it becomes much easier when
// gathering the data. // gathering the data.
return ( return (
<form class={style.optionsSection} onSubmit={preventDefault}> <form class={style.optionsSection}>
<label class={style.optionInputFirst}> <label class={style.optionInputFirst}>
<Checkbox <Checkbox
name="lossless" name="lossless"

View File

@@ -9,6 +9,9 @@ import '../../lib/SnackBar';
import Intro from '../intro'; import Intro from '../intro';
import '../custom-els/LoadingSpinner'; import '../custom-els/LoadingSpinner';
// This is imported for TypeScript only. It isn't used.
import Compress from '../compress';
const compressPromise = import( const compressPromise = import(
/* webpackChunkName: "main-app" */ /* webpackChunkName: "main-app" */
'../compress', '../compress',
@@ -18,11 +21,17 @@ const offlinerPromise = import(
'../../lib/offliner', '../../lib/offliner',
); );
export interface SourceImage {
file: File | Fileish;
data: ImageData;
vectorImage?: HTMLImageElement;
}
interface Props {} interface Props {}
interface State { interface State {
file?: File | Fileish; file?: File | Fileish;
Compress?: typeof import('../compress').default; Compress?: typeof Compress;
} }
export default class App extends Component<Props, State> { export default class App extends Component<Props, State> {

View File

@@ -35,7 +35,7 @@ import {
import { QuantizeOptions } from '../../codecs/imagequant/processor-meta'; import { QuantizeOptions } from '../../codecs/imagequant/processor-meta';
import { ResizeOptions } from '../../codecs/resize/processor-meta'; import { ResizeOptions } from '../../codecs/resize/processor-meta';
import { PreprocessorState } from '../../codecs/preprocessors'; import { PreprocessorState } from '../../codecs/preprocessors';
import { SourceImage } from '../compress'; import { SourceImage } from '../App';
import Checkbox from '../checkbox'; import Checkbox from '../checkbox';
import Expander from '../expander'; import Expander from '../expander';
import Select from '../select'; import Select from '../select';
@@ -81,7 +81,7 @@ export default class Options extends Component<Props, State> {
} }
@bind @bind
private onEncoderTypeChange(event: Event) { onEncoderTypeChange(event: Event) {
const el = event.currentTarget as HTMLSelectElement; const el = event.currentTarget as HTMLSelectElement;
// The select element only has values matching encoder types, // The select element only has values matching encoder types,
@@ -91,7 +91,7 @@ export default class Options extends Component<Props, State> {
} }
@bind @bind
private onPreprocessorEnabledChange(event: Event) { onPreprocessorEnabledChange(event: Event) {
const el = event.currentTarget as HTMLInputElement; const el = event.currentTarget as HTMLInputElement;
const preprocessor = el.name.split('.')[0] as keyof PreprocessorState; const preprocessor = el.name.split('.')[0] as keyof PreprocessorState;
@@ -101,14 +101,14 @@ export default class Options extends Component<Props, State> {
} }
@bind @bind
private onQuantizerOptionsChange(opts: QuantizeOptions) { onQuantizerOptionsChange(opts: QuantizeOptions) {
this.props.onPreprocessorOptionsChange( this.props.onPreprocessorOptionsChange(
cleanMerge(this.props.preprocessorState, 'quantizer', opts), cleanMerge(this.props.preprocessorState, 'quantizer', opts),
); );
} }
@bind @bind
private onResizeOptionsChange(opts: ResizeOptions) { onResizeOptionsChange(opts: ResizeOptions) {
this.props.onPreprocessorOptionsChange( this.props.onPreprocessorOptionsChange(
cleanMerge(this.props.preprocessorState, 'resize', opts), cleanMerge(this.props.preprocessorState, 'resize', opts),
); );
@@ -144,7 +144,7 @@ export default class Options extends Component<Props, State> {
{preprocessorState.resize.enabled ? {preprocessorState.resize.enabled ?
<ResizeOptionsComponent <ResizeOptionsComponent
isVector={Boolean(source && source.vectorImage)} isVector={Boolean(source && source.vectorImage)}
aspect={source ? source.processed.width / source.processed.height : 1} aspect={source ? (source.data.width / source.data.height) : 1}
options={preprocessorState.resize} options={preprocessorState.resize}
onChange={this.onResizeOptionsChange} onChange={this.onResizeOptionsChange}
/> />

View File

@@ -5,29 +5,17 @@ import './custom-els/TwoUp';
import * as style from './style.scss'; import * as style from './style.scss';
import { bind, linkRef } from '../../lib/initial-util'; import { bind, linkRef } from '../../lib/initial-util';
import { shallowEqual, drawDataToCanvas } from '../../lib/util'; import { shallowEqual, drawDataToCanvas } from '../../lib/util';
import { import { ToggleIcon, AddIcon, RemoveIcon, BackIcon } from '../../lib/icons';
ToggleBackgroundIcon,
AddIcon,
RemoveIcon,
BackIcon,
ToggleBackgroundActiveIcon,
RotateIcon,
} from '../../lib/icons';
import { twoUpHandle } from './custom-els/TwoUp/styles.css'; 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 { interface Props {
source?: SourceImage; originalImage?: ImageData;
inputProcessorState?: InputProcessorState;
mobileView: boolean; mobileView: boolean;
leftCompressed?: ImageData; leftCompressed?: ImageData;
rightCompressed?: ImageData; rightCompressed?: ImageData;
leftImgContain: boolean; leftImgContain: boolean;
rightImgContain: boolean; rightImgContain: boolean;
onBack: () => void; onBack: () => void;
onInputProcessorChange: (newState: InputProcessorState) => void;
} }
interface State { interface State {
@@ -60,15 +48,6 @@ export default class Output extends Component<Props, State> {
const leftDraw = this.leftDrawable(); const leftDraw = this.leftDrawable();
const rightDraw = this.rightDrawable(); const rightDraw = this.rightDrawable();
// Reset the pinch zoom, which may have an position set from the previous view, after pressing
// the back button.
this.pinchZoomLeft!.setTransform({
allowChangeEvent: true,
x: 0,
y: 0,
scale: 1,
});
if (this.canvasLeft && leftDraw) { if (this.canvasLeft && leftDraw) {
drawDataToCanvas(this.canvasLeft, leftDraw); drawDataToCanvas(this.canvasLeft, leftDraw);
} }
@@ -82,38 +61,6 @@ export default class Output extends Component<Props, State> {
const prevRightDraw = this.rightDrawable(prevProps); const prevRightDraw = this.rightDrawable(prevProps);
const leftDraw = this.leftDrawable(); const leftDraw = this.leftDrawable();
const rightDraw = this.rightDrawable(); 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) { if (leftDraw && leftDraw !== prevLeftDraw && this.canvasLeft) {
drawDataToCanvas(this.canvasLeft, leftDraw); drawDataToCanvas(this.canvasLeft, leftDraw);
@@ -121,6 +68,16 @@ export default class Output extends Component<Props, State> {
if (rightDraw && rightDraw !== prevRightDraw && this.canvasRight) { if (rightDraw && rightDraw !== prevRightDraw && this.canvasRight) {
drawDataToCanvas(this.canvasRight, rightDraw); 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) { shouldComponentUpdate(nextProps: Props, nextState: State) {
@@ -128,11 +85,11 @@ export default class Output extends Component<Props, State> {
} }
private leftDrawable(props: Props = this.props): ImageData | undefined { private leftDrawable(props: Props = this.props): ImageData | undefined {
return props.leftCompressed || (props.source && props.source.processed); return props.leftCompressed || props.originalImage;
} }
private rightDrawable(props: Props = this.props): ImageData | undefined { private rightDrawable(props: Props = this.props): ImageData | undefined {
return props.rightCompressed || (props.source && props.source.processed); return props.rightCompressed || props.originalImage;
} }
@bind @bind
@@ -156,20 +113,6 @@ export default class Output extends Component<Props, State> {
this.pinchZoomLeft.scaleTo(this.state.scale / 1.25, scaleToOpts); 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 @bind
private onScaleValueFocus() { private onScaleValueFocus() {
this.setState({ editingScale: true }, () => { this.setState({ editingScale: true }, () => {
@@ -249,13 +192,11 @@ export default class Output extends Component<Props, State> {
} }
render( render(
{ mobileView, leftImgContain, rightImgContain, source, onBack }: Props, { mobileView, leftImgContain, rightImgContain, originalImage, onBack }: Props,
{ scale, editingScale, altBackground }: State, { scale, editingScale, altBackground }: State,
) { ) {
const leftDraw = this.leftDrawable(); const leftDraw = this.leftDrawable();
const rightDraw = this.rightDrawable(); 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 ( return (
<div class={`${style.output} ${altBackground ? style.altBackground : ''}`}> <div class={`${style.output} ${altBackground ? style.altBackground : ''}`}>
@@ -277,7 +218,7 @@ export default class Output extends Component<Props, State> {
ref={linkRef(this, 'pinchZoomLeft')} ref={linkRef(this, 'pinchZoomLeft')}
> >
<canvas <canvas
class={style.pinchTarget} class={style.outputCanvas}
ref={linkRef(this, 'canvasLeft')} ref={linkRef(this, 'canvasLeft')}
width={leftDraw && leftDraw.width} width={leftDraw && leftDraw.width}
height={leftDraw && leftDraw.height} height={leftDraw && leftDraw.height}
@@ -290,7 +231,7 @@ export default class Output extends Component<Props, State> {
</pinch-zoom> </pinch-zoom>
<pinch-zoom class={style.pinchZoom} ref={linkRef(this, 'pinchZoomRight')}> <pinch-zoom class={style.pinchZoom} ref={linkRef(this, 'pinchZoomRight')}>
<canvas <canvas
class={style.pinchTarget} class={style.outputCanvas}
ref={linkRef(this, 'canvasRight')} ref={linkRef(this, 'canvasRight')}
width={rightDraw && rightDraw.width} width={rightDraw && rightDraw.width}
height={rightDraw && rightDraw.height} height={rightDraw && rightDraw.height}
@@ -336,21 +277,10 @@ export default class Output extends Component<Props, State> {
<AddIcon /> <AddIcon />
</button> </button>
</div> </div>
<div class={style.buttonsNoWrap}> <button class={style.button} onClick={this.toggleBackground}>
<button class={style.button} onClick={this.onRotateClick} title="Rotate image"> <ToggleIcon />
<RotateIcon /> Toggle Background
</button> </button>
<button
class={`${style.button} ${altBackground ? style.active : ''}`}
onClick={this.toggleBackground}
title="Change canvas color"
>
{altBackground
? <ToggleBackgroundActiveIcon />
: <ToggleBackgroundIcon />
}
</button>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -31,15 +31,6 @@
align-items: center; align-items: center;
} }
.pinch-target {
// This fixes a severe painting bug in Chrome.
// We should try to remove this once the issue is fixed.
// https://bugs.chromium.org/p/chromium/issues/detail?id=870222#c10
will-change: auto;
// Prevent the image becoming misshapen due to default flexbox layout.
flex-shrink: 0;
}
.controls { .controls {
position: absolute; position: absolute;
display: flex; display: flex;
@@ -64,7 +55,6 @@
left: 320px; left: 320px;
right: 320px; right: 320px;
bottom: 0; bottom: 0;
flex-wrap: wrap-reverse;
} }
} }
@@ -97,7 +87,6 @@
white-space: nowrap; white-space: nowrap;
height: 36px; height: 36px;
padding: 0 8px; padding: 0 8px;
cursor: pointer;
@media (min-width: 600px) { @media (min-width: 600px) {
height: 48px; height: 48px;
@@ -112,20 +101,15 @@
} }
.button { .button {
text-transform: uppercase;
color: var(--button-fg); color: var(--button-fg);
cursor: pointer;
text-indent: 6px;
font-size: 110%;
&:hover { &:hover {
background-color: #eee; background-color: #eee;
} }
&.active {
background: #34B9EB;
color: #fff;
&:hover {
background: #32a3ce;
}
}
} }
.zoom { .zoom {
@@ -149,18 +133,17 @@
border-bottom: 1px dashed #999; 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 { .back {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
padding: 9px; padding: 9px;
} }
.buttons-no-wrap {
display: flex;
pointer-events: none;
& > * {
pointer-events: auto;
}
}

View File

@@ -103,7 +103,7 @@ export default class MultiPanel extends HTMLElement {
// KeyDown event handler // KeyDown event handler
private _onKeyDown(event: KeyboardEvent) { private _onKeyDown(event: KeyboardEvent) {
const selectedEl = document.activeElement!; const selectedEl = document.activeElement;
const heading = getClosestHeading(selectedEl); const heading = getClosestHeading(selectedEl);
// if keydown event is not on heading element, ignore // if keydown event is not on heading element, ignore
@@ -253,7 +253,7 @@ export default class MultiPanel extends HTMLElement {
} }
// previous Element of active Element is previous Content, // previous Element of active Element is previous Content,
// previous Element of previous Content is previousHeading // previous Element of previous Content is previousHeading
const previousContent = document.activeElement!.previousElementSibling; const previousContent = document.activeElement.previousElementSibling;
if (previousContent) { if (previousContent) {
return previousContent.previousElementSibling as HTMLElement; return previousContent.previousElementSibling as HTMLElement;
} }
@@ -263,7 +263,7 @@ export default class MultiPanel extends HTMLElement {
private _nextHeading() { private _nextHeading() {
// activeElement would be the currently selected heading // activeElement would be the currently selected heading
// 2 elemements after that would be the next heading. // 2 elemements after that would be the next heading.
const nextContent = document.activeElement!.nextElementSibling; const nextContent = document.activeElement.nextElementSibling;
if (nextContent) { if (nextContent) {
return nextContent.nextElementSibling as HTMLElement; return nextContent.nextElementSibling as HTMLElement;
} }

View File

@@ -35,29 +35,21 @@ import { VectorResizeOptions, BitmapResizeOptions } from '../../codecs/resize/pr
import './custom-els/MultiPanel'; import './custom-els/MultiPanel';
import Results from '../results'; import Results from '../results';
import { ExpandIcon, CopyAcrossIconProps } from '../../lib/icons'; import { ExpandIcon, CopyAcrossIconProps } from '../../lib/icons';
import SnackBarElement from '../../lib/SnackBar'; import SnackBarElement from 'src/lib/SnackBar';
import { InputProcessorState, defaultInputProcessorState } from '../../codecs/input-processors';
export interface SourceImage { export interface SourceImage {
file: File | Fileish; file: File | Fileish;
decoded: ImageData; data: ImageData;
processed: ImageData;
vectorImage?: HTMLImageElement; vectorImage?: HTMLImageElement;
inputProcessorState: InputProcessorState;
} }
interface SideSettings { interface EncodedImage {
preprocessorState: PreprocessorState;
encoderState: EncoderState;
}
interface Side {
preprocessed?: ImageData; preprocessed?: ImageData;
file?: Fileish; file?: Fileish;
downloadUrl?: string; downloadUrl?: string;
data?: ImageData; data?: ImageData;
latestSettings: SideSettings; preprocessorState: PreprocessorState;
encodedSettings?: SideSettings; encoderState: EncoderState;
loading: boolean; loading: boolean;
/** Counter of the latest bmp currently encoding */ /** Counter of the latest bmp currently encoding */
loadingCounter: number; loadingCounter: number;
@@ -73,7 +65,7 @@ interface Props {
interface State { interface State {
source?: SourceImage; source?: SourceImage;
sides: [Side, Side]; images: [EncodedImage, EncodedImage];
/** Source image load */ /** Source image load */
loading: boolean; loading: boolean;
loadingCounter: number; loadingCounter: number;
@@ -85,21 +77,12 @@ interface UpdateImageOptions {
skipPreprocessing?: boolean; skipPreprocessing?: boolean;
} }
function processInput(
data: ImageData,
inputProcessData: InputProcessorState,
processor: Processor,
) {
return processor.rotate(data, inputProcessData.rotate);
}
async function preprocessImage( async function preprocessImage(
source: SourceImage, source: SourceImage,
preprocessData: PreprocessorState, preprocessData: PreprocessorState,
processor: Processor, processor: Processor,
): Promise<ImageData> { ): Promise<ImageData> {
let result = source.processed; let result = source.data;
if (preprocessData.resize.enabled) { if (preprocessData.resize.enabled) {
if (preprocessData.resize.method === 'vector' && source.vectorImage) { if (preprocessData.resize.method === 'vector' && source.vectorImage) {
result = processor.vectorResize( result = processor.vectorResize(
@@ -148,26 +131,6 @@ 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<HTMLImageElement> { async function processSvg(blob: Blob): Promise<HTMLImageElement> {
// Firefox throws if you try to draw an SVG to canvas that doesn't have width/height. // 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. // In Chrome it loads, but drawImage behaves weirdly.
@@ -199,8 +162,6 @@ const resultTitles = ['Top', 'Bottom'];
const buttonPositions = const buttonPositions =
['download-left', 'download-right'] as ('download-left' | 'download-right')[]; ['download-left', 'download-right'] as ('download-left' | 'download-right')[];
const originalDocumentTitle = document.title;
export default class Compress extends Component<Props, State> { export default class Compress extends Component<Props, State> {
widthQuery = window.matchMedia('(max-width: 599px)'); widthQuery = window.matchMedia('(max-width: 599px)');
@@ -208,21 +169,17 @@ export default class Compress extends Component<Props, State> {
source: undefined, source: undefined,
loading: false, loading: false,
loadingCounter: 0, loadingCounter: 0,
sides: [ images: [
{ {
latestSettings: {
preprocessorState: defaultPreprocessorState, preprocessorState: defaultPreprocessorState,
encoderState: { type: identity.type, options: identity.defaultOptions }, encoderState: { type: identity.type, options: identity.defaultOptions },
},
loadingCounter: 0, loadingCounter: 0,
loadedCounter: 0, loadedCounter: 0,
loading: false, loading: false,
}, },
{ {
latestSettings: {
preprocessorState: defaultPreprocessorState, preprocessorState: defaultPreprocessorState,
encoderState: { type: mozJPEG.type, options: mozJPEG.defaultOptions }, encoderState: { type: mozJPEG.type, options: mozJPEG.defaultOptions },
},
loadingCounter: 0, loadingCounter: 0,
loadedCounter: 0, loadedCounter: 0,
loading: false, loading: false,
@@ -250,7 +207,7 @@ export default class Compress extends Component<Props, State> {
private onEncoderTypeChange(index: 0 | 1, newType: EncoderType): void { private onEncoderTypeChange(index: 0 | 1, newType: EncoderType): void {
this.setState({ this.setState({
sides: cleanSet(this.state.sides, `${index}.latestSettings.encoderState`, { images: cleanSet(this.state.images, `${index}.encoderState`, {
type: newType, type: newType,
options: encoderMap[newType].defaultOptions, options: encoderMap[newType].defaultOptions,
}), }),
@@ -259,50 +216,37 @@ export default class Compress extends Component<Props, State> {
private onPreprocessorOptionsChange(index: 0 | 1, options: PreprocessorState): void { private onPreprocessorOptionsChange(index: 0 | 1, options: PreprocessorState): void {
this.setState({ this.setState({
sides: cleanSet(this.state.sides, `${index}.latestSettings.preprocessorState`, options), images: cleanSet(this.state.images, `${index}.preprocessorState`, options),
}); });
} }
private onEncoderOptionsChange(index: 0 | 1, options: EncoderOptions): void { private onEncoderOptionsChange(index: 0 | 1, options: EncoderOptions): void {
this.setState({ this.setState({
sides: cleanSet(this.state.sides, `${index}.latestSettings.encoderState.options`, options), images: cleanSet(this.state.images, `${index}.encoderState.options`, options),
}); });
} }
private updateDocumentTitle(filename: string = ''): void {
document.title = filename ? `${filename} - ${originalDocumentTitle}` : originalDocumentTitle;
}
componentWillReceiveProps(nextProps: Props): void { componentWillReceiveProps(nextProps: Props): void {
if (nextProps.file !== this.props.file) { if (nextProps.file !== this.props.file) {
this.updateFile(nextProps.file); this.updateFile(nextProps.file);
} }
} }
componentWillUnmount(): void {
this.updateDocumentTitle();
}
componentDidUpdate(prevProps: Props, prevState: State): void { componentDidUpdate(prevProps: Props, prevState: State): void {
const { source, sides } = this.state; const { source, images } = this.state;
const sourceDataChanged = for (const [i, image] of images.entries()) {
// Has the source object become set/unset? const prevImage = prevState.images[i];
!!source !== !!prevState.source || const sourceChanged = source !== prevState.source;
// Or has the processed data changed? const encoderChanged = image.encoderState !== prevImage.encoderState;
(source && prevState.source && source.processed !== prevState.source.processed); const preprocessorChanged = image.preprocessorState !== prevImage.preprocessorState;
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 // The image only needs updated if the encoder/preprocessor settings have changed, or the
// source has changed. // source has changed.
if (sourceDataChanged || encoderChanged || preprocessorChanged) { if (sourceChanged || encoderChanged || preprocessorChanged) {
if (prevImage.downloadUrl) URL.revokeObjectURL(prevImage.downloadUrl);
this.updateImage(i, { this.updateImage(i, {
skipPreprocessing: !sourceDataChanged && !preprocessorChanged, skipPreprocessing: !sourceChanged && !preprocessorChanged,
}).catch((err) => { }).catch((err) => {
console.error(err); console.error(err);
}); });
@@ -312,10 +256,10 @@ export default class Compress extends Component<Props, State> {
private async onCopyToOtherClick(index: 0 | 1) { private async onCopyToOtherClick(index: 0 | 1) {
const otherIndex = (index + 1) % 2; const otherIndex = (index + 1) % 2;
const oldSettings = this.state.sides[otherIndex]; const oldSettings = this.state.images[otherIndex];
this.setState({ this.setState({
sides: cleanSet(this.state.sides, otherIndex, this.state.sides[index]), images: cleanSet(this.state.images, otherIndex, this.state.images[index]),
}); });
const result = await this.props.showSnack('Settings copied across', { const result = await this.props.showSnack('Settings copied across', {
@@ -326,67 +270,13 @@ export default class Compress extends Component<Props, State> {
if (result !== 'undo') return; if (result !== 'undo') return;
this.setState({ this.setState({
sides: cleanSet(this.state.sides, otherIndex, oldSettings), images: cleanSet(this.state.images, otherIndex, oldSettings),
}); });
} }
@bind
private async onInputProcessorChange(options: InputProcessorState): Promise<void> {
const source = this.state.source;
if (!source) return;
const oldRotate = source.inputProcessorState.rotate.rotate;
const newRotate = options.rotate.rotate;
const orientationChanged = oldRotate % 180 !== newRotate % 180;
const loadingCounter = this.state.loadingCounter + 1;
// Either processor is good enough here.
const processor = this.leftProcessor;
this.setState({
loadingCounter, loading: true,
source: cleanSet(source, 'inputProcessorState', options),
});
// Abort any current encode jobs, as they're redundant now.
this.leftProcessor.abortCurrent();
this.rightProcessor.abortCurrent();
try {
const processed = await processInput(source.decoded, options, processor);
// Another file has been opened/processed before this one processed.
if (this.state.loadingCounter !== loadingCounter) return;
let newState = { ...this.state, loading: false };
newState = cleanSet(newState, 'source.processed', processed);
newState = stateForNewSourceData(newState, newState.source!);
if (orientationChanged) {
// If orientation has changed, we should flip the resize values.
for (const i of [0, 1]) {
const resizeSettings = newState.sides[i].latestSettings.preprocessorState.resize;
newState = cleanMerge(newState, `sides.${i}.latestSettings.preprocessorState.resize`, {
width: resizeSettings.height,
height: resizeSettings.width,
});
}
}
this.setState(newState);
} catch (err) {
if (err.name === 'AbortError') return;
console.error(err);
// Another file has been opened/processed before this one processed.
if (this.state.loadingCounter !== loadingCounter) return;
this.props.showSnack('Processing error');
this.setState({ loading: false });
}
}
@bind @bind
private async updateFile(file: File | Fileish) { private async updateFile(file: File | Fileish) {
const loadingCounter = this.state.loadingCounter + 1; const loadingCounter = this.state.loadingCounter + 1;
// Either processor is good enough here.
const processor = this.leftProcessor;
this.setState({ loadingCounter, loading: true }); this.setState({ loadingCounter, loading: true });
@@ -395,7 +285,7 @@ export default class Compress extends Component<Props, State> {
this.rightProcessor.abortCurrent(); this.rightProcessor.abortCurrent();
try { try {
let decoded: ImageData; let data: ImageData;
let vectorImage: HTMLImageElement | undefined; let vectorImage: HTMLImageElement | undefined;
// Special-case SVG. We need to avoid createImageBitmap because of // Special-case SVG. We need to avoid createImageBitmap because of
@@ -403,43 +293,46 @@ export default class Compress extends Component<Props, State> {
// Also, we cache the HTMLImageElement so we can perform vector resizing later. // Also, we cache the HTMLImageElement so we can perform vector resizing later.
if (file.type.startsWith('image/svg+xml')) { if (file.type.startsWith('image/svg+xml')) {
vectorImage = await processSvg(file); vectorImage = await processSvg(file);
decoded = drawableToImageData(vectorImage); data = drawableToImageData(vectorImage);
} else { } else {
// Either processor is good enough here. // Either processor is good enough here.
decoded = await decodeImage(file, processor); data = await decodeImage(file, this.leftProcessor);
} }
const processed = await processInput(decoded, defaultInputProcessorState, processor); // 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; if (this.state.loadingCounter !== loadingCounter) return;
let newState: State = { let newState: State = {
...this.state, ...this.state,
source: { source: { data, file, vectorImage },
decoded, file, vectorImage, processed,
inputProcessorState: defaultInputProcessorState,
},
loading: false, loading: false,
}; };
newState = stateForNewSourceData(newState, newState.source!);
for (const i of [0, 1]) { 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: // Default resize values come from the image:
newState = cleanMerge(newState, `sides.${i}.latestSettings.preprocessorState.resize`, { newState = cleanMerge(newState, `images.${i}.preprocessorState.resize`, {
width: processed.width, width: data.width,
height: processed.height, height: data.height,
method: vectorImage ? 'vector' : 'browser-high', method: vectorImage ? 'vector' : 'browser-high',
}); });
} }
this.updateDocumentTitle(file.name);
this.setState(newState); this.setState(newState);
} catch (err) { } catch (err) {
if (err.name === 'AbortError') return; if (err.name === 'AbortError') return;
console.error(err); console.error(err);
// Another file has been opened/processed before this one processed. // Another file has been opened before this one processed.
if (this.state.loadingCounter !== loadingCounter) return; if (this.state.loadingCounter !== loadingCounter) return;
this.props.showSnack('Invalid image'); this.props.showSnack('Invalid image');
this.setState({ loading: false }); this.setState({ loading: false });
@@ -447,31 +340,26 @@ export default class Compress extends Component<Props, State> {
} }
private async updateImage(index: number, options: UpdateImageOptions = {}): Promise<void> { private async updateImage(index: number, options: UpdateImageOptions = {}): Promise<void> {
const { const { skipPreprocessing = false } = options;
skipPreprocessing = false,
} = options;
const { source } = this.state; const { source } = this.state;
if (!source) return; if (!source) return;
// Each time we trigger an async encode, the counter changes. // Each time we trigger an async encode, the counter changes.
const loadingCounter = this.state.sides[index].loadingCounter + 1; const loadingCounter = this.state.images[index].loadingCounter + 1;
let sides = cleanMerge(this.state.sides, index, { let images = cleanMerge(this.state.images, index, {
loadingCounter, loadingCounter,
loading: true, loading: true,
}); });
this.setState({ sides }); this.setState({ images });
const side = sides[index]; const image = images[index];
const settings = side.latestSettings;
let file: File | Fileish | undefined; let file: File | Fileish | undefined;
let preprocessed: ImageData | undefined; let preprocessed: ImageData | undefined;
let data: ImageData | undefined; let data: ImageData | undefined;
const cacheResult = this.encodeCache.match( const cacheResult = this.encodeCache.match(source, image.preprocessorState, image.encoderState);
source.processed, settings.preprocessorState, settings.encoderState,
);
const processor = (index === 0) ? this.leftProcessor : this.rightProcessor; const processor = (index === 0) ? this.leftProcessor : this.rightProcessor;
// Abort anything the processor is currently doing. // Abort anything the processor is currently doing.
@@ -484,66 +372,60 @@ export default class Compress extends Component<Props, State> {
} else { } else {
try { try {
// Special case for identity // Special case for identity
if (settings.encoderState.type === identity.type) { if (image.encoderState.type === identity.type) {
file = source.file; ({ file, data } = source);
data = source.processed;
} else { } else {
preprocessed = (skipPreprocessing && side.preprocessed) preprocessed = (skipPreprocessing && image.preprocessed)
? side.preprocessed ? image.preprocessed
: await preprocessImage(source, settings.preprocessorState, processor); : await preprocessImage(source, image.preprocessorState, processor);
file = await compressImage( file = await compressImage(preprocessed, image.encoderState, source.file.name, processor);
preprocessed, settings.encoderState, source.file.name, processor,
);
data = await decodeImage(file, processor); data = await decodeImage(file, processor);
this.encodeCache.add({ this.encodeCache.add({
source,
data, data,
preprocessed, preprocessed,
file, file,
sourceData: source.processed, encoderState: image.encoderState,
encoderState: settings.encoderState, preprocessorState: image.preprocessorState,
preprocessorState: settings.preprocessorState,
}); });
} }
} catch (err) { } catch (err) {
if (err.name === 'AbortError') return; if (err.name === 'AbortError') return;
this.props.showSnack(`Processing error (type=${settings.encoderState.type}): ${err}`); this.props.showSnack(`Processing error (type=${image.encoderState.type}): ${err}`);
throw err; throw err;
} }
} }
const latestData = this.state.sides[index]; const latestImage = this.state.images[index];
// If a later encode has landed before this one, return. // If a later encode has landed before this one, return.
if (loadingCounter < latestData.loadedCounter) { if (loadingCounter < latestImage.loadedCounter) {
return; return;
} }
if (latestData.downloadUrl) URL.revokeObjectURL(latestData.downloadUrl); images = cleanMerge(this.state.images, index, {
sides = cleanMerge(this.state.sides, index, {
file, file,
data, data,
preprocessed, preprocessed,
downloadUrl: URL.createObjectURL(file), downloadUrl: URL.createObjectURL(file),
loading: sides[index].loadingCounter !== loadingCounter, loading: images[index].loadingCounter !== loadingCounter,
loadedCounter: loadingCounter, loadedCounter: loadingCounter,
encodedSettings: settings,
}); });
this.setState({ sides }); this.setState({ images });
} }
render({ onBack }: Props, { loading, sides, source, mobileView }: State) { render({ onBack }: Props, { loading, images, source, mobileView }: State) {
const [leftSide, rightSide] = sides; const [leftImage, rightImage] = images;
const [leftImageData, rightImageData] = sides.map(i => i.data); const [leftImageData, rightImageData] = images.map(i => i.data);
const options = sides.map((side, index) => ( const options = images.map((image, index) => (
<Options <Options
source={source} source={source}
mobileView={mobileView} mobileView={mobileView}
preprocessorState={side.latestSettings.preprocessorState} preprocessorState={image.preprocessorState}
encoderState={side.latestSettings.encoderState} encoderState={image.encoderState}
onEncoderTypeChange={this.onEncoderTypeChange.bind(this, index)} onEncoderTypeChange={this.onEncoderTypeChange.bind(this, index)}
onEncoderOptionsChange={this.onEncoderOptionsChange.bind(this, index)} onEncoderOptionsChange={this.onEncoderOptionsChange.bind(this, index)}
onPreprocessorOptionsChange={this.onPreprocessorOptionsChange.bind(this, index)} onPreprocessorOptionsChange={this.onPreprocessorOptionsChange.bind(this, index)}
@@ -553,44 +435,33 @@ export default class Compress extends Component<Props, State> {
const copyDirections = const copyDirections =
(mobileView ? ['down', 'up'] : ['right', 'left']) as CopyAcrossIconProps['copyDirection'][]; (mobileView ? ['down', 'up'] : ['right', 'left']) as CopyAcrossIconProps['copyDirection'][];
const results = sides.map((side, index) => ( const results = images.map((image, index) => (
<Results <Results
downloadUrl={side.downloadUrl} downloadUrl={image.downloadUrl}
imageFile={side.file} imageFile={image.file}
source={source} source={source}
loading={loading || side.loading} loading={loading || image.loading}
copyDirection={copyDirections[index]} copyDirection={copyDirections[index]}
onCopyToOtherClick={this.onCopyToOtherClick.bind(this, index)} onCopyToOtherClick={this.onCopyToOtherClick.bind(this, index)}
buttonPosition={mobileView ? 'stack-right' : buttonPositions[index]} buttonPosition={mobileView ? 'stack-right' : buttonPositions[index]}
> >
{!mobileView ? null : [ {!mobileView ? null : [
<ExpandIcon class={style.expandIcon} key="expand-icon"/>, <ExpandIcon class={style.expandIcon} key="expand-icon"/>,
`${resultTitles[index]} (${encoderMap[side.latestSettings.encoderState.type].label})`, `${resultTitles[index]} (${encoderMap[image.encoderState.type].label})`,
]} ]}
</Results> </Results>
)); ));
// For rendering, we ideally want the settings that were used to create the data, not the latest
// settings.
const leftDisplaySettings = leftSide.encodedSettings || leftSide.latestSettings;
const rightDisplaySettings = rightSide.encodedSettings || rightSide.latestSettings;
const leftImgContain = leftDisplaySettings.preprocessorState.resize.enabled &&
leftDisplaySettings.preprocessorState.resize.fitMethod === 'contain';
const rightImgContain = rightDisplaySettings.preprocessorState.resize.enabled &&
rightDisplaySettings.preprocessorState.resize.fitMethod === 'contain';
return ( return (
<div class={style.compress}> <div class={style.compress}>
<Output <Output
source={source} originalImage={source && source.data}
mobileView={mobileView} mobileView={mobileView}
leftCompressed={leftImageData} leftCompressed={leftImageData}
rightCompressed={rightImageData} rightCompressed={rightImageData}
leftImgContain={leftImgContain} leftImgContain={leftImage.preprocessorState.resize.fitMethod === 'cover'}
rightImgContain={rightImgContain} rightImgContain={rightImage.preprocessorState.resize.fitMethod === 'cover'}
onBack={onBack} onBack={onBack}
inputProcessorState={source && source.inputProcessorState}
onInputProcessorChange={this.onInputProcessorChange}
/> />
{mobileView {mobileView
? ( ? (

View File

@@ -1,6 +1,7 @@
import { EncoderState } from '../../codecs/encoders'; import { EncoderState } from '../../codecs/encoders';
import { Fileish } from '../../lib/initial-util'; import { Fileish } from '../../lib/initial-util';
import { shallowEqual } from '../../lib/util'; import { shallowEqual } from '../../lib/util';
import { SourceImage } from '.';
import { PreprocessorState } from '../../codecs/preprocessors'; import { PreprocessorState } from '../../codecs/preprocessors';
import * as identity from '../../codecs/identity/encoder-meta'; import * as identity from '../../codecs/identity/encoder-meta';
@@ -14,7 +15,7 @@ interface CacheResult {
interface CacheEntry extends CacheResult { interface CacheEntry extends CacheResult {
preprocessorState: PreprocessorState; preprocessorState: PreprocessorState;
encoderState: EncoderState; encoderState: EncoderState;
sourceData: ImageData; source: SourceImage;
} }
const SIZE = 5; const SIZE = 5;
@@ -31,13 +32,13 @@ export default class ResultCache {
} }
match( match(
sourceData: ImageData, source: SourceImage,
preprocessorState: PreprocessorState, preprocessorState: PreprocessorState,
encoderState: EncoderState, encoderState: EncoderState,
): CacheResult | undefined { ): CacheResult | undefined {
const matchingIndex = this._entries.findIndex((entry) => { const matchingIndex = this._entries.findIndex((entry) => {
// Check for quick exits: // Check for quick exits:
if (entry.sourceData !== sourceData) return false; if (entry.source !== source) return false;
if (entry.encoderState.type !== encoderState.type) return false; if (entry.encoderState.type !== encoderState.type) return false;
// Check that each set of options in the preprocessor are the same // Check that each set of options in the preprocessor are the same

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -129,11 +129,6 @@ export default class Intro extends Component<Props, State> {
<ul class={style.relatedLinks}> <ul class={style.relatedLinks}>
<li><a href="https://github.com/GoogleChromeLabs/squoosh/">View the code</a></li> <li><a href="https://github.com/GoogleChromeLabs/squoosh/">View the code</a></li>
<li><a href="https://github.com/GoogleChromeLabs/squoosh/issues">Report a bug</a></li> <li><a href="https://github.com/GoogleChromeLabs/squoosh/issues">Report a bug</a></li>
<li>
<a href="https://github.com/GoogleChromeLabs/squoosh/blob/master/README.md#privacy">
Privacy
</a>
</li>
</ul> </ul>
</div> </div>
); );

View File

@@ -48,7 +48,6 @@
.logo { .logo {
composes: abs-fill from '../../lib/util.scss'; composes: abs-fill from '../../lib/util.scss';
pointer-events: none;
} }
.open-image-guide { .open-image-guide {
@@ -145,7 +144,6 @@
.demo-icon { .demo-icon {
composes: abs-fill from '../../lib/util.scss'; composes: abs-fill from '../../lib/util.scss';
pointer-events: none;
} }
.demo-description { .demo-description {

View File

@@ -13,8 +13,6 @@ export default class Range extends Component<Props, State> {
@bind @bind
private onTextInput(event: Event) { private onTextInput(event: Event) {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
const value = input.value.trim();
if (!value) return;
this.rangeWc!.value = input.value; this.rangeWc!.value = input.value;
const { onInput } = this.props; const { onInput } = this.props;
if (onInput) onInput(event); if (onInput) onInput(event);

View File

@@ -60,16 +60,11 @@ export default class Results extends Component<Props, State> {
@bind @bind
onDownload() { onDownload() {
// GA cant do floats. So we round to ints. We're deliberately rounding to nearest kilobyte to
// avoid cases where exact image sizes leak something interesting about the user.
const before = Math.round(this.props.source!.file.size / 1024);
const after = Math.round(this.props.imageFile!.size / 1024);
const change = Math.round(after / before * 1000);
ga('send', 'event', 'compression', 'download', { ga('send', 'event', 'compression', 'download', {
metric1: before, // GA cant do floats. So we round to ints.
metric2: after, metric1: Math.floor(this.props.source!.file.size),
metric3: change, metric2: Math.floor(this.props.imageFile!.size),
metric3: Math.floor(this.props.imageFile!.size / this.props.source!.file.size * 1000),
}); });
} }

View File

@@ -13,7 +13,6 @@ if (!('customElements' in self)) {
init(); init();
} }
if (typeof PRERENDER === 'undefined') {
window.ga = window.ga || ((...args) => (ga.q = ga.q || []).push(args)); window.ga = window.ga || ((...args) => (ga.q = ga.q || []).push(args));
ga('create', 'UA-128752250-1', 'auto'); ga('create', 'UA-128752250-1', 'auto');
ga('set', 'transport', 'beacon'); ga('set', 'transport', 'beacon');
@@ -22,4 +21,3 @@ if (typeof PRERENDER === 'undefined') {
const s = document.createElement('script'); const s = document.createElement('script');
s.src = 'https://www.google-analytics.com/analytics.js'; s.src = 'https://www.google-analytics.com/analytics.js';
document.head!.appendChild(s); document.head!.appendChild(s);
}

View File

@@ -12,21 +12,9 @@ export const DownloadIcon = (props: JSX.HTMLAttributes) => (
</Icon> </Icon>
); );
export const ToggleBackgroundIcon = (props: JSX.HTMLAttributes) => ( export const ToggleIcon = (props: JSX.HTMLAttributes) => (
<Icon {...props}> <Icon {...props}>
<path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm2 4v-2H3c0 1.1.9 2 2 2zM3 9h2V7H3v2zm12 12h2v-2h-2v2zm4-18H9a2 2 0 0 0-2 2v10c0 1.1.9 2 2 2h10a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zm0 12H9V5h10v10zm-8 6h2v-2h-2v2zm-4 0h2v-2H7v2z"/> <path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm2 4v-2H3c0 1.1.89 2 2 2zM3 9h2V7H3v2zm12 12h2v-2h-2v2zm4-18H9c-1.11 0-2 .9-2 2v10c0 1.1.89 2 2 2h10c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 12H9V5h10v10zm-8 6h2v-2h-2v2zm-4 0h2v-2H7v2z" />
</Icon>
);
export const ToggleBackgroundActiveIcon = (props: JSX.HTMLAttributes) => (
<Icon {...props}>
<path d="M9 7H7v2h2V7zm0 4H7v2h2v-2zm0-8a2 2 0 0 0-2 2h2V3zm4 12h-2v2h2v-2zm6-12v2h2a2 2 0 0 0-2-2zm-6 0h-2v2h2V3zM9 17v-2H7c0 1.1.9 2 2 2zm10-4h2v-2h-2v2zm0-4h2V7h-2v2zm0 8a2 2 0 0 0 2-2h-2v2zM5 7H3v12c0 1.1.9 2 2 2h12v-2H5V7zm10-2h2V3h-2v2zm0 12h2v-2h-2v2z"/>
</Icon>
);
export const RotateIcon = (props: JSX.HTMLAttributes) => (
<Icon {...props}>
<path d="M15.6 5.5L11 1v3a8 8 0 0 0 0 16v-2a6 6 0 0 1 0-12v4l4.5-4.5zm4.3 5.5a8 8 0 0 0-1.6-3.9L17 8.5c.5.8.9 1.6 1 2.5h2zM13 17.9v2a8 8 0 0 0 3.9-1.6L15.5 17c-.8.5-1.6.9-2.5 1zm3.9-2.4l1.4 1.4A8 8 0 0 0 20 13h-2c-.1.9-.5 1.7-1 2.5z"/>
</Icon> </Icon>
); );

View File

@@ -42,9 +42,6 @@ async function updateReady(reg: ServiceWorkerRegistration): Promise<void> {
/** Set up the service worker and monitor changes */ /** Set up the service worker and monitor changes */
export async function offliner(showSnack: SnackBarElement['showSnackbar']) { export async function offliner(showSnack: SnackBarElement['showSnackbar']) {
// This needs to be a typeof because Webpack.
if (typeof PRERENDER === 'boolean') return;
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
navigator.serviceWorker.register('../sw'); navigator.serviceWorker.register('../sw');
} }
@@ -63,10 +60,6 @@ export async function offliner(showSnack: SnackBarElement['showSnackbar']) {
location.reload(); location.reload();
}); });
// If we don't have a controller, we don't need to check for updates we've just loaded from the
// network.
if (!hasController) return;
const reg = await navigator.serviceWorker.getRegistration(); const reg = await navigator.serviceWorker.getRegistration();
// Service worker not registered yet. // Service worker not registered yet.
if (!reg) return; if (!reg) return;

View File

@@ -297,10 +297,3 @@ export async function transitionHeight(el: HTMLElement, opts: TransitionOptions)
el.addEventListener('transitioncancel', listener); el.addEventListener('transitioncancel', listener);
}); });
} }
/**
* Simple event listener that prevents the default.
*/
export function preventDefault(event: Event) {
event.preventDefault();
}

View File

@@ -3,7 +3,7 @@
"short_name": "Squoosh", "short_name": "Squoosh",
"start_url": "/", "start_url": "/",
"display": "standalone", "display": "standalone",
"orientation": "any", "orientation": "portrait",
"background_color": "#fff", "background_color": "#fff",
"theme_color": "#f78f21", "theme_color": "#f78f21",
"icons": [ "icons": [

View File

@@ -39,3 +39,7 @@ declare var ga: {
(...args: any[]): void; (...args: any[]): void;
q: any[]; q: any[];
}; };
interface Window {
ga: typeof ga;
}

View File

@@ -56,6 +56,8 @@ export async function cacheBasics(cacheName: string, buildAssets: string[]) {
const toCache = ['/', '/assets/favicon.ico']; const toCache = ['/', '/assets/favicon.ico'];
const prefixesToCache = [ const prefixesToCache = [
// First interaction JS & CSS:
'first-interaction.',
// Main app JS & CSS: // Main app JS & CSS:
'main-app.', 'main-app.',
// Service worker handler: // Service worker handler:

View File

@@ -13,8 +13,5 @@
"allowJs": false, "allowJs": false,
"baseUrl": "." "baseUrl": "."
}, },
"exclude": [ "exclude": ["src/sw/**/*"]
"src/sw/**/*",
"src/codecs/processor-worker/**/*"
]
} }

View File

@@ -2,7 +2,7 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const webpack = require('webpack'); const webpack = require('webpack');
const CleanPlugin = require('clean-webpack-plugin'); const CleanPlugin = require('clean-webpack-plugin');
const TerserPlugin = require('terser-webpack-plugin'); const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
const ProgressBarPlugin = require('progress-bar-webpack-plugin'); const ProgressBarPlugin = require('progress-bar-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin'); const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin');
@@ -14,8 +14,6 @@ const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPl
const WorkerPlugin = require('worker-plugin'); const WorkerPlugin = require('worker-plugin');
const AutoSWPlugin = require('./config/auto-sw-plugin'); const AutoSWPlugin = require('./config/auto-sw-plugin');
const CrittersPlugin = require('critters-webpack-plugin'); const CrittersPlugin = require('critters-webpack-plugin');
const AssetTemplatePlugin = require('./config/asset-template-plugin');
const addCssTypes = require('./config/add-css-types');
function readJson (filename) { function readJson (filename) {
return JSON.parse(fs.readFileSync(filename)); return JSON.parse(fs.readFileSync(filename));
@@ -23,7 +21,7 @@ function readJson (filename) {
const VERSION = readJson('./package.json').version; const VERSION = readJson('./package.json').version;
module.exports = async function (_, env) { module.exports = function (_, env) {
const isProd = env.mode === 'production'; const isProd = env.mode === 'production';
const nodeModules = path.join(__dirname, 'node_modules'); const nodeModules = path.join(__dirname, 'node_modules');
const componentStyleDirs = [ const componentStyleDirs = [
@@ -33,8 +31,6 @@ module.exports = async function (_, env) {
path.join(__dirname, 'src/lib'), path.join(__dirname, 'src/lib'),
]; ];
await addCssTypes(componentStyleDirs, { watch: !isProd });
return { return {
mode: isProd ? 'production' : 'development', mode: isProd ? 'production' : 'development',
entry: { entry: {
@@ -112,7 +108,9 @@ module.exports = async function (_, env) {
// In production, CSS is extracted to files on disk. In development, it's inlined into JS: // In production, CSS is extracted to files on disk. In development, it's inlined into JS:
isProd ? MiniCssExtractPlugin.loader : 'style-loader', isProd ? MiniCssExtractPlugin.loader : 'style-loader',
{ {
loader: 'css-loader', // This is a fork of css-loader that auto-generates .d.ts files for CSS module imports.
// The result is a definition file with the exported String classname mappings.
loader: 'typings-for-css-modules-loader',
options: { options: {
modules: true, modules: true,
localIdentName: isProd ? '[hash:base64:5]' : '[local]__[hash:base64:5]', localIdentName: isProd ? '[hash:base64:5]' : '[local]__[hash:base64:5]',
@@ -170,11 +168,7 @@ module.exports = async function (_, env) {
] ]
}, },
plugins: [ plugins: [
new webpack.IgnorePlugin( new webpack.IgnorePlugin(/(fs|crypto|path)/, /\/codecs\//),
/(fs|crypto|path)/,
new RegExp(`${path.sep}codecs${path.sep}`)
),
// Pretty progressbar showing build progress: // Pretty progressbar showing build progress:
new ProgressBarPlugin({ new ProgressBarPlugin({
format: '\u001b[90m\u001b[44mBuild\u001b[49m\u001b[39m [:bar] \u001b[32m\u001b[1m:percent\u001b[22m\u001b[39m (:elapseds) \u001b[2m:msg\u001b[22m\r', format: '\u001b[90m\u001b[44mBuild\u001b[49m\u001b[39m [:bar] \u001b[32m\u001b[1m:percent\u001b[22m\u001b[39m (:elapseds) \u001b[2m:msg\u001b[22m\r',
@@ -231,7 +225,7 @@ module.exports = async function (_, env) {
// For now we're not doing SSR. // For now we're not doing SSR.
new HtmlPlugin({ new HtmlPlugin({
filename: path.join(__dirname, 'build/index.html'), filename: path.join(__dirname, 'build/index.html'),
template: isProd ? '!!prerender-loader?string!src/index.html' : 'src/index.html', template: '!!prerender-loader?string!src/index.html',
minify: isProd && { minify: isProd && {
collapseWhitespace: true, collapseWhitespace: true,
removeScriptTypeAttributes: true, removeScriptTypeAttributes: true,
@@ -244,11 +238,8 @@ module.exports = async function (_, env) {
compile: true compile: true
}), }),
new AutoSWPlugin({ version: VERSION }), new AutoSWPlugin({
version: VERSION
isProd && new AssetTemplatePlugin({
template: path.join(__dirname, '_headers.ejs'),
filename: '_headers',
}), }),
new ScriptExtHtmlPlugin({ new ScriptExtHtmlPlugin({
@@ -295,10 +286,12 @@ module.exports = async function (_, env) {
optimization: { optimization: {
minimizer: [ minimizer: [
new TerserPlugin({ new UglifyJsPlugin({
sourceMap: isProd, sourceMap: isProd,
extractComments: 'build/licenses.txt', extractComments: {
terserOptions: { file: 'build/licenses.txt'
},
uglifyOptions: {
compress: { compress: {
inline: 1 inline: 1
}, },