Compare commits

..

15 Commits

Author SHA1 Message Date
Jake Archibald
b139119551 Prevent options overflow at larger widths 2018-11-06 13:37:13 +00:00
Jake Archibald
85323dff87 No longer need this. 2018-11-06 13:37:13 +00:00
Jake Archibald
849441f23a Range bubble now behaves properly on mobile 2018-11-06 13:37:12 +00:00
Jake Archibald
c125af564a Allow two-up and pinch-zoom to work beneath controls 2018-11-06 13:37:12 +00:00
Jake Archibald
ce67f6c538 Expand/collapse icon 2018-11-06 13:37:11 +00:00
Jake Archibald
c8e0c56687 Fixing animation bugs 2018-11-06 13:37:11 +00:00
Jake Archibald
ac4f845d8e Adding height animation to multi-panel 2018-11-06 13:37:11 +00:00
Jake Archibald
52f61dfccc Adding labels to collapsed view 2018-11-06 13:37:10 +00:00
Jake Archibald
068dfe1b19 Ordering of items in mobile view. Changing scrolling element. 2018-11-06 13:37:10 +00:00
Jake Archibald
637e859a1e Abstracting results so it can be used as a heading. 2018-11-06 13:37:09 +00:00
Jake Archibald
da072a015b Edge cases for one-open 2018-11-06 13:37:08 +00:00
Jake Archibald
04492f8f5e Allow multi-panel to keep one open only 2018-11-06 13:37:08 +00:00
Jake Archibald
b34dca744d Adding margin so you can still access the two-up 2018-11-06 13:37:08 +00:00
Jake Archibald
c3edde280a Fixing thumb on two-up 2018-11-06 13:37:07 +00:00
Jake Archibald
8db8892529 Basic grid setup 2018-11-06 13:37:07 +00:00
82 changed files with 3258 additions and 7716 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

@@ -39,9 +39,5 @@ struct MozJpegOptions {
bool trellis_opt_zero; bool trellis_opt_zero;
bool trellis_opt_table; bool trellis_opt_table;
int trellis_loops; int trellis_loops;
bool auto_subsample;
int chroma_subsample;
bool separate_chroma_quality;
int chroma_quality;
}; };
``` ```

View File

@@ -21,7 +21,7 @@
console.log('Version:', module.version().toString(16)); console.log('Version:', module.version().toString(16));
const image = await loadImage('../example.png'); const image = await loadImage('../example.png');
const result = module.encode(image.data, image.width, image.height, { const result = module.encode(image.data, image.width, image.height, {
quality: 75, quality: 40,
baseline: false, baseline: false,
arithmetic: false, arithmetic: false,
progressive: true, progressive: true,
@@ -29,14 +29,10 @@
smoothing: 0, smoothing: 0,
color_space: 3, color_space: 3,
quant_table: 3, quant_table: 3,
trellis_multipass: false, trellis_multipass: true,
trellis_opt_zero: false, trellis_opt_zero: true,
trellis_opt_table: false, trellis_opt_table: true,
trellis_loops: 1, trellis_loops: 1,
auto_subsample: true,
chroma_subsample: 2,
separate_chroma_quality: false,
chroma_quality: 75,
}); });
const blob = new Blob([result], {type: 'image/jpeg'}); const blob = new Blob([result], {type: 'image/jpeg'});

View File

@@ -29,10 +29,6 @@ struct MozJpegOptions {
bool trellis_opt_zero; bool trellis_opt_zero;
bool trellis_opt_table; bool trellis_opt_table;
int trellis_loops; int trellis_loops;
bool auto_subsample;
int chroma_subsample;
bool separate_chroma_quality;
int chroma_quality;
}; };
int version() { int version() {
@@ -123,6 +119,9 @@ val encode(std::string image_in, int image_width, int image_height, MozJpegOptio
*/ */
jpeg_set_defaults(&cinfo); jpeg_set_defaults(&cinfo);
/* Now you can set any non-default parameters you wish to.
* Here we just illustrate the use of quality (quantization table) scaling:
*/
jpeg_set_colorspace(&cinfo, (J_COLOR_SPACE) opts.color_space); jpeg_set_colorspace(&cinfo, (J_COLOR_SPACE) opts.color_space);
if (opts.quant_table != -1) { if (opts.quant_table != -1) {
@@ -143,23 +142,11 @@ val encode(std::string image_in, int image_width, int image_height, MozJpegOptio
jpeg_c_set_bool_param(&cinfo, JBOOLEAN_TRELLIS_Q_OPT, opts.trellis_opt_table); jpeg_c_set_bool_param(&cinfo, JBOOLEAN_TRELLIS_Q_OPT, opts.trellis_opt_table);
jpeg_c_set_int_param(&cinfo, JINT_TRELLIS_NUM_LOOPS, opts.trellis_loops); jpeg_c_set_int_param(&cinfo, JINT_TRELLIS_NUM_LOOPS, opts.trellis_loops);
// A little hacky to build a string for this, but it means we can use set_quality_ratings which
// does some useful heuristic stuff.
std::string quality_str = std::to_string(opts.quality); std::string quality_str = std::to_string(opts.quality);
if (opts.separate_chroma_quality && opts.color_space == JCS_YCbCr) {
quality_str += "," + std::to_string(opts.chroma_quality);
}
char const *pqual = quality_str.c_str(); char const *pqual = quality_str.c_str();
set_quality_ratings(&cinfo, (char*) pqual, opts.baseline); set_quality_ratings(&cinfo, (char*) pqual, opts.baseline);
if (!opts.auto_subsample && opts.color_space == JCS_YCbCr) {
cinfo.comp_info[0].h_samp_factor = opts.chroma_subsample;
cinfo.comp_info[0].v_samp_factor = opts.chroma_subsample;
}
if (!opts.baseline && opts.progressive) { if (!opts.baseline && opts.progressive) {
jpeg_simple_progression(&cinfo); jpeg_simple_progression(&cinfo);
} else { } else {
@@ -222,10 +209,6 @@ EMSCRIPTEN_BINDINGS(my_module) {
.field("trellis_opt_zero", &MozJpegOptions::trellis_opt_zero) .field("trellis_opt_zero", &MozJpegOptions::trellis_opt_zero)
.field("trellis_opt_table", &MozJpegOptions::trellis_opt_table) .field("trellis_opt_table", &MozJpegOptions::trellis_opt_table)
.field("trellis_loops", &MozJpegOptions::trellis_loops) .field("trellis_loops", &MozJpegOptions::trellis_loops)
.field("chroma_subsample", &MozJpegOptions::chroma_subsample)
.field("auto_subsample", &MozJpegOptions::auto_subsample)
.field("separate_chroma_quality", &MozJpegOptions::separate_chroma_quality)
.field("chroma_quality", &MozJpegOptions::chroma_quality)
; ;
function("version", &version); function("version", &version);

File diff suppressed because one or more lines are too long

Binary file not shown.

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);
}
};

View File

@@ -1,158 +0,0 @@
const util = require('util');
const minimatch = require('minimatch');
const SingleEntryPlugin = require('webpack/lib/SingleEntryPlugin');
const WebWorkerTemplatePlugin = require('webpack/lib/webworker/WebWorkerTemplatePlugin');
const ParserHelpers = require('webpack/lib/ParserHelpers');
const NAME = 'auto-sw-plugin';
const JS_TYPES = ['auto', 'esm', 'dynamic'];
/**
* Automatically finds and bundles Service Workers by looking for navigator.serviceWorker.register(..).
* An Array of webpack assets is injected into the Service Worker bundle as a `BUILD_ASSETS` global.
* Hidden and `.map` files are excluded by default, and this can be customized using the include & exclude options.
* @example
* // webpack config
* plugins: [
* new AutoSWPlugin({
* exclude: [
* '**\/.*', // don't expose hidden files (default)
* '**\/*.map', // don't precache sourcemaps (default)
* 'index.html' // don't cache the page itself
* ]
* })
* ]
* @param {Object} [options={}]
* @param {string[]} [options.exclude] Minimatch pattern(s) of which assets to omit from BUILD_ASSETS.
* @param {string[]} [options.include] Minimatch pattern(s) of assets to allow in BUILD_ASSETS.
*/
module.exports = class AutoSWPlugin {
constructor(options) {
this.options = Object.assign({
exclude: [
'**/*.map',
'**/.*'
]
}, options || {});
}
apply(compiler) {
const serviceWorkers = [];
compiler.hooks.emit.tapPromise(NAME, compilation => this.emit(compiler, compilation, serviceWorkers));
compiler.hooks.normalModuleFactory.tap(NAME, (factory) => {
for (const type of JS_TYPES) {
factory.hooks.parser.for(`javascript/${type}`).tap(NAME, parser => {
let counter = 0;
const processRegisterCall = expr => {
const dep = parser.evaluateExpression(expr.arguments[0]);
if (!dep.isString()) {
parser.state.module.warnings.push({
message: 'navigator.serviceWorker.register() will only be bundled if passed a String literal.'
});
return false;
}
const filename = dep.string;
const outputFilename = this.options.filename || 'serviceworker.js'
const context = parser.state.current.context;
serviceWorkers.push({
outputFilename,
filename,
context
});
const id = `__webpack__serviceworker__${++counter}`;
ParserHelpers.toConstantDependency(parser, id)(expr.arguments[0]);
return ParserHelpers.addParsedVariableToModule(parser, id, '__webpack_public_path__ + ' + JSON.stringify(outputFilename));
};
parser.hooks.call.for('navigator.serviceWorker.register').tap(NAME, processRegisterCall);
parser.hooks.call.for('self.navigator.serviceWorker.register').tap(NAME, processRegisterCall);
parser.hooks.call.for('window.navigator.serviceWorker.register').tap(NAME, processRegisterCall);
});
}
});
}
createFilter(list) {
const filters = [].concat(list);
for (let i=0; i<filters.length; i++) {
if (typeof filters[i] === 'string') {
filters[i] = minimatch.filter(filters[i]);
}
}
return filters;
}
async emit(compiler, compilation, serviceWorkers) {
let assetMapping = Object.keys(compilation.assets);
if (this.options.include) {
const filters = this.createFilter(this.options.include);
assetMapping = assetMapping.filter(filename => {
for (const filter of filters) {
if (filter(filename)) return true;
}
return false;
});
}
if (this.options.exclude) {
const filters = this.createFilter(this.options.exclude);
assetMapping = assetMapping.filter(filename => {
for (const filter of filters) {
if (filter(filename)) return false;
}
return true;
});
}
await Promise.all(serviceWorkers.map(
(serviceWorker, index) => this.compileServiceWorker(compiler, compilation, serviceWorker, index, assetMapping)
));
}
async compileServiceWorker(compiler, compilation, options, index, assetMapping) {
const entryFilename = options.filename;
const chunkFilename = compiler.options.output.chunkFilename.replace(/\.([a-z]+)$/i, '.serviceworker.$1');
const workerOptions = {
filename: options.outputFilename, // chunkFilename.replace(/\.?\[(?:chunkhash|contenthash|hash)(:\d+(?::\d+)?)?\]/g, ''),
chunkFilename: this.options.chunkFilename || chunkFilename,
globalObject: 'self'
};
const childCompiler = compilation.createChildCompiler(NAME, { filename: workerOptions.filename });
(new WebWorkerTemplatePlugin(workerOptions)).apply(childCompiler);
/* The duplication DefinePlugin ends up causing is problematic (it doesn't hoist injections), so we'll do it manually. */
// (new DefinePlugin({
// BUILD_ASSETS: JSON.stringify(assetMapping)
// })).apply(childCompiler);
(new SingleEntryPlugin(options.context, entryFilename, workerOptions.filename)).apply(childCompiler);
const subCache = `subcache ${__dirname} ${entryFilename} ${index}`;
let childCompilation;
childCompiler.hooks.compilation.tap(NAME, c => {
childCompilation = c;
if (childCompilation.cache) {
if (!childCompilation.cache[subCache]) childCompilation.cache[subCache] = {};
childCompilation.cache = childCompilation.cache[subCache];
}
});
await (util.promisify(childCompiler.runAsChild.bind(childCompiler)))();
const versionVar = this.options.version ?
`var VERSION = ${JSON.stringify(this.options.version)};` : '';
const original = childCompilation.assets[workerOptions.filename].source();
const source = `${versionVar}var BUILD_ASSETS=${JSON.stringify(assetMapping)};${original}`;
childCompilation.assets[workerOptions.filename] = {
source: () => source,
size: () => Buffer.byteLength(source, 'utf8')
};
Object.assign(compilation.assets, childCompilation.assets);
}
};

7
global.d.ts vendored
View File

@@ -1,21 +1,16 @@
declare const __webpack_public_path__: string; declare const __webpack_public_path__: string;
declare const PRERENDER: boolean;
declare interface NodeModule { declare interface NodeModule {
hot: any; hot: any;
} }
declare interface Window { declare interface Window {
STATE: any; STATE: any
ga: typeof ga;
} }
declare namespace JSX { declare namespace JSX {
interface Element { } interface Element { }
interface IntrinsicElements { } interface IntrinsicElements { }
interface HTMLAttributes {
decoding?: string;
}
} }
declare module 'classnames' { declare module 'classnames' {

8106
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.0.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,56 @@
} }
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^10.12.6", "@types/node": "^9.6.23",
"@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.0",
"@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",
"classnames": "^2.2.6", "babel-plugin-transform-class-properties": "^6.24.1",
"clean-webpack-plugin": "^1.0.0", "babel-plugin-transform-decorators-legacy": "^1.3.5",
"comlink": "^3.0.3", "babel-plugin-transform-object-rest-spread": "^6.26.0",
"copy-webpack-plugin": "^4.6.0", "babel-plugin-transform-react-constant-elements": "^6.23.0",
"critters-webpack-plugin": "^2.0.1", "babel-plugin-transform-react-jsx": "^6.24.1",
"css-loader": "^1.0.1", "babel-plugin-transform-react-remove-prop-types": "^0.4.14",
"ejs": "^2.6.1", "babel-preset-env": "^1.7.0",
"babel-register": "^6.26.0",
"clean-webpack-plugin": "^0.1.19",
"copy-webpack-plugin": "^4.5.2",
"css-loader": "^0.28.11",
"exports-loader": "^0.7.0", "exports-loader": "^0.7.0",
"file-drop-element": "^0.0.9", "file-loader": "^1.1.11",
"file-loader": "^2.0.0",
"html-webpack-plugin": "^3.2.0", "html-webpack-plugin": "^3.2.0",
"husky": "^1.1.4", "husky": "^1.0.0-rc.13",
"idb-keyval": "^3.1.0", "if-env": "^1.0.4",
"linkstate": "^1.1.1",
"loader-utils": "^1.1.0", "loader-utils": "^1.1.0",
"mini-css-extract-plugin": "^0.4.4", "mini-css-extract-plugin": "^0.3.0",
"minimatch": "^3.0.4", "node-sass": "^4.9.3",
"node-sass": "^4.11.0", "optimize-css-assets-webpack-plugin": "^4.0.3",
"optimize-css-assets-webpack-plugin": "^5.0.1",
"pointer-tracker": "^2.0.3",
"preact": "^8.3.1",
"prerender-loader": "^1.2.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", "webpack": "^4.19.1",
"webpack": "^4.25.1", "webpack-bundle-analyzer": "^2.13.1",
"webpack-bundle-analyzer": "^3.0.3", "webpack-cli": "^2.1.5",
"webpack-cli": "^3.1.2", "webpack-dev-server": "^3.1.5",
"webpack-dev-server": "^3.1.10", "webpack-plugin-replace": "^1.1.1",
"classnames": "^2.2.6",
"comlink": "^3.0.3",
"linkstate": "^1.1.1",
"preact": "^8.3.1",
"pretty-bytes": "^5.1.0",
"worker-plugin": "^1.1.1" "worker-plugin": "^1.1.1"
} }
} }

View File

@@ -1,8 +1,9 @@
import { nativeDecode, sniffMimeType, canDecodeImage } from '../lib/util'; import { nativeDecode, sniffMimeType, canDecodeImage } from '../lib/util';
import Processor from './processor'; import Processor from './processor';
import webpDataUrl from 'url-loader!./tiny.webp';
const nativeWebPSupported = canDecodeImage(webpDataUrl); // tslint:disable-next-line:max-line-length Its a data URL. Whatcha gonna do?
const webpFile = 'data:image/webp;base64,UklGRh4AAABXRUJQVlA4TBEAAAAvAAAAAAfQ//73v/+BiOh/AAA=';
const nativeWebPSupported = canDecodeImage(webpFile);
export async function decodeImage(blob: Blob, processor: Processor): Promise<ImageData> { export async function decodeImage(blob: Blob, processor: Processor): Promise<ImageData> {
const mimeType = await sniffMimeType(blob); const mimeType = await sniffMimeType(blob);

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

@@ -17,10 +17,6 @@ export interface EncodeOptions {
trellis_opt_zero: boolean; trellis_opt_zero: boolean;
trellis_opt_table: boolean; trellis_opt_table: boolean;
trellis_loops: number; trellis_loops: number;
auto_subsample: boolean;
chroma_subsample: number;
separate_chroma_quality: boolean;
chroma_quality: number;
} }
export interface EncoderState { type: typeof type; options: EncodeOptions; } export interface EncoderState { type: typeof type; options: EncodeOptions; }
@@ -42,8 +38,4 @@ export const defaultOptions: EncodeOptions = {
trellis_opt_zero: false, trellis_opt_zero: false,
trellis_opt_table: false, trellis_opt_table: false,
trellis_loops: 1, trellis_loops: 1,
auto_subsample: true,
chroma_subsample: 2,
separate_chroma_quality: false,
chroma_quality: 75,
}; };

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';
@@ -39,13 +39,8 @@ export default class MozJPEGEncoderOptions extends Component<Props, State> {
trellis_multipass: inputFieldChecked(form.trellis_multipass, options.trellis_multipass), trellis_multipass: inputFieldChecked(form.trellis_multipass, options.trellis_multipass),
trellis_opt_zero: inputFieldChecked(form.trellis_opt_zero, options.trellis_opt_zero), trellis_opt_zero: inputFieldChecked(form.trellis_opt_zero, options.trellis_opt_zero),
trellis_opt_table: inputFieldChecked(form.trellis_opt_table, options.trellis_opt_table), trellis_opt_table: inputFieldChecked(form.trellis_opt_table, options.trellis_opt_table),
auto_subsample: inputFieldChecked(form.auto_subsample, options.auto_subsample),
separate_chroma_quality:
inputFieldChecked(form.separate_chroma_quality, options.separate_chroma_quality),
// .value // .value
quality: inputFieldValueAsNumber(form.quality, options.quality), quality: inputFieldValueAsNumber(form.quality, options.quality),
chroma_quality: inputFieldValueAsNumber(form.chroma_quality, options.chroma_quality),
chroma_subsample: inputFieldValueAsNumber(form.chroma_subsample, options.chroma_subsample),
smoothing: inputFieldValueAsNumber(form.smoothing, options.smoothing), smoothing: inputFieldValueAsNumber(form.smoothing, options.smoothing),
color_space: inputFieldValueAsNumber(form.color_space, options.color_space), color_space: inputFieldValueAsNumber(form.color_space, options.color_space),
quant_table: inputFieldValueAsNumber(form.quant_table, options.quant_table), quant_table: inputFieldValueAsNumber(form.quant_table, options.quant_table),
@@ -58,7 +53,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"
@@ -80,72 +75,6 @@ export default class MozJPEGEncoderOptions extends Component<Props, State> {
<Expander> <Expander>
{showAdvanced ? {showAdvanced ?
<div> <div>
<label class={style.optionTextFirst}>
Channels:
<Select
name="color_space"
value={options.color_space}
onChange={this.onChange}
>
<option value={MozJpegColorSpace.GRAYSCALE}>Grayscale</option>
<option value={MozJpegColorSpace.RGB}>RGB</option>
<option value={MozJpegColorSpace.YCbCr}>YCbCr</option>
</Select>
</label>
<Expander>
{options.color_space === MozJpegColorSpace.YCbCr ?
<div>
<label class={style.optionInputFirst}>
<Checkbox
name="auto_subsample"
checked={options.auto_subsample}
onChange={this.onChange}
/>
Auto subsample chroma
</label>
<Expander>
{options.auto_subsample ? null :
<div class={style.optionOneCell}>
<Range
name="chroma_subsample"
min="1"
max="4"
value={options.chroma_subsample}
onInput={this.onChange}
>
Subsample chroma by:
</Range>
</div>
}
</Expander>
<label class={style.optionInputFirst}>
<Checkbox
name="separate_chroma_quality"
checked={options.separate_chroma_quality}
onChange={this.onChange}
/>
Separate chroma quality
</label>
<Expander>
{options.separate_chroma_quality ?
<div class={style.optionOneCell}>
<Range
name="chroma_quality"
min="0"
max="100"
value={options.chroma_quality}
onInput={this.onChange}
>
Chroma quality:
</Range>
</div>
: null
}
</Expander>
</div>
: null
}
</Expander>
<label class={style.optionInputFirst}> <label class={style.optionInputFirst}>
<Checkbox <Checkbox
name="baseline" name="baseline"
@@ -190,6 +119,18 @@ export default class MozJPEGEncoderOptions extends Component<Props, State> {
Smoothing: Smoothing:
</Range> </Range>
</div> </div>
<label class={style.optionTextFirst}>
Channels:
<Select
name="color_space"
value={options.color_space}
onChange={this.onChange}
>
<option value={MozJpegColorSpace.GRAYSCALE}>Grayscale</option>
<option value={MozJpegColorSpace.RGB}>RGB</option>
<option value={MozJpegColorSpace.YCbCr}>YCbCr</option>
</Select>
</label>
<label class={style.optionTextFirst}> <label class={style.optionTextFirst}>
Quantization: Quantization:
<Select <Select

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

@@ -0,0 +1,41 @@
import { expose } from 'comlink';
import { EncodeOptions as MozJPEGEncoderOptions } from './mozjpeg/encoder-meta';
import { QuantizeOptions } from './imagequant/processor-meta';
import { EncodeOptions as OptiPNGEncoderOptions } from './optipng/encoder-meta';
import { EncodeOptions as WebPEncoderOptions } from './webp/encoder-meta';
async function mozjpegEncode(
data: ImageData, options: MozJPEGEncoderOptions,
): Promise<ArrayBuffer> {
const { encode } = await import('./mozjpeg/encoder');
return encode(data, options);
}
async function quantize(data: ImageData, opts: QuantizeOptions): Promise<ImageData> {
const { process } = await import('./imagequant/processor');
return process(data, opts);
}
async function optiPngEncode(
data: BufferSource, options: OptiPNGEncoderOptions,
): Promise<ArrayBuffer> {
const { compress } = await import('./optipng/encoder');
return compress(data, options);
}
async function webpEncode(
data: ImageData, options: WebPEncoderOptions,
): Promise<ArrayBuffer> {
const { encode } = await import('./webp/encoder');
return encode(data, options);
}
async function webpDecode(data: ArrayBuffer): Promise<ImageData> {
const { decode } = await import('./webp/decoder');
return decode(data);
}
const exports = { mozjpegEncode, quantize, optiPngEncode, webpEncode, webpDecode };
export type ProcessorWorkerApi = typeof exports;
expose(exports, self);

View File

@@ -1,65 +0,0 @@
import { expose } from 'comlink';
async function mozjpegEncode(
data: ImageData, options: import('../mozjpeg/encoder-meta').EncodeOptions,
): Promise<ArrayBuffer> {
const { encode } = await import(
/* webpackChunkName: "process-mozjpeg-enc" */
'../mozjpeg/encoder',
);
return encode(data, options);
}
async function quantize(
data: ImageData, opts: import('../imagequant/processor-meta').QuantizeOptions,
): Promise<ImageData> {
const { process } = await import(
/* webpackChunkName: "process-imagequant" */
'../imagequant/processor',
);
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(
data: BufferSource, options: import('../optipng/encoder-meta').EncodeOptions,
): Promise<ArrayBuffer> {
const { compress } = await import(
/* webpackChunkName: "process-optipng" */
'../optipng/encoder',
);
return compress(data, options);
}
async function webpEncode(
data: ImageData, options: import('../webp/encoder-meta').EncodeOptions,
): Promise<ArrayBuffer> {
const { encode } = await import(
/* webpackChunkName: "process-webp-enc" */
'../webp/encoder',
);
return encode(data, options);
}
async function webpDecode(data: ArrayBuffer): Promise<ImageData> {
const { decode } = await import(
/* webpackChunkName: "process-webp-dec" */
'../webp/decoder',
);
return decode(data);
}
const exports = { mozjpegEncode, quantize, rotate, optiPngEncode, webpEncode, webpDecode };
export type ProcessorWorkerApi = typeof exports;
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,10 +18,8 @@ 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 = 10000; const workerTimeout = 1000;
interface ProcessingJobOptions { interface ProcessingJobOptions {
needsWorker?: boolean; needsWorker?: boolean;
@@ -62,10 +61,7 @@ export default class Processor {
// worker-loader does magic here. // worker-loader does magic here.
// @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.ts', { type: 'module' }) as Worker;
'./processor-worker',
{ name: 'processor-worker', type: 'module' },
) as Worker;
// Need to do some TypeScript trickery to make the type match. // Need to do some TypeScript trickery to make the type match.
this._workerApi = proxy(this._worker) as any as ProcessorWorkerApi; this._workerApi = proxy(this._worker) as any as ProcessorWorkerApi;
} }
@@ -118,18 +114,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,76 +0,0 @@
import { RotateOptions } from './processor-meta';
const bpp = 4;
export function rotate(data: ImageData, opts: RotateOptions): ImageData {
const { rotate } = opts;
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;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 B

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';
@@ -26,10 +26,8 @@ const losslessPresets:[number, number][] = [
]; ];
const losslessPresetDefault = 6; const losslessPresetDefault = 6;
function determineLosslessQuality(quality: number, method: number): number { function determineLosslessQuality(quality: number): number {
const index = losslessPresets.findIndex( const index = losslessPresets.findIndex(item => item[1] === quality);
([presetMethod, presetQuality]) => presetMethod === method && presetQuality === quality,
);
if (index !== -1) return index; if (index !== -1) return index;
// Quality doesn't match one of the presets. // Quality doesn't match one of the presets.
// This can happen when toggling 'lossless'. // This can happen when toggling 'lossless'.
@@ -47,7 +45,7 @@ export default class WebPEncoderOptions extends Component<Props, State> {
const lossless = inputFieldCheckedAsNumber(form.lossless); const lossless = inputFieldCheckedAsNumber(form.lossless);
const { options } = this.props; const { options } = this.props;
const losslessPresetValue = inputFieldValueAsNumber( const losslessPresetValue = inputFieldValueAsNumber(
form.lossless_preset, determineLosslessQuality(options.quality, options.method), form.lossless_preset, determineLosslessQuality(options.quality),
); );
const newOptions: EncodeOptions = { const newOptions: EncodeOptions = {
@@ -99,7 +97,7 @@ export default class WebPEncoderOptions extends Component<Props, State> {
name="lossless_preset" name="lossless_preset"
min="0" min="0"
max="9" max="9"
value={determineLosslessQuality(options.quality, options.method)} value={determineLosslessQuality(options.quality)}
onInput={this.onChange} onInput={this.onChange}
> >
Effort: Effort:
@@ -319,7 +317,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

@@ -0,0 +1,153 @@
import { bind } from '../../../../lib/initial-util';
import './styles.css';
// tslint:disable-next-line:max-line-length
function firstMatchingItem(list: DataTransferItemList, acceptVal: string): DataTransferItem | undefined {
// Split accepts values by ',' then by '/'. Trim everything & lowercase.
const accepts = acceptVal.toLowerCase().split(',').map((accept) => {
return accept.trim().split('/').map(part => part.trim());
}).filter(acceptParts => acceptParts.length === 2); // Filter invalid values
return Array.from(list).find((item) => {
if (item.kind !== 'file') return false;
// 'Parse' the type.
const [typeMain, typeSub] = item.type.toLowerCase().split('/').map(s => s.trim());
for (const [acceptMain, acceptSub] of accepts) {
// Look for an exact match, or a partial match if * is accepted, eg image/*.
if (typeMain === acceptMain && (acceptSub === '*' || typeSub === acceptSub)) {
return true;
}
}
return false;
});
}
function getFileData(data: DataTransfer, accept: string): File | undefined {
const dragDataItem = firstMatchingItem(data.items, accept);
if (!dragDataItem) return;
return dragDataItem.getAsFile() || undefined;
}
interface FileDropEventInit extends EventInit {
action: FileDropAccept;
file: File;
}
type FileDropAccept = 'drop' | 'paste';
// Safari and Edge don't quite support extending Event, this works around it.
function fixExtendedEvent(instance: Event, type: Function) {
if (!(instance instanceof type)) {
Object.setPrototypeOf(instance, type.prototype);
}
}
export class FileDropEvent extends Event {
private _action: FileDropAccept;
private _file: File;
constructor(typeArg: string, eventInitDict: FileDropEventInit) {
super(typeArg, eventInitDict);
fixExtendedEvent(this, FileDropEvent);
this._file = eventInitDict.file;
this._action = eventInitDict.action;
}
get action() {
return this._action;
}
get file() {
return this._file;
}
}
/*
Example Usage.
<file-drop
accept='image/*'
class='drop-valid|drop-invalid'
>
[everything in here is a drop target.]
</file-drop>
dropElement.addEventListner('dropfile', (event) => console.log(event.detail))
*/
export class FileDrop extends HTMLElement {
private _dragEnterCount = 0;
constructor() {
super();
this.addEventListener('dragover', event => event.preventDefault());
this.addEventListener('drop', this._onDrop);
this.addEventListener('dragenter', this._onDragEnter);
this.addEventListener('dragend', () => this._reset());
this.addEventListener('dragleave', this._onDragLeave);
this.addEventListener('paste', this._onPaste);
}
get accept() {
return this.getAttribute('accept') || '';
}
set accept(val: string) {
this.setAttribute('accept', val);
}
@bind
private _onDragEnter(event: DragEvent) {
this._dragEnterCount += 1;
if (this._dragEnterCount > 1) return;
// We don't have data, attempt to get it and if it matches, set the correct state.
const validDrop: boolean = event.dataTransfer.items.length ?
!!firstMatchingItem(event.dataTransfer.items, this.accept) :
// Safari doesn't give file information on drag enter, so the best we can do is return valid.
true;
if (validDrop) {
this.classList.add('drop-valid');
} else {
this.classList.add('drop-invalid');
}
}
@bind
private _onDragLeave() {
this._dragEnterCount -= 1;
if (this._dragEnterCount === 0) {
this._reset();
}
}
@bind
private _onDrop(event: DragEvent) {
event.preventDefault();
this._reset();
const action = 'drop';
const file = getFileData(event.dataTransfer, this.accept);
if (file === undefined) return;
this.dispatchEvent(new FileDropEvent('filedrop', { action, file }));
}
@bind
private _onPaste(event: ClipboardEvent) {
const action = 'paste';
const file = getFileData(event.clipboardData, this.accept);
if (file === undefined) return;
this.dispatchEvent(new FileDropEvent('filedrop', { action, file }));
}
private _reset() {
this._dragEnterCount = 0;
this.classList.remove('drop-valid');
this.classList.remove('drop-invalid');
}
}
customElements.define('file-drop', FileDrop);

View File

@@ -0,0 +1,19 @@
import { FileDropEvent, FileDrop } from '.';
declare global {
interface HTMLElementEventMap {
'filedrop': FileDropEvent;
}
namespace JSX {
interface IntrinsicElements {
'file-drop': FileDropAttributes;
}
interface FileDropAttributes extends HTMLAttributes {
accept?: string;
onfiledrop?: ((this: FileDrop, ev: FileDropEvent) => any) | null;
}
}
}

View File

@@ -0,0 +1,3 @@
file-drop {
display: block;
}

View File

@@ -2,27 +2,27 @@ import { h, Component } from 'preact';
import { bind, linkRef, Fileish } from '../../lib/initial-util'; import { bind, linkRef, Fileish } from '../../lib/initial-util';
import * as style from './style.scss'; import * as style from './style.scss';
import { FileDropEvent } from 'file-drop-element'; import { FileDropEvent } from './custom-els/FileDrop';
import 'file-drop-element'; import './custom-els/FileDrop';
import SnackBarElement, { SnackOptions } from '../../lib/SnackBar'; import SnackBarElement from '../../lib/SnackBar';
import '../../lib/SnackBar'; import '../../lib/SnackBar';
import Intro from '../intro'; import Intro from '../intro';
import '../custom-els/LoadingSpinner'; import '../custom-els/LoadingSpinner';
const compressPromise = import( // This is imported for TypeScript only. It isn't used.
/* webpackChunkName: "main-app" */ import Compress from '../compress';
'../compress',
); export interface SourceImage {
const offlinerPromise = import( file: File | Fileish;
/* webpackChunkName: "offliner" */ data: ImageData;
'../../lib/offliner', 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> {
@@ -36,14 +36,12 @@ export default class App extends Component<Props, State> {
constructor() { constructor() {
super(); super();
compressPromise.then((module) => { import('../compress').then((module) => {
this.setState({ Compress: module.default }); this.setState({ Compress: module.default });
}).catch(() => { }).catch(() => {
this.showSnack('Failed to load app'); this.showError('Failed to load app');
}); });
offlinerPromise.then(({ offliner }) => offliner(this.showSnack));
// In development, persist application state across hot reloads: // In development, persist application state across hot reloads:
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
this.setState(window.STATE); this.setState(window.STATE);
@@ -68,14 +66,9 @@ export default class App extends Component<Props, State> {
} }
@bind @bind
private showSnack(message: string, options: SnackOptions = {}): Promise<string> { private showError(error: string) {
if (!this.snackbar) throw Error('Snackbar missing'); if (!this.snackbar) throw Error('Snackbar missing');
return this.snackbar.showSnackbar(message, options); this.snackbar.showSnackbar({ message: error });
}
@bind
private onBack() {
this.setState({ file: undefined });
} }
render({}: Props, { file, Compress }: State) { render({}: Props, { file, Compress }: State) {
@@ -83,9 +76,9 @@ export default class App extends Component<Props, State> {
<div id="app" class={style.app}> <div id="app" class={style.app}>
<file-drop accept="image/*" onfiledrop={this.onFileDrop} class={style.drop}> <file-drop accept="image/*" onfiledrop={this.onFileDrop} class={style.drop}>
{(!file) {(!file)
? <Intro onFile={this.onIntroPickFile} showSnack={this.showSnack} /> ? <Intro onFile={this.onIntroPickFile} onError={this.showError} />
: (Compress) : (Compress)
? <Compress file={file} showSnack={this.showSnack} onBack={this.onBack} /> ? <Compress file={file} onError={this.showError} />
: <loading-spinner class={style.appLoader}/> : <loading-spinner class={style.appLoader}/>
} }
<snack-bar ref={linkRef(this, 'snackbar')} /> <snack-bar ref={linkRef(this, 'snackbar')} />

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';
@@ -59,11 +59,13 @@ const encoderOptionsComponentMap = {
interface Props { interface Props {
mobileView: boolean; mobileView: boolean;
source?: SourceImage; source?: SourceImage;
imageIndex: number;
encoderState: EncoderState; encoderState: EncoderState;
preprocessorState: PreprocessorState; preprocessorState: PreprocessorState;
onEncoderTypeChange(newType: EncoderType): void; onEncoderTypeChange(newType: EncoderType): void;
onEncoderOptionsChange(newOptions: EncoderOptions): void; onEncoderOptionsChange(newOptions: EncoderOptions): void;
onPreprocessorOptionsChange(newOptions: PreprocessorState): void; onPreprocessorOptionsChange(newOptions: PreprocessorState): void;
onCopyToOtherClick(): void;
} }
interface State { interface State {
@@ -81,7 +83,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 +93,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,22 +103,29 @@ 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),
); );
} }
@bind
onCopyToOtherClick(event: Event) {
event.preventDefault();
this.props.onCopyToOtherClick();
}
render( render(
{ {
source, source,
imageIndex,
encoderState, encoderState,
preprocessorState, preprocessorState,
onEncoderOptionsChange, onEncoderOptionsChange,
@@ -144,7 +153,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}
/> />
@@ -196,6 +205,14 @@ export default class Options extends Component<Props, State> {
/> />
: null} : null}
</Expander> </Expander>
<div class={style.optionsCopy}>
<button onClick={this.onCopyToOtherClick} class={style.copyButton}>
{imageIndex === 1 && '← '}
Copy settings across
{imageIndex === 0 && ' →'}
</button>
</div>
</div> </div>
); );
} }

View File

@@ -54,5 +54,19 @@ $horizontalPadding: 15px;
.options-scroller { .options-scroller {
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
-webkit-overflow-scrolling: touch; }
.options-copy {
display: grid;
background: rgba(0, 0, 0, 0.9);
padding: 5px;
}
.copy-button {
composes: unbutton from '../../lib/util.scss';
background: #484848;
border-radius: 4px;
color: #fff;
text-align: left;
padding: 5px 10px;
} }

View File

@@ -1,5 +1,5 @@
import PointerTracker, { Pointer } from 'pointer-tracker';
import './styles.css'; import './styles.css';
import { PointerTracker, Pointer } from '../../../../lib/PointerTracker';
interface Point { interface Point {
clientX: number; clientX: number;
@@ -242,7 +242,7 @@ export default class PinchZoom extends HTMLElement {
/** /**
* Update transform values without checking bounds. This is only called in setTransform. * Update transform values without checking bounds. This is only called in setTransform.
*/ */
private _updateTransform(scale: number, x: number, y: number, allowChangeEvent: boolean) { _updateTransform(scale: number, x: number, y: number, allowChangeEvent: boolean) {
// Avoid scaling to zero // Avoid scaling to zero
if (scale < MIN_SCALE) return; if (scale < MIN_SCALE) return;

View File

@@ -1,5 +1,5 @@
import PointerTracker, { Pointer } from 'pointer-tracker';
import * as styles from './styles.css'; import * as styles from './styles.css';
import { PointerTracker, Pointer } from '../../../../lib/PointerTracker';
const legacyClipCompatAttr = 'legacy-clip-compat'; const legacyClipCompatAttr = 'legacy-clip-compat';
const orientationAttr = 'orientation'; const orientationAttr = 'orientation';
@@ -70,13 +70,12 @@ export default class TwoUp extends HTMLElement {
connectedCallback() { connectedCallback() {
this._childrenChange(); this._childrenChange();
this._handle.innerHTML = `<div class="${styles.scrubber}">${
`<svg viewBox="0 0 27 20" fill="currentColor">${
'<path d="M17 19.2l9.5-9.6L16.9 0zM9.6 0L0 9.6l9.6 9.6z"/>'
}</svg>`
}</div>`;
if (!this._everConnected) { if (!this._everConnected) {
this._handle.innerHTML = `<div class="${styles.scrubber}">${
`<svg viewBox="0 0 27 20" fill="currentColor">${
'<path d="M17 19.2l9.5-9.6L16.9 0zM9.6 0L0 9.6l9.6 9.6z"/>'
}</svg>`
}</div>`;
this._resetPosition(); this._resetPosition();
this._everConnected = true; this._everConnected = true;
} }

View File

@@ -5,29 +5,16 @@ 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 } 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;
onInputProcessorChange: (newState: InputProcessorState) => void;
} }
interface State { interface State {
@@ -60,15 +47,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 +60,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 +67,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 +84,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 +112,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 }, () => {
@@ -235,27 +177,14 @@ export default class Output extends Component<Props, State> {
const clonedEvent = new (event.constructor as typeof Event)(event.type, event); const clonedEvent = new (event.constructor as typeof Event)(event.type, event);
this.retargetedEvents.add(clonedEvent); this.retargetedEvents.add(clonedEvent);
this.pinchZoomLeft.dispatchEvent(clonedEvent); this.pinchZoomLeft.dispatchEvent(clonedEvent);
// Unfocus any active element on touchend. This fixes an issue on (at least) Android Chrome,
// where the software keyboard is hidden, but the input remains focused, then after interaction
// with this element the keyboard reappears for NO GOOD REASON. Thanks Android.
if (
event.type === 'touchend' &&
document.activeElement &&
document.activeElement instanceof HTMLElement
) {
document.activeElement.blur();
}
} }
render( render(
{ mobileView, leftImgContain, rightImgContain, source, onBack }: Props, { mobileView, leftImgContain, rightImgContain, originalImage }: 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 +206,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 +219,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}
@@ -303,12 +232,6 @@ export default class Output extends Component<Props, State> {
</pinch-zoom> </pinch-zoom>
</two-up> </two-up>
<div class={style.back}>
<button class={style.button} onClick={onBack}>
<BackIcon />
</button>
</div>
<div class={style.controls}> <div class={style.controls}>
<div class={style.zoomControls}> <div class={style.zoomControls}>
<button class={style.button} onClick={this.zoomOut}> <button class={style.button} onClick={this.zoomOut}>
@@ -336,21 +259,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;
@@ -47,7 +38,7 @@
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
padding: 9px 84px; padding: 9px;
overflow: hidden; overflow: hidden;
flex-wrap: wrap; flex-wrap: wrap;
contain: content; contain: content;
@@ -59,12 +50,10 @@
} }
@media (min-width: 860px) { @media (min-width: 860px) {
padding: 9px;
top: auto; top: auto;
left: 320px; left: 320px;
right: 320px; right: 320px;
bottom: 0; bottom: 0;
flex-wrap: wrap-reverse;
} }
} }
@@ -89,20 +78,14 @@
display: flex; display: flex;
align-items: center; align-items: center;
box-sizing: border-box; box-sizing: border-box;
height: 48px;
padding: 0 16px;
margin: 4px; margin: 4px;
background-color: #fff; background-color: #fff;
border: 1px solid rgba(0,0,0,0.2); border: 1px solid rgba(0,0,0,0.2);
border-radius: 5px; border-radius: 5px;
line-height: 1; line-height: 1;
white-space: nowrap; white-space: nowrap;
height: 36px;
padding: 0 8px;
cursor: pointer;
@media (min-width: 600px) {
height: 48px;
padding: 0 16px;
}
&:focus { &:focus {
box-shadow: 0 0 0 2px var(--button-fg); box-shadow: 0 0 0 2px var(--button-fg);
@@ -112,20 +95,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 +127,6 @@
border-bottom: 1px dashed #999; border-bottom: 1px dashed #999;
} }
.back { .output-canvas {
position: absolute; flex-shrink: 0;
top: 0;
left: 0;
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
@@ -252,8 +252,8 @@ export default class MultiPanel extends HTMLElement {
return this.firstElementChild as HTMLElement; return this.firstElementChild as 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

@@ -34,30 +34,21 @@ import Processor from '../../codecs/processor';
import { VectorResizeOptions, BitmapResizeOptions } from '../../codecs/resize/processor-meta'; import { VectorResizeOptions, BitmapResizeOptions } from '../../codecs/resize/processor-meta';
import './custom-els/MultiPanel'; import './custom-els/MultiPanel';
import Results from '../results'; import Results from '../results';
import { ExpandIcon, CopyAcrossIconProps } from '../../lib/icons'; import { ExpandIcon } from '../../lib/icons';
import SnackBarElement from '../../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;
@@ -67,13 +58,12 @@ interface Side {
interface Props { interface Props {
file: File | Fileish; file: File | Fileish;
showSnack: SnackBarElement['showSnackbar']; onError: (msg: string) => void;
onBack: () => void;
} }
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,27 +75,12 @@ interface UpdateImageOptions {
skipPreprocessing?: boolean; skipPreprocessing?: boolean;
} }
async function processInput(
data: ImageData,
inputProcessData: InputProcessorState,
processor: Processor,
) {
let processedData = data;
if (inputProcessData.rotate.rotate !== 0) {
processedData = await processor.rotate(processedData, inputProcessData.rotate);
}
return processedData;
}
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(
@@ -154,26 +129,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.
@@ -181,7 +136,7 @@ async function processSvg(blob: Blob): Promise<HTMLImageElement> {
const parser = new DOMParser(); const parser = new DOMParser();
const text = await blobToText(blob); const text = await blobToText(blob);
const document = parser.parseFromString(text, 'image/svg+xml'); const document = parser.parseFromString(text, 'image/svg+xml');
const svg = document.documentElement!; const svg = document.documentElement;
if (svg.hasAttribute('width') && svg.hasAttribute('height')) { if (svg.hasAttribute('width') && svg.hasAttribute('height')) {
return blobToImg(blob); return blobToImg(blob);
@@ -201,11 +156,6 @@ async function processSvg(blob: Blob): Promise<HTMLImageElement> {
// These are only used in the mobile view // These are only used in the mobile view
const resultTitles = ['Top', 'Bottom']; const resultTitles = ['Top', 'Bottom'];
// These are only used in the desktop view
const buttonPositions =
['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)');
@@ -214,21 +164,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,
@@ -245,8 +191,6 @@ export default class Compress extends Component<Props, State> {
super(props); super(props);
this.widthQuery.addListener(this.onMobileWidthChange); this.widthQuery.addListener(this.onMobileWidthChange);
this.updateFile(props.file); this.updateFile(props.file);
import('../../lib/offliner').then(({ mainAppLoaded }) => mainAppLoaded());
} }
@bind @bind
@@ -256,7 +200,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,
}), }),
@@ -265,50 +209,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);
}); });
@@ -316,83 +247,17 @@ export default class Compress extends Component<Props, State> {
} }
} }
private async onCopyToOtherClick(index: 0 | 1) { private onCopyToOtherClick(index: 0 | 1) {
const otherIndex = (index + 1) % 2; const otherIndex = (index + 1) % 2;
const oldSettings = this.state.sides[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', {
timeout: 5000,
actions: ['undo', 'dismiss'],
});
if (result !== 'undo') return;
this.setState({
sides: cleanSet(this.state.sides, 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 });
@@ -401,7 +266,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
@@ -409,75 +274,73 @@ 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.onError('Invalid image');
this.setState({ loading: false }); this.setState({ loading: false });
} }
} }
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.
@@ -490,113 +353,91 @@ 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.onError(`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({ }: 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} imageIndex={index}
encoderState={side.latestSettings.encoderState} preprocessorState={image.preprocessorState}
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)}
onCopyToOtherClick={this.onCopyToOtherClick.bind(this, index)}
/> />
)); ));
const copyDirections = const results = images.map((image, i) => (
(mobileView ? ['down', 'up'] : ['right', 'left']) as CopyAcrossIconProps['copyDirection'][];
const results = sides.map((side, 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]}
onCopyToOtherClick={this.onCopyToOtherClick.bind(this, 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[i]} (${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}
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

View File

@@ -22,7 +22,7 @@
max-width: 400px; max-width: 400px;
margin: 0 auto; margin: 0 auto;
width: calc(100% - 60px); width: calc(100% - 60px);
max-height: calc(100% - 104px); max-height: calc(100% - 143px);
overflow: hidden; overflow: hidden;
@media (min-width: 600px) { @media (min-width: 600px) {
@@ -32,7 +32,7 @@
} }
@media (min-width: 860px) { @media (min-width: 860px) {
max-height: calc(100% - 40px); max-height: 100%;
} }
} }

View File

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 2.8 MiB

View File

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

Before

Width:  |  Height:  |  Size: 2.7 MiB

After

Width:  |  Height:  |  Size: 2.7 MiB

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

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

@@ -4,15 +4,14 @@ import { bind, linkRef, Fileish } from '../../lib/initial-util';
import '../custom-els/LoadingSpinner'; import '../custom-els/LoadingSpinner';
import logo from './imgs/logo.svg'; import logo from './imgs/logo.svg';
import largePhoto from './imgs/demos/demo-large-photo.jpg'; import largePhoto from './imgs/demos/large-photo.jpg';
import artwork from './imgs/demos/demo-artwork.jpg'; import artwork from './imgs/demos/artwork.jpg';
import deviceScreen from './imgs/demos/demo-device-screen.png'; import deviceScreen from './imgs/demos/device-screen.png';
import largePhotoIcon from './imgs/demos/icon-demo-large-photo.jpg'; import largePhotoIcon from './imgs/demos/large-photo-icon.jpg';
import artworkIcon from './imgs/demos/icon-demo-artwork.jpg'; import artworkIcon from './imgs/demos/artwork-icon.jpg';
import deviceScreenIcon from './imgs/demos/icon-demo-device-screen.jpg'; import deviceScreenIcon from './imgs/demos/device-screen-icon.jpg';
import logoIcon from './imgs/demos/icon-demo-logo.png'; import logoIcon from './imgs/demos/logo-icon.png';
import * as style from './style.scss'; import * as style from './style.scss';
import SnackBarElement from '../../lib/SnackBar';
const demos = [ const demos = [
{ {
@@ -43,7 +42,7 @@ const demos = [
interface Props { interface Props {
onFile: (file: File | Fileish) => void; onFile: (file: File | Fileish) => void;
showSnack: SnackBarElement['showSnackbar']; onError: (error: string) => void;
} }
interface State { interface State {
fetchingDemoIndex?: number; fetchingDemoIndex?: number;
@@ -80,7 +79,7 @@ export default class Intro extends Component<Props, State> {
this.props.onFile(file); this.props.onFile(file);
} catch (err) { } catch (err) {
this.setState({ fetchingDemoIndex: undefined }); this.setState({ fetchingDemoIndex: undefined });
this.props.showSnack("Couldn't fetch demo image"); this.props.onError("Couldn't fetch demo image");
} }
} }
@@ -90,7 +89,7 @@ export default class Intro extends Component<Props, State> {
<div> <div>
<div class={style.logoSizer}> <div class={style.logoSizer}>
<div class={style.logoContainer}> <div class={style.logoContainer}>
<img src={logo} class={style.logo} alt="Squoosh" decoding="async" /> <img src={logo} class={style.logo} alt="Squoosh" />
</div> </div>
</div> </div>
<p class={style.openImageGuide}> <p class={style.openImageGuide}>
@@ -111,7 +110,7 @@ export default class Intro extends Component<Props, State> {
<div class={style.demo}> <div class={style.demo}>
<div class={style.demoImgContainer}> <div class={style.demoImgContainer}>
<div class={style.demoImgAspect}> <div class={style.demoImgAspect}>
<img class={style.demoIcon} src={demo.iconUrl} alt="" decoding="async" /> <img class={style.demoIcon} src={demo.iconUrl} alt=""/>
{fetchingDemoIndex === i && {fetchingDemoIndex === i &&
<div class={style.demoLoading}> <div class={style.demoLoading}>
<loading-spinner class={style.demoLoadingSpinner}/> <loading-spinner class={style.demoLoadingSpinner}/>
@@ -129,11 +128,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

@@ -2,7 +2,6 @@
font-family: 'intro-text'; font-family: 'intro-text';
font-style: normal; font-style: normal;
font-weight: 300; font-weight: 300;
font-display: block;
// This only contains the chars for "Drag & drop or" // This only contains the chars for "Drag & drop or"
src: url('data:font/woff2;base64,d09GMgABAAAAAAXcAA4AAAAACowAAAWJAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGhYbg2gcMAZgAGwRCAqIQIcnCxYAATYCJAMoBCAFgwAHIBvqCFEU84FMI2Xh/P3g+Tfn532yQ/IgYz4BrJyhtkkZwFBYAZ49sI63e5v/NnqzIfbADyE0qxOqK8ESLoNNdULHihxbW0W86/qHEk4wT/eHShPRZJYYqUGkQdLSWCeSemZBzwpKyX/LRoAhMEQhqCFBw5RHNCc4hbVn35FsxtTXVHYyo7miu5VN2AW1fwzVauRgXnIGo2IWsYdViUoLu6mms5VFAn+SeQ4eBazfj7QodrMt4oyQHaGADEPRpTbDqJaoTENNK6DpOralUszf6gI/QsAhWZSMKVOirikSJxZRLBVD0S4mB0kTBRwopjZ/mt/2/25+bcSipgiHRmwiFI1g+XhwlshyEAsbJzGiGH+U5whHNgiXooplafI1rMFbmIqjGAPhmcSkVFxeu9hw87aXsGyL+dPE05qUpK2WyaVQcZVW+aDmw3aalLJKNmQORcpZYtBIuTrncN4xXoVZY617TBSsx2T1DHgGU6u4etE04wha1GEwjVkEaDttOrl1FCOwUMxgHnuooJo62ukcWEuc1/aT+dZ8b142t5tbzc3mGnP1EJqVTEGMYTjG14YxtGEEG+0E2axhe6Oa1E8UrDHDFjhTRywYNWrU9JHTlw7RmaslkrrGcTJ+znW4EzzP0zovE4Z5d0hqVhBobftBIKkwL09SOv3hhCuv1Dp9taTeCJ2Mj3KDT8iDng5DkWzPw/UdP8idNDkMnUyOwEauwnYLQeLC7GskNe72QKe97AmuA42E5FjfyYTM+HTdQ+Xqb+q4JvptyKZN1w47qMMwL58fyKZM1U6NXgWlOFdxx7DpXHDTz4UB89WMK3HH3uY7mavFopGF+u36lGlqZsL4ugmbqvZxveycMO+a4uyN3o7GT2qdHpfr6W++kNTn1crdx7Z+FW7PfffTmfnXV/2ivsh5UX93zdlzct6QlSuHSumG3oGNNT9/m9yXnDcnKfsmDx8xUaoKi+uvGs99H2ieUJUg8bTnVwQcDd/SPKwYWDUv+QkpT6MulMrcPTXNWYnIowxvoiwnX+opTMkvzOMGgpNpqnK32CNVwCnassw0BwQwTa0rLS3m1DfIoxx5PIE8SvEmSk3pHSWZiRVKjOOQSylJSHGXkhT/u/tg/Vm9UZQcS59TGb1qjcuuT0925iaaU1vaWpZJM4ukqWWlrdWSIcVNlOImvnrzLn53UpnSLzbGT5lUlpTiKiPJFEmyqywFLtOhcaYJkWkaGe/oGBlnmiIiIiKYpqHxLmdaWg5JpxxHSXpajsuVlkPSvb1JelqOC0pubbAn2A2UsDdYmTmjvbVlgTRhVBSSxpbF1nZD+jvkUR4rcJeSFBp2d19SUsVW5DjkUkoSoITHJ7iJEpZnZaL4OiF7g92DN1mz8b1RiM9RDk9ps9pcanamlnj2ftqbJpHJ0wpkRn2+RJ6qsGflpYrPnxG6A4r9zqGY3qCcqDuhsWGQhoXpQ0663cWFM4qNR0Jxj1R0UBT36pahMneH4NYV27jElOeyAAAACACAABy4uvGyOsj21Y9h3gIA3PuxYAYAuC/7vftf7L+PXunMAQDwBQIAAAIA5vR/HwCvOQ//TzLL7cPIHUC0zMI5v7+tHiVfzWOeSrJKZbFabWGNSnJE+jmsnmTjTZm6kBi9r0aLgm8qNk6t67ATuPlEitG+g+E7in1GMYxCxmIF9YzNJK7lRoSPc6PCD+8fxhp+YjdttDNAJw3UUU83M1jFClaylkpkU08NVZqkh0oaaBLPnaCTNhqpoaok2UkPZqy/JyfpKnVLkhrq6KGZCjpZxTJWqN9uJofD5HGMzSXHLaVbOmuTSnOp6cTQgJlaB6oF7RIITul8N+1sYjnL6aJqqoZ2inaxDIY2s2zwlXUs5zj7OPJmAPao+ZhVHy0A') format('woff2'); src: url('data:font/woff2;base64,d09GMgABAAAAAAXcAA4AAAAACowAAAWJAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGhYbg2gcMAZgAGwRCAqIQIcnCxYAATYCJAMoBCAFgwAHIBvqCFEU84FMI2Xh/P3g+Tfn532yQ/IgYz4BrJyhtkkZwFBYAZ49sI63e5v/NnqzIfbADyE0qxOqK8ESLoNNdULHihxbW0W86/qHEk4wT/eHShPRZJYYqUGkQdLSWCeSemZBzwpKyX/LRoAhMEQhqCFBw5RHNCc4hbVn35FsxtTXVHYyo7miu5VN2AW1fwzVauRgXnIGo2IWsYdViUoLu6mms5VFAn+SeQ4eBazfj7QodrMt4oyQHaGADEPRpTbDqJaoTENNK6DpOralUszf6gI/QsAhWZSMKVOirikSJxZRLBVD0S4mB0kTBRwopjZ/mt/2/25+bcSipgiHRmwiFI1g+XhwlshyEAsbJzGiGH+U5whHNgiXooplafI1rMFbmIqjGAPhmcSkVFxeu9hw87aXsGyL+dPE05qUpK2WyaVQcZVW+aDmw3aalLJKNmQORcpZYtBIuTrncN4xXoVZY617TBSsx2T1DHgGU6u4etE04wha1GEwjVkEaDttOrl1FCOwUMxgHnuooJo62ukcWEuc1/aT+dZ8b142t5tbzc3mGnP1EJqVTEGMYTjG14YxtGEEG+0E2axhe6Oa1E8UrDHDFjhTRywYNWrU9JHTlw7RmaslkrrGcTJ+znW4EzzP0zovE4Z5d0hqVhBobftBIKkwL09SOv3hhCuv1Dp9taTeCJ2Mj3KDT8iDng5DkWzPw/UdP8idNDkMnUyOwEauwnYLQeLC7GskNe72QKe97AmuA42E5FjfyYTM+HTdQ+Xqb+q4JvptyKZN1w47qMMwL58fyKZM1U6NXgWlOFdxx7DpXHDTz4UB89WMK3HH3uY7mavFopGF+u36lGlqZsL4ugmbqvZxveycMO+a4uyN3o7GT2qdHpfr6W++kNTn1crdx7Z+FW7PfffTmfnXV/2ivsh5UX93zdlzct6QlSuHSumG3oGNNT9/m9yXnDcnKfsmDx8xUaoKi+uvGs99H2ieUJUg8bTnVwQcDd/SPKwYWDUv+QkpT6MulMrcPTXNWYnIowxvoiwnX+opTMkvzOMGgpNpqnK32CNVwCnassw0BwQwTa0rLS3m1DfIoxx5PIE8SvEmSk3pHSWZiRVKjOOQSylJSHGXkhT/u/tg/Vm9UZQcS59TGb1qjcuuT0925iaaU1vaWpZJM4ukqWWlrdWSIcVNlOImvnrzLn53UpnSLzbGT5lUlpTiKiPJFEmyqywFLtOhcaYJkWkaGe/oGBlnmiIiIiKYpqHxLmdaWg5JpxxHSXpajsuVlkPSvb1JelqOC0pubbAn2A2UsDdYmTmjvbVlgTRhVBSSxpbF1nZD+jvkUR4rcJeSFBp2d19SUsVW5DjkUkoSoITHJ7iJEpZnZaL4OiF7g92DN1mz8b1RiM9RDk9ps9pcanamlnj2ftqbJpHJ0wpkRn2+RJ6qsGflpYrPnxG6A4r9zqGY3qCcqDuhsWGQhoXpQ0663cWFM4qNR0Jxj1R0UBT36pahMneH4NYV27jElOeyAAAACACAABy4uvGyOsj21Y9h3gIA3PuxYAYAuC/7vftf7L+PXunMAQDwBQIAAAIA5vR/HwCvOQ//TzLL7cPIHUC0zMI5v7+tHiVfzWOeSrJKZbFabWGNSnJE+jmsnmTjTZm6kBi9r0aLgm8qNk6t67ATuPlEitG+g+E7in1GMYxCxmIF9YzNJK7lRoSPc6PCD+8fxhp+YjdttDNAJw3UUU83M1jFClaylkpkU08NVZqkh0oaaBLPnaCTNhqpoaok2UkPZqy/JyfpKnVLkhrq6KGZCjpZxTJWqN9uJofD5HGMzSXHLaVbOmuTSnOp6cTQgJlaB6oF7RIITul8N+1sYjnL6aJqqoZ2inaxDIY2s2zwlXUs5zj7OPJmAPao+ZhVHy0A') format('woff2');
} }
@@ -11,7 +10,6 @@
font-family: 'intro-text'; font-family: 'intro-text';
font-style: normal; font-style: normal;
font-weight: 500; font-weight: 500;
font-display: block;
// Only contains the chars for "select an image" // Only contains the chars for "select an image"
src: url('data:font/woff2;base64,d09GMgABAAAAAAXMAA4AAAAACwQAAAV5AAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGhYbg1IcMAZgAGwRCAqJUId/CxoAATYCJAMwBCAFgnAHIBskCcjEh6dNff8Ou/9Tj9VZGnUhJeqFzWGiVVOkxkTthr9f6kWkRdsBbkIj3YuLaloFZWr7aBg22z7IOoWqBWCW5cZU3GBrh0n+dAcBYAUlzYHAzWTYchqKXEyAT0zOLIS1qqm+B8q2ur4OhEMy0PHHUH8KklaSr8T0mp/EU7kRvXlI1E09HXA1qLN8Djxa0AsSDOg3cJARb9mtQJoSK3gvEn372/gcAigg/gOnbsT/MYv491GTReW4rJC5LA+h5FFclF6QQgoZ5Kx7GbsuGeytUgFClkOomY2Gdake3m9HegkHieAx/a0hBTALsy4jvpxBcnFXUnjC+2ZS5zHnDeEaJVwi+ZWqzOm4Uvgy4k6kGv4kFDVkfjk1gkVRRkk2zlo42PBbRJmG30cClJQjak7BnfQqHza4ITKftQZ/ZMUaEiyy1+mYCh4clKhDA5rQglZ0oG9jw+qiNvT+SfxIXCeuFdeIq8VV4gpxyRaGOl0JiChiCocfc7e93DwZIPvWgPiZJLcJugxyjW7UQyl4TJk6dWqYU7Cn0WQiWnNJCdGeprqjW63fpVS3mKy4YGZ6I3ya4nbIVgM1mwkpNEBzixlNxfPmH7owvdE4973OM9quvk11dwvnzDUy/Zn5S5Ywpn/PeqXBQI2m4lna05CRtsI6+GIENjS9K4jWRHUGYA2ozdZm2Smmf0DI3aqpeNbsJfxe7YdFmcZAn5gXLCFa2/Umqiu017APFhMZ0rfQp4sJX0ZrJ+n9UtAljr5VYWb6oj1MrpvX3qe6u8WRJg0bj7aPkDOa7m+E0Oa9Y8eY/gbRbr+efH7hcO49bMd28fbDVHcUmm3XkozQGKjeBHSJ4TQnI879LIFmF2v/BJuEQlffJPfE9oKayS/PsPE44fvM4MsBESxbuEEV39d5pw6oW4vD6S1WQC3UpSbHNbK0Jikl0bphSs+0CGW8Ew4Kzw7zarmcVz873JHTFhKYay18R8vY0ozPiHPAGyROAqlW5fLj5+HbWBn9TpgekKsOy8N+4dlFfL9i/Nk3+gY1bwzZUAvLVNiFpvqHRenetSoVrgn2obGtPsltEVxEeHJAQFhyBIcHT9rDvTJm06e0TLgase2gd2RffGJNg0o1zrdRyi9s1bYE5bi85cK+o/nUwvBR5+jweEBaSMoCub29fEFISmib9Dn5yl5kVFpoGrPQSmZhafQ7WimttNCH7Cktohb6kFpoEfIsdDF7SgvZTbaY3mKFyLXQh+wpzWE3mUNQQkWnR+lgiW9Afkunej49Nz3sYlI8XFTRdkNhUR5d6h4oOpJc8OjcItMXVqoHW2fSW6ycWuiM8NDYoICouLAZ9BYrwwmhycvKlt4Q8hUlCV5nZ7vOm2ut2rizcFpuWpSrT3K1Z3xinbuHnXBTyGAljV/XzHZaNGu6y7vLDziMpIyUdOBBRXXlxznUQiOoheZsZk9njG8er1mNmz2eOCQ3x9BbLP+Zxt+VrbEz9aWxmimRvyl4/sumyoM/nw+LNzV4/uP0/9T/P5f08cvhl38USAS/xTYs2fL/VNFF0vd+SVsRB/khPwW4SCi5SHhx8fDgVPAiAqIJRQL/EuK5bsRzNnAiezD1i7u2VyHHAKRU+E2YUaA5DUsE2ZfbApAmsJcxjBwmQ2Xk4Y2BqZJ+oxSzsNEogz1O3/xkBMKEBHSiC8PoQStaoEIflPCHL/wQBCUKoUITlMhHP+olt5ojykUPOrEQTWgY+f449KMPKnSiB72jGrLQhEZO24925LtbkG5DndTkD2/4um+NQBEyUIJsRBxX24tcDGmbWtGJjq05jhzTaMgCcR+6EA4f+KAXDay5u6RsL7xJsm3w3mzvFvggB8nI/AYBdVnxNvx/2wA=') format('woff2'); src: url('data:font/woff2;base64,d09GMgABAAAAAAXMAA4AAAAACwQAAAV5AAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGhYbg1IcMAZgAGwRCAqJUId/CxoAATYCJAMwBCAFgnAHIBskCcjEh6dNff8Ou/9Tj9VZGnUhJeqFzWGiVVOkxkTthr9f6kWkRdsBbkIj3YuLaloFZWr7aBg22z7IOoWqBWCW5cZU3GBrh0n+dAcBYAUlzYHAzWTYchqKXEyAT0zOLIS1qqm+B8q2ur4OhEMy0PHHUH8KklaSr8T0mp/EU7kRvXlI1E09HXA1qLN8Djxa0AsSDOg3cJARb9mtQJoSK3gvEn372/gcAigg/gOnbsT/MYv491GTReW4rJC5LA+h5FFclF6QQgoZ5Kx7GbsuGeytUgFClkOomY2Gdake3m9HegkHieAx/a0hBTALsy4jvpxBcnFXUnjC+2ZS5zHnDeEaJVwi+ZWqzOm4Uvgy4k6kGv4kFDVkfjk1gkVRRkk2zlo42PBbRJmG30cClJQjak7BnfQqHza4ITKftQZ/ZMUaEiyy1+mYCh4clKhDA5rQglZ0oG9jw+qiNvT+SfxIXCeuFdeIq8VV4gpxyRaGOl0JiChiCocfc7e93DwZIPvWgPiZJLcJugxyjW7UQyl4TJk6dWqYU7Cn0WQiWnNJCdGeprqjW63fpVS3mKy4YGZ6I3ya4nbIVgM1mwkpNEBzixlNxfPmH7owvdE4973OM9quvk11dwvnzDUy/Zn5S5Ywpn/PeqXBQI2m4lna05CRtsI6+GIENjS9K4jWRHUGYA2ozdZm2Smmf0DI3aqpeNbsJfxe7YdFmcZAn5gXLCFa2/Umqiu017APFhMZ0rfQp4sJX0ZrJ+n9UtAljr5VYWb6oj1MrpvX3qe6u8WRJg0bj7aPkDOa7m+E0Oa9Y8eY/gbRbr+efH7hcO49bMd28fbDVHcUmm3XkozQGKjeBHSJ4TQnI879LIFmF2v/BJuEQlffJPfE9oKayS/PsPE44fvM4MsBESxbuEEV39d5pw6oW4vD6S1WQC3UpSbHNbK0Jikl0bphSs+0CGW8Ew4Kzw7zarmcVz873JHTFhKYay18R8vY0ozPiHPAGyROAqlW5fLj5+HbWBn9TpgekKsOy8N+4dlFfL9i/Nk3+gY1bwzZUAvLVNiFpvqHRenetSoVrgn2obGtPsltEVxEeHJAQFhyBIcHT9rDvTJm06e0TLgase2gd2RffGJNg0o1zrdRyi9s1bYE5bi85cK+o/nUwvBR5+jweEBaSMoCub29fEFISmib9Dn5yl5kVFpoGrPQSmZhafQ7WimttNCH7Cktohb6kFpoEfIsdDF7SgvZTbaY3mKFyLXQh+wpzWE3mUNQQkWnR+lgiW9Afkunej49Nz3sYlI8XFTRdkNhUR5d6h4oOpJc8OjcItMXVqoHW2fSW6ycWuiM8NDYoICouLAZ9BYrwwmhycvKlt4Q8hUlCV5nZ7vOm2ut2rizcFpuWpSrT3K1Z3xinbuHnXBTyGAljV/XzHZaNGu6y7vLDziMpIyUdOBBRXXlxznUQiOoheZsZk9njG8er1mNmz2eOCQ3x9BbLP+Zxt+VrbEz9aWxmimRvyl4/sumyoM/nw+LNzV4/uP0/9T/P5f08cvhl38USAS/xTYs2fL/VNFF0vd+SVsRB/khPwW4SCi5SHhx8fDgVPAiAqIJRQL/EuK5bsRzNnAiezD1i7u2VyHHAKRU+E2YUaA5DUsE2ZfbApAmsJcxjBwmQ2Xk4Y2BqZJ+oxSzsNEogz1O3/xkBMKEBHSiC8PoQStaoEIflPCHL/wQBCUKoUITlMhHP+olt5ojykUPOrEQTWgY+f449KMPKnSiB72jGrLQhEZO24925LtbkG5DndTkD2/4um+NQBEyUIJsRBxX24tcDGmbWtGJjq05jhzTaMgCcR+6EA4f+KAXDay5u6RsL7xJsm3w3mzvFvggB8nI/AYBdVnxNvx/2wA=') format('woff2');
} }
@@ -21,6 +19,7 @@
} }
.intro { .intro {
composes: abs-fill from '../../lib/util.scss';
display: grid; display: grid;
grid-template-rows: 1fr min-content; grid-template-rows: 1fr min-content;
align-items: center; align-items: center;
@@ -30,9 +29,6 @@
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
overflow: auto; overflow: auto;
padding: 20px 0 0; padding: 20px 0 0;
height: 100%;
box-sizing: border-box;
overscroll-behavior: contain;
} }
.logo-container { .logo-container {
@@ -42,17 +38,16 @@
.logo-sizer { .logo-sizer {
width: 90%; width: 90%;
max-width: 52vh; max-width: 480px;
margin: 0 auto; margin: 0 auto;
} }
.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 {
font: 300 11vw intro-text, sans-serif; font: 300 11vw intro-text;
margin-bottom: 0; margin-bottom: 0;
@media (min-width: 460px) { @media (min-width: 460px) {
@@ -145,7 +140,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

@@ -2,10 +2,10 @@ import { h, Component, ComponentChildren, ComponentChild } from 'preact';
import * as style from './style.scss'; import * as style from './style.scss';
import FileSize from './FileSize'; import FileSize from './FileSize';
import { DownloadIcon, CopyAcrossIcon, CopyAcrossIconProps } from '../../lib/icons'; import { DownloadIcon } from '../../lib/icons';
import '../custom-els/LoadingSpinner'; import '../custom-els/LoadingSpinner';
import { SourceImage } from '../compress'; import { SourceImage } from '../compress';
import { Fileish, bind } from '../../lib/initial-util'; import { Fileish } from '../../lib/initial-util';
interface Props { interface Props {
loading: boolean; loading: boolean;
@@ -13,21 +13,12 @@ interface Props {
imageFile?: Fileish; imageFile?: Fileish;
downloadUrl?: string; downloadUrl?: string;
children: ComponentChildren; children: ComponentChildren;
copyDirection: CopyAcrossIconProps['copyDirection'];
buttonPosition: keyof typeof buttonPositionClass;
onCopyToOtherClick(): void;
} }
interface State { interface State {
showLoadingState: boolean; showLoadingState: boolean;
} }
const buttonPositionClass = {
'stack-right': style.stackRight,
'download-right': style.downloadRight,
'download-left': style.downloadLeft,
};
const loadingReactionDelay = 500; const loadingReactionDelay = 500;
export default class Results extends Component<Props, State> { export default class Results extends Component<Props, State> {
@@ -52,34 +43,9 @@ export default class Results extends Component<Props, State> {
} }
} }
@bind render({ source, imageFile, downloadUrl, children }: Props, { showLoadingState }: State) {
private onCopyToOtherClick(event: Event) {
event.preventDefault();
this.props.onCopyToOtherClick();
}
@bind
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', {
metric1: before,
metric2: after,
metric3: change,
});
}
render(
{ source, imageFile, downloadUrl, children, copyDirection, buttonPosition }: Props,
{ showLoadingState }: State,
) {
return ( return (
<div class={`${style.results} ${buttonPositionClass[buttonPosition]}`}> <div class={style.results}>
<div class={style.resultData}> <div class={style.resultData}>
{(children as ComponentChild[])[0] {(children as ComponentChild[])[0]
? <div class={style.resultTitle}>{children}</div> ? <div class={style.resultTitle}>{children}</div>
@@ -93,14 +59,6 @@ export default class Results extends Component<Props, State> {
} }
</div> </div>
<button
class={style.copyToOther}
title="Copy settings to other side"
onClick={this.onCopyToOtherClick}
>
<CopyAcrossIcon class={style.copyIcon} copyDirection={copyDirection} />
</button>
<div class={style.download}> <div class={style.download}>
{(downloadUrl && imageFile) && ( {(downloadUrl && imageFile) && (
<a <a
@@ -108,7 +66,6 @@ export default class Results extends Component<Props, State> {
href={downloadUrl} href={downloadUrl}
download={imageFile.name} download={imageFile.name}
title="Download" title="Download"
onClick={this.onDownload}
> >
<DownloadIcon class={style.downloadIcon} /> <DownloadIcon class={style.downloadIcon} />
</a> </a>

View File

@@ -16,17 +16,9 @@
.results { .results {
display: grid; display: grid;
grid-template-columns: [text] 1fr [copy-button] auto [download-button] auto; grid-template-columns: 1fr auto;
background: rgba(0, 0, 0, 0.9); background: rgba(0, 0, 0, 0.9);
font-size: 1rem; font-size: 1.4rem;
@media (min-width: 400px) {
font-size: 1.2rem;
}
@media (min-width: 600px) {
font-size: 1.4rem;
}
&:focus { &:focus {
outline: none; outline: none;
@@ -34,29 +26,13 @@
} }
.result-data { .result-data {
grid-row: 1;
grid-column: text;
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0 10px; padding: 0 15px;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
} }
.download-right {
grid-template-columns: [copy-button] auto [text] 1fr [download-button] auto;
}
.download-left {
grid-template-columns: [download-button] auto [text] 1fr [copy-button] auto;
}
.stack-right {
& .result-data {
padding: 0 15px;
}
}
.result-title { .result-title {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -64,7 +40,7 @@
} }
.size-delta { .size-delta {
font-size: 0.8em; font-size: 1.1rem;
font-style: italic; font-style: italic;
position: relative; position: relative;
top: -1px; top: -1px;
@@ -80,8 +56,6 @@
} }
.download { .download {
grid-row: 1;
grid-column: download-button;
background: #34B9EB; background: #34B9EB;
--size: 38px; --size: 38px;
width: var(--size); width: var(--size);
@@ -103,8 +77,7 @@
animation: action-leave 0.2s; animation: action-leave 0.2s;
} }
.download-icon, .download-icon {
.copy-icon {
color: #fff; color: #fff;
display: block; display: block;
--size: 24px; --size: 24px;
@@ -120,12 +93,3 @@
--size: 22px; --size: 22px;
grid-area: 1/1; grid-area: 1/1;
} }
.copy-to-other {
grid-row: 1;
grid-column: copy-button;
composes: unbutton from '../../lib/util.scss';
composes: download;
background: #656565;
}

View File

@@ -1,6 +1,6 @@
import PointerTracker from 'pointer-tracker';
import { bind } from '../../lib/initial-util'; import { bind } from '../../lib/initial-util';
import * as style from './styles.css'; import * as style from './styles.css';
import { PointerTracker } from '../../lib/PointerTracker';
const RETARGETED_EVENTS = ['focus', 'blur']; const RETARGETED_EVENTS = ['focus', 'blur'];
const UPDATE_EVENTS = ['input', 'change']; const UPDATE_EVENTS = ['input', 'change'];
@@ -57,8 +57,6 @@ class RangeInputElement extends HTMLElement {
this.insertBefore(this._input, this.firstChild); this.insertBefore(this._input, this.firstChild);
this._valueDisplay = this.querySelector('.' + style.valueDisplay) as HTMLDivElement; this._valueDisplay = this.querySelector('.' + style.valueDisplay) as HTMLDivElement;
// Set inline styles (this is useful when used with frameworks which might clear inline styles)
this._update();
} }
get labelPrecision(): string { get labelPrecision(): string {

View File

@@ -4,13 +4,13 @@
<meta charset="utf-8"> <meta charset="utf-8">
<title>Squoosh</title> <title>Squoosh</title>
<meta name="description" content="Compress and compare images with different codecs, right in your browser"> <meta name="description" content="Compress and compare images with different codecs, right in your browser">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"> <meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<link rel="shortcut icon" href="/assets/favicon.ico"> <meta name="theme-color" content="#673ab8">
<meta name="theme-color" content="#f78f21">
<link rel="manifest" href="/manifest.json"> <link rel="manifest" href="/manifest.json">
</head> </head>
<body> <body>
<div id="app"></div>
</body> </body>
</html> </html>

View File

@@ -1,25 +1,9 @@
declare module '@webcomponents/custom-elements'; declare module '@webcomponents/custom-elements';
function init() { (async function () {
if (!('customElements' in self)) {
await import('@webcomponents/custom-elements');
}
require('./init-app.tsx'); require('./init-app.tsx');
} })();
if (!('customElements' in self)) {
import(
/* webpackChunkName: "wc-polyfill" */
'@webcomponents/custom-elements',
).then(init);
} else {
init();
}
if (typeof PRERENDER === 'undefined') {
window.ga = window.ga || ((...args) => (ga.q = ga.q || []).push(args));
ga('create', 'UA-128752250-1', 'auto');
ga('set', 'transport', 'beacon');
ga('send', 'pageview');
// Load the GA script
const s = document.createElement('script');
s.src = 'https://www.google-analytics.com/analytics.js';
document.head!.appendChild(s);
}

View File

@@ -4,13 +4,13 @@ import './style';
import App from './components/App'; import App from './components/App';
// Find the outermost Element in our server-rendered HTML structure. // Find the outermost Element in our server-rendered HTML structure.
let root = document.getElementById('app_root') as Element; let root = document.querySelector('#app') || undefined;
// "attach" the client-side rendering to it, updating the DOM in-place instead of replacing: // "attach" the client-side rendering to it, updating the DOM in-place instead of replacing:
root = render(<App />, document.body, root); root = render(<App />, document.body, root);
root.setAttribute('id', 'app_root');
if (process.env.NODE_ENV !== 'production') { // In production, this entire condition is removed.
if (process.env.NODE_ENV === 'development') {
// Enable support for React DevTools and some helpful console warnings: // Enable support for React DevTools and some helpful console warnings:
require('preact/debug'); require('preact/debug');

View File

@@ -0,0 +1,255 @@
import { bind } from '../../lib/initial-util';
const enum Button { Left }
export class Pointer {
/** x offset from the top of the document */
pageX: number;
/** y offset from the top of the document */
pageY: number;
/** x offset from the top of the viewport */
clientX: number;
/** y offset from the top of the viewport */
clientY: number;
/** ID for this pointer */
id: number = -1;
/** The platform object used to create this Pointer */
nativePointer: Touch | PointerEvent | MouseEvent;
constructor (nativePointer: Touch | PointerEvent | MouseEvent) {
this.nativePointer = nativePointer;
this.pageX = nativePointer.pageX;
this.pageY = nativePointer.pageY;
this.clientX = nativePointer.clientX;
this.clientY = nativePointer.clientY;
if (self.Touch && nativePointer instanceof Touch) {
this.id = nativePointer.identifier;
} else if (isPointerEvent(nativePointer)) { // is PointerEvent
this.id = nativePointer.pointerId;
}
}
/**
* Returns an expanded set of Pointers for high-resolution inputs.
*/
getCoalesced(): Pointer[] {
if ('getCoalescedEvents' in this.nativePointer) {
return this.nativePointer.getCoalescedEvents().map(p => new Pointer(p));
}
return [this];
}
}
const isPointerEvent = (event: any): event is PointerEvent =>
self.PointerEvent && event instanceof PointerEvent;
const noop = () => {};
export type InputEvent = TouchEvent | PointerEvent | MouseEvent;
type StartCallback = (pointer: Pointer, event: InputEvent) => boolean;
type MoveCallback = (
previousPointers: Pointer[],
changedPointers: Pointer[],
event: InputEvent,
) => void;
type EndCallback = (pointer: Pointer, event: InputEvent) => void;
interface PointerTrackerCallbacks {
/**
* Called when a pointer is pressed/touched within the element.
*
* @param pointer The new pointer.
* This pointer isn't included in this.currentPointers or this.startPointers yet.
* @param event The event related to this pointer.
*
* @returns Whether you want to track this pointer as it moves.
*/
start?: StartCallback;
/**
* Called when pointers have moved.
*
* @param previousPointers The state of the pointers before this event.
* This contains the same number of pointers, in the same order, as
* this.currentPointers and this.startPointers.
* @param changedPointers The pointers that have changed since the last move callback.
* @param event The event related to the pointer changes.
*/
move?: MoveCallback;
/**
* Called when a pointer is released.
*
* @param pointer The final state of the pointer that ended. This
* pointer is now absent from this.currentPointers and
* this.startPointers.
* @param event The event related to this pointer.
*/
end?: EndCallback;
}
/**
* Track pointers across a particular element
*/
export class PointerTracker {
/**
* State of the tracked pointers when they were pressed/touched.
*/
readonly startPointers: Pointer[] = [];
/**
* Latest state of the tracked pointers. Contains the same number
* of pointers, and in the same order as this.startPointers.
*/
readonly currentPointers: Pointer[] = [];
private _startCallback: StartCallback;
private _moveCallback: MoveCallback;
private _endCallback: EndCallback;
/**
* Track pointers across a particular element
*
* @param element Element to monitor.
* @param callbacks
*/
constructor (private _element: HTMLElement, callbacks: PointerTrackerCallbacks) {
const {
start = () => true,
move = noop,
end = noop,
} = callbacks;
this._startCallback = start;
this._moveCallback = move;
this._endCallback = end;
// Add listeners
if (self.PointerEvent) {
this._element.addEventListener('pointerdown', this._pointerStart);
} else {
this._element.addEventListener('mousedown', this._pointerStart);
this._element.addEventListener('touchstart', this._touchStart);
this._element.addEventListener('touchmove', this._move);
this._element.addEventListener('touchend', this._touchEnd);
}
}
/**
* Call the start callback for this pointer, and track it if the user wants.
*
* @param pointer Pointer
* @param event Related event
* @returns Whether the pointer is being tracked.
*/
private _triggerPointerStart (pointer: Pointer, event: InputEvent): boolean {
if (!this._startCallback(pointer, event)) return false;
this.currentPointers.push(pointer);
this.startPointers.push(pointer);
return true;
}
/**
* Listener for mouse/pointer starts. Bound to the class in the constructor.
*
* @param event This will only be a MouseEvent if the browser doesn't support
* pointer events.
*/
@bind
private _pointerStart (event: PointerEvent | MouseEvent) {
if (event.button !== Button.Left) return;
if (!this._triggerPointerStart(new Pointer(event), event)) return;
// Add listeners for additional events.
// The listeners may already exist, but no harm in adding them again.
if (isPointerEvent(event)) {
this._element.setPointerCapture(event.pointerId);
this._element.addEventListener('pointermove', this._move);
this._element.addEventListener('pointerup', this._pointerEnd);
} else { // MouseEvent
window.addEventListener('mousemove', this._move);
window.addEventListener('mouseup', this._pointerEnd);
}
}
/**
* Listener for touchstart. Bound to the class in the constructor.
* Only used if the browser doesn't support pointer events.
*/
@bind
private _touchStart (event: TouchEvent) {
for (const touch of Array.from(event.changedTouches)) {
this._triggerPointerStart(new Pointer(touch), event);
}
}
/**
* Listener for pointer/mouse/touch move events.
* Bound to the class in the constructor.
*/
@bind
private _move (event: PointerEvent | MouseEvent | TouchEvent) {
const previousPointers = this.currentPointers.slice();
const changedPointers = ('changedTouches' in event) ? // Shortcut for 'is touch event'.
Array.from(event.changedTouches).map(t => new Pointer(t)) :
[new Pointer(event)];
const trackedChangedPointers = [];
for (const pointer of changedPointers) {
const index = this.currentPointers.findIndex(p => p.id === pointer.id);
if (index === -1) continue; // Not a pointer we're tracking
trackedChangedPointers.push(pointer);
this.currentPointers[index] = pointer;
}
if (trackedChangedPointers.length === 0) return;
this._moveCallback(previousPointers, trackedChangedPointers, event);
}
/**
* Call the end callback for this pointer.
*
* @param pointer Pointer
* @param event Related event
*/
@bind
private _triggerPointerEnd (pointer: Pointer, event: InputEvent): boolean {
const index = this.currentPointers.findIndex(p => p.id === pointer.id);
// Not a pointer we're interested in?
if (index === -1) return false;
this.currentPointers.splice(index, 1);
this.startPointers.splice(index, 1);
this._endCallback(pointer, event);
return true;
}
/**
* Listener for mouse/pointer ends. Bound to the class in the constructor.
* @param event This will only be a MouseEvent if the browser doesn't support
* pointer events.
*/
@bind
private _pointerEnd (event: PointerEvent | MouseEvent) {
if (!this._triggerPointerEnd(new Pointer(event), event)) return;
if (isPointerEvent(event)) {
if (this.currentPointers.length) return;
this._element.removeEventListener('pointermove', this._move);
this._element.removeEventListener('pointerup', this._pointerEnd);
} else { // MouseEvent
window.removeEventListener('mousemove', this._move);
window.removeEventListener('mouseup', this._pointerEnd);
}
}
/**
* Listener for touchend. Bound to the class in the constructor.
* Only used if the browser doesn't support pointer events.
*/
@bind
private _touchEnd (event: TouchEvent) {
for (const touch of Array.from(event.changedTouches)) {
this._triggerPointerEnd(new Pointer(touch), event);
}
}
}

View File

@@ -0,0 +1,10 @@
// TypeScript, you make me sad.
// https://github.com/Microsoft/TypeScript/issues/18756
interface Window {
PointerEvent: typeof PointerEvent;
Touch: typeof Touch;
}
interface PointerEvent {
getCoalescedEvents(): PointerEvent[];
}

View File

@@ -1,93 +1,114 @@
import * as style from './styles.css'; import './styles.css';
const DEFAULT_TIMEOUT = 2750;
export interface SnackOptions { export interface SnackOptions {
message: string;
timeout?: number; timeout?: number;
actions?: string[]; actionText?: string;
actionHandler?: () => boolean | null;
} }
function createSnack(message: string, options: SnackOptions): [Element, Promise<string>] { export interface SnackShowResult {
const { action: boolean;
timeout = 0, }
actions = ['dismiss'],
} = options;
const el = document.createElement('div'); class Snack {
el.className = style.snackbar; private _onremove: ((result: SnackShowResult) => void)[] = [];
el.setAttribute('aria-live', 'assertive'); private _options: SnackOptions;
el.setAttribute('aria-atomic', 'true'); private _element: Element = document.createElement('div');
el.setAttribute('aria-hidden', 'false'); private _text: Element = document.createElement('div');
private _button: Element = document.createElement('button');
private _showing = false;
private _closeTimer?: number;
private _result: SnackShowResult = {
action: false,
};
const text = document.createElement('div'); constructor (options: SnackOptions, callback?: (result: SnackShowResult) => void) {
text.className = style.text; this._options = options;
text.textContent = message;
el.appendChild(text);
const result = new Promise<string>((resolve) => { this._element.className = 'snackbar';
let timeoutId: number; this._element.setAttribute('aria-live', 'assertive');
this._element.setAttribute('aria-atomic', 'true');
this._element.setAttribute('aria-hidden', 'true');
// Add action buttons this._text.className = 'snackbar--text';
for (const action of actions) { this._text.textContent = options.message;
const button = document.createElement('button'); this._element.appendChild(this._text);
button.className = style.button;
button.textContent = action; if (options.actionText) {
button.addEventListener('click', () => { this._button.className = 'snackbar--button';
clearTimeout(timeoutId); this._button.textContent = options.actionText;
resolve(action); this._button.addEventListener('click', () => {
if (this._showing) {
if (options.actionHandler && options.actionHandler() === false) return;
this._result.action = true;
}
this.hide();
}); });
el.appendChild(button); this._element.appendChild(this._button);
} }
// Add timeout if (callback) {
if (timeout) { this._onremove.push(callback);
timeoutId = self.setTimeout(
() => resolve(''),
timeout,
);
} }
}); }
return [el, result]; cancelTimer () {
} if (this._closeTimer != null) clearTimeout(this._closeTimer);
}
export default class SnackBarElement extends HTMLElement { show (parent: Element): Promise<SnackShowResult> {
private _snackbars: [string, SnackOptions, (action: Promise<string>) => void][] = []; if (this._showing) return Promise.resolve(this._result);
private _processingQueue = false; this._showing = true;
this.cancelTimer();
/** if (parent !== this._element.parentNode) {
* Show a snackbar. Returns a promise for the name of the action clicked, or an empty string if no parent.appendChild(this._element);
* action is clicked. }
*/ this._element.removeAttribute('aria-hidden');
showSnackbar(message: string, options: SnackOptions = {}): Promise<string> { this._closeTimer = setTimeout(this.hide.bind(this), this._options.timeout || DEFAULT_TIMEOUT);
return new Promise<string>((resolve) => { return new Promise((resolve) => {
this._snackbars.push([message, options, resolve]); this._onremove.push(resolve);
if (!this._processingQueue) this._processQueue();
}); });
} }
private async _processQueue() { hide () {
this._processingQueue = true; if (!this._showing) return;
this._showing = false;
this.cancelTimer();
this._element.addEventListener('animationend', this.remove.bind(this));
this._element.setAttribute('aria-hidden', 'true');
}
while (this._snackbars[0]) { remove () {
const [message, options, resolver] = this._snackbars[0]; this.cancelTimer();
const [el, result] = createSnack(message, options); const parent = this._element.parentNode;
// Pass the result back to the original showSnackbar call. if (parent) parent.removeChild(this._element);
resolver(result); this._onremove.forEach(f => f(this._result));
this.appendChild(el); this._onremove = [];
}
}
// Wait for the user to click an action, or for the snack to timeout. export default class SnackBarElement extends HTMLElement {
await result; private _snackbars: Snack[] = [];
private _processingStack = false;
// Transition the snack away. showSnackbar (options: SnackOptions): Promise<SnackShowResult> {
el.setAttribute('aria-hidden', 'true'); return new Promise((resolve) => {
await new Promise((resolve) => { const snack = new Snack(options, resolve);
el.addEventListener('animationend', () => resolve()); this._snackbars.push(snack);
}); this._processStack();
el.remove(); });
}
this._snackbars.shift(); private async _processStack () {
} if (this._processingStack === true || this._snackbars.length === 0) return;
this._processingStack = true;
this._processingQueue = false; await this._snackbars[0].show(this);
this._snackbars.shift();
this._processingStack = false;
this._processStack();
} }
} }

View File

@@ -22,6 +22,7 @@ snack-bar {
transform-origin: center; transform-origin: center;
color: #eee; color: #eee;
z-index: 100; z-index: 100;
pointer-events: none;
cursor: default; cursor: default;
will-change: transform; will-change: transform;
animation: snackbar-show 300ms ease forwards 1; animation: snackbar-show 300ms ease forwards 1;
@@ -52,13 +53,13 @@ snack-bar {
} }
} }
.text { .snackbar--text {
flex: 1 1 auto; flex: 1 1 auto;
padding: 16px; padding: 16px;
font-size: 100%; font-size: 100%;
} }
.button { .snackbar--button {
position: relative; position: relative;
flex: 0 1 auto; flex: 0 1 auto;
padding: 8px; padding: 8px;
@@ -74,15 +75,16 @@ snack-bar {
font-size: 100%; font-size: 100%;
text-transform: uppercase; text-transform: uppercase;
text-align: center; text-align: center;
pointer-events: all;
cursor: pointer; cursor: pointer;
overflow: hidden; overflow: hidden;
transition: background-color 200ms ease; transition: background-color 200ms ease;
outline: none; outline: none;
} }
.button:hover { .snackbar--button:hover {
background-color: rgba(0,0,0,0.15); background-color: rgba(0,0,0,0.15);
} }
.button:focus:before { .snackbar--button:focus:before {
content: ''; content: '';
position: absolute; position: absolute;
left: 50%; left: 50%;

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>
); );
@@ -59,34 +47,3 @@ export const ExpandIcon = (props: JSX.HTMLAttributes) => (
<path d="M16.6 8.6L12 13.2 7.4 8.6 6 10l6 6 6-6z"/> <path d="M16.6 8.6L12 13.2 7.4 8.6 6 10l6 6 6-6z"/>
</Icon> </Icon>
); );
export const BackIcon = (props: JSX.HTMLAttributes) => (
<Icon {...props}>
<path d="M20 11H7.8l5.6-5.6L12 4l-8 8 8 8 1.4-1.4L7.8 13H20v-2z"/>
</Icon>
);
const copyAcrossRotations = {
up: 90, right: 180, down: -90, left: 0,
};
export interface CopyAcrossIconProps extends JSX.HTMLAttributes {
copyDirection: keyof typeof copyAcrossRotations;
}
export const CopyAcrossIcon = (props: CopyAcrossIconProps) => {
const { copyDirection, ...otherProps } = props;
const id = 'point-' + copyDirection;
const rotation = copyAcrossRotations[copyDirection];
return (
<Icon {...otherProps}>
<defs>
<clipPath id={id}>
<path d="M-12-12v24h24v-24zM4.5 2h-4v3l-5-5 5-5v3h4z" transform={`translate(12 13) rotate(${rotation})`}/>
</clipPath>
</defs>
<path clip-path={`url(#${id})`} d="M19 3h-4.2c-.4-1.2-1.5-2-2.8-2s-2.4.8-2.8 2H5a2 2 0 0 0-2 2v14c0 1.1.9 2 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zm-7 0a1 1 0 0 1 0 2c-.6 0-1-.4-1-1s.4-1 1-1z"/>
</Icon>
);
};

View File

@@ -1,98 +0,0 @@
import { get, set } from 'idb-keyval';
// Just for TypeScript
import SnackBarElement from './SnackBar';
/** Tell the service worker to skip waiting */
async function skipWaiting() {
const reg = await navigator.serviceWorker.getRegistration();
if (!reg || !reg.waiting) return;
reg.waiting.postMessage('skip-waiting');
}
/** Find the service worker that's 'active' or closest to 'active' */
async function getMostActiveServiceWorker() {
const reg = await navigator.serviceWorker.getRegistration();
if (!reg) return null;
return reg.active || reg.waiting || reg.installing;
}
/** Wait for an installing worker */
async function installingWorker(reg: ServiceWorkerRegistration): Promise<ServiceWorker> {
if (reg.installing) return reg.installing;
return new Promise<ServiceWorker>((resolve) => {
reg.addEventListener(
'updatefound',
() => resolve(reg.installing!),
{ once: true },
);
});
}
/** Wait a service worker to become waiting */
async function updateReady(reg: ServiceWorkerRegistration): Promise<void> {
if (reg.waiting) return;
const installing = await installingWorker(reg);
return new Promise<void>((resolve) => {
installing.addEventListener('statechange', () => {
if (installing.state === 'installed') resolve();
});
});
}
/** Set up the service worker and monitor changes */
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') {
navigator.serviceWorker.register('../sw');
}
const hasController = !!navigator.serviceWorker.controller;
// Look for changes in the controller
navigator.serviceWorker.addEventListener('controllerchange', async () => {
// Is it the first install?
if (!hasController) {
showSnack('Ready to work offline', { timeout: 5000 });
return;
}
// Otherwise reload (the user will have agreed to this).
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();
// Service worker not registered yet.
if (!reg) return;
// Look for updates
await updateReady(reg);
// Ask the user if they want to update.
const result = await showSnack('Update available', {
actions: ['reload', 'dismiss'],
});
// Tell the waiting worker to activate, this will change the controller and cause a reload (see
// 'controllerchange')
if (result === 'reload') skipWaiting();
}
/**
* Tell the service worker the main app has loaded. If it's the first time the service worker has
* heard about this, cache the heavier assets like codecs.
*/
export async function mainAppLoaded() {
// If the user has already interacted, no need to tell the service worker anything.
const userInteracted = await get<boolean | undefined>('user-interacted');
if (userInteracted) return;
set('user-interacted', true);
const serviceWorker = await getMostActiveServiceWorker();
if (!serviceWorker) return; // Service worker not installing yet.
serviceWorker.postMessage('cache-all');
}

View File

@@ -57,32 +57,16 @@ export async function canvasEncode(data: ImageData, type: string, quality?: numb
return blob; return blob;
} }
async function decodeImage(url: string): Promise<HTMLImageElement> {
const img = new Image();
img.decoding = 'async';
img.src = url;
const loaded = new Promise((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(Error('Image loading error'));
});
if (img.decode) {
// Nice off-thread way supported in Safari/Chrome.
// Safari throws on decode if the source is SVG.
// https://bugs.webkit.org/show_bug.cgi?id=188347
await img.decode().catch(() => null);
}
// Always await loaded, as we may have bailed due to the Safari bug above.
await loaded;
return img;
}
/** /**
* Attempts to load the given URL as an image. * Attempts to load the given URL as an image.
*/ */
export function canDecodeImage(url: string): Promise<boolean> { export function canDecodeImage(data: string): Promise<boolean> {
return decodeImage(url).then(() => true, () => false); return new Promise((resolve) => {
const img = document.createElement('img');
img.src = data;
img.onload = _ => resolve(true);
img.onerror = _ => resolve(false);
});
} }
export function blobToArrayBuffer(blob: Blob): Promise<ArrayBuffer> { export function blobToArrayBuffer(blob: Blob): Promise<ArrayBuffer> {
@@ -124,7 +108,24 @@ export async function blobToImg(blob: Blob): Promise<HTMLImageElement> {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
try { try {
return await decodeImage(url); const img = new Image();
img.decoding = 'async';
img.src = url;
const loaded = new Promise((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(Error('Image loading error'));
});
if (img.decode) {
// Nice off-thread way supported in Safari/Chrome.
// Safari throws on decode if the source is SVG.
// https://bugs.webkit.org/show_bug.cgi?id=188347
await img.decode().catch(() => null);
}
// Always await loaded, as we may have bailed due to the Safari bug above.
await loaded;
return img;
} finally { } finally {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
@@ -297,10 +298,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,14 +3,14 @@
"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": "#673ab8",
"icons": [ "icons": [
{ {
"src": "/assets/icon-large.png", "src": "/assets/icon.png",
"type": "image/png", "type": "image/png",
"sizes": "1024x1024" "sizes": "512x512"
} }
] ]
} }

View File

@@ -27,15 +27,3 @@ declare module '*.wasm' {
const content: string; const content: string;
export default content; export default content;
} }
declare module 'url-loader!*' {
const value: string;
export default value;
}
declare var VERSION: string;
declare var ga: {
(...args: any[]): void;
q: any[];
};

View File

@@ -2,6 +2,7 @@
html, body { html, body {
height: 100%; height: 100%;
width: 100%;
padding: 0; padding: 0;
margin: 0; margin: 0;
font: 12px/1.3 system-ui, -apple-system, BlinkMacSystemFont, Roboto, Helvetica, sans-serif; font: 12px/1.3 system-ui, -apple-system, BlinkMacSystemFont, Roboto, Helvetica, sans-serif;

View File

@@ -1,69 +0,0 @@
import {
cacheOrNetworkAndCache, cleanupCache, cacheOrNetwork, cacheBasics, cacheAdditionalProcessors,
} from './util';
import { get } from 'idb-keyval';
// Give TypeScript the correct global.
declare var self: ServiceWorkerGlobalScope;
// This is populated by webpack.
declare var BUILD_ASSETS: string[];
const versionedCache = 'static-' + VERSION;
const dynamicCache = 'dynamic';
const expectedCaches = [versionedCache, dynamicCache];
self.addEventListener('install', (event) => {
event.waitUntil(async function () {
const promises = [];
promises.push(cacheBasics(versionedCache, BUILD_ASSETS));
// If the user has already interacted with the app, update the codecs too.
if (await get('user-interacted')) {
promises.push(cacheAdditionalProcessors(versionedCache, BUILD_ASSETS));
}
await Promise.all(promises);
}());
});
self.addEventListener('activate', (event) => {
self.clients.claim();
event.waitUntil(async function () {
// Remove old caches.
const promises = (await caches.keys()).map((cacheName) => {
if (!expectedCaches.includes(cacheName)) return caches.delete(cacheName);
});
await Promise.all<any>(promises);
}());
});
self.addEventListener('fetch', (event) => {
// We only care about GET.
if (event.request.method !== 'GET') return;
const url = new URL(event.request.url);
// Don't care about other-origin URLs
if (url.origin !== location.origin) return;
if (url.pathname.startsWith('/demo-') || url.pathname.startsWith('/wc-polyfill')) {
cacheOrNetworkAndCache(event, dynamicCache);
cleanupCache(event, dynamicCache, BUILD_ASSETS);
return;
}
cacheOrNetwork(event);
});
self.addEventListener('message', (event) => {
switch (event.data) {
case 'cache-all':
event.waitUntil(cacheAdditionalProcessors(versionedCache, BUILD_ASSETS));
break;
case 'skip-waiting':
self.skipWaiting();
break;
}
});

View File

@@ -1 +0,0 @@
import '../missing-types';

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,106 +0,0 @@
import webpDataUrl from 'url-loader!../codecs/tiny.webp';
export function cacheOrNetwork(event: FetchEvent): void {
event.respondWith(async function () {
const cachedResponse = await caches.match(event.request);
return cachedResponse || fetch(event.request);
}());
}
export function cacheOrNetworkAndCache(event: FetchEvent, cacheName: string): void {
event.respondWith(async function () {
const { request } = event;
// Return from cache if possible.
const cachedResponse = await caches.match(request);
if (cachedResponse) return cachedResponse;
// Else go to the network.
const response = await fetch(request);
const responseToCache = response.clone();
event.waitUntil(async function () {
// Cache what we fetched.
const cache = await caches.open(cacheName);
await cache.put(request, responseToCache);
}());
// Return the network response.
return response;
}());
}
export function cleanupCache(event: FetchEvent, cacheName: string, keepAssets: string[]) {
event.waitUntil(async function () {
const cache = await caches.open(cacheName);
// Clean old entries from the dynamic cache.
const requests = await cache.keys();
const promises = requests.map((cachedRequest) => {
// Get pathname without leading /
const assetPath = new URL(cachedRequest.url).pathname.slice(1);
// If it isn't one of our keepAssets, we don't need it anymore.
if (!keepAssets.includes(assetPath)) return cache.delete(cachedRequest);
});
await Promise.all<any>(promises);
}());
}
function getAssetsWithPrefix(assets: string[], prefixes: string[]) {
return assets.filter(
asset => prefixes.some(prefix => asset.startsWith(prefix)),
);
}
export async function cacheBasics(cacheName: string, buildAssets: string[]) {
const toCache = ['/', '/assets/favicon.ico'];
const prefixesToCache = [
// Main app JS & CSS:
'main-app.',
// Service worker handler:
'offliner.',
// Little icons for the demo images on the homescreen:
'icon-demo-',
// Site logo:
'logo.',
];
const prefixMatches = getAssetsWithPrefix(buildAssets, prefixesToCache);
toCache.push(...prefixMatches);
const cache = await caches.open(cacheName);
await cache.addAll(toCache);
}
export async function cacheAdditionalProcessors(cacheName: string, buildAssets: string[]) {
let toCache = [];
const prefixesToCache = [
// Worker which handles image processing:
'processor-worker.',
// processor-worker imports:
'process-',
];
const prefixMatches = getAssetsWithPrefix(buildAssets, prefixesToCache);
const wasm = buildAssets.filter(asset => asset.endsWith('.wasm'));
toCache.push(...prefixMatches, ...wasm);
const supportsWebP = await (async () => {
if (!self.createImageBitmap) return false;
const response = await fetch(webpDataUrl);
const blob = await response.blob();
return createImageBitmap(blob).then(() => true, () => false);
})();
// No point caching the WebP decoder if it's supported natively:
if (supportsWebP) {
toCache = toCache.filter(asset => !/webp[\-_]dec/.test(asset));
}
const cache = await caches.open(cacheName);
await cache.addAll(toCache);
}

View File

@@ -12,9 +12,5 @@
"jsxFactory": "h", "jsxFactory": "h",
"allowJs": false, "allowJs": false,
"baseUrl": "." "baseUrl": "."
}, }
"exclude": [ }
"src/sw/**/*",
"src/codecs/processor-worker/**/*"
]
}

View File

@@ -2,49 +2,39 @@ 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');
const HtmlPlugin = require('html-webpack-plugin'); const HtmlPlugin = require('html-webpack-plugin');
const ScriptExtHtmlPlugin = require('script-ext-html-webpack-plugin'); const ScriptExtHtmlPlugin = require('script-ext-html-webpack-plugin');
const ReplacePlugin = require('webpack-plugin-replace');
const CopyPlugin = require('copy-webpack-plugin'); const CopyPlugin = require('copy-webpack-plugin');
const WatchTimestampsPlugin = require('./config/watch-timestamps-plugin'); const WatchTimestampsPlugin = require('./config/watch-timestamps-plugin');
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
const WorkerPlugin = require('worker-plugin'); const WorkerPlugin = require('worker-plugin');
const AutoSWPlugin = require('./config/auto-sw-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));
} }
const VERSION = readJson('./package.json').version; module.exports = function (_, env) {
module.exports = async 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 = [
path.join(__dirname, 'src/components'), path.join(__dirname, 'src/components'),
path.join(__dirname, 'src/codecs'), path.join(__dirname, 'src/codecs'),
path.join(__dirname, 'src/custom-els'), path.join(__dirname, 'src/custom-els'),
path.join(__dirname, 'src/lib'),
]; ];
await addCssTypes(componentStyleDirs, { watch: !isProd });
return { return {
mode: isProd ? 'production' : 'development', mode: isProd ? 'production' : 'development',
entry: { entry: './src/index',
'first-interaction': './src/index'
},
devtool: isProd ? 'source-map' : 'inline-source-map', devtool: isProd ? 'source-map' : 'inline-source-map',
stats: 'minimal', stats: 'minimal',
output: { output: {
filename: isProd ? '[name].[chunkhash:5].js' : '[name].js', filename: isProd ? '[name].[chunkhash:5].js' : '[name].js',
chunkFilename: '[name].[chunkhash:5].js', chunkFilename: '[name].chunk.[chunkhash:5].js',
path: path.join(__dirname, 'build'), path: path.join(__dirname, 'build'),
publicPath: '/', publicPath: '/',
globalObject: 'self' globalObject: 'self'
@@ -112,10 +102,12 @@ 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: '[local]__[hash:base64:5]',
namedExport: true, namedExport: true,
camelCase: true, camelCase: true,
importLoaders: 1, importLoaders: 1,
@@ -145,6 +137,12 @@ module.exports = async function (_, env) {
exclude: nodeModules, exclude: nodeModules,
loader: 'ts-loader' loader: 'ts-loader'
}, },
{
test: /\.jsx?$/,
loader: 'babel-loader',
// Don't respect any Babel RC files found on the filesystem:
options: Object.assign(readJson('.babelrc'), { babelrc: false })
},
{ {
// All the codec files define a global with the same name as their file name. `exports-loader` attaches those to `module.exports`. // All the codec files define a global with the same name as their file name. `exports-loader` attaches those to `module.exports`.
test: /\/codecs\/.*\.js$/, test: /\/codecs\/.*\.js$/,
@@ -155,26 +153,16 @@ module.exports = async function (_, env) {
// This is needed to make webpack NOT process wasm files. // This is needed to make webpack NOT process wasm files.
// See https://github.com/webpack/webpack/issues/6725 // See https://github.com/webpack/webpack/issues/6725
type: 'javascript/auto', type: 'javascript/auto',
loader: 'file-loader', loader: 'file-loader'
options: {
name: '[name].[hash:5].[ext]',
},
}, },
{ {
test: /\.(png|svg|jpg|gif)$/, test: /\.(png|svg|jpg|gif)$/,
loader: 'file-loader', loader: 'file-loader'
options: {
name: '[name].[hash:5].[ext]',
},
} }
] ]
}, },
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',
@@ -206,16 +194,13 @@ module.exports = async function (_, env) {
// See also: https://twitter.com/wsokra/status/970253245733113856 // See also: https://twitter.com/wsokra/status/970253245733113856
isProd && new MiniCssExtractPlugin({ isProd && new MiniCssExtractPlugin({
filename: '[name].[contenthash:5].css', filename: '[name].[contenthash:5].css',
chunkFilename: '[name].[contenthash:5].css' chunkFilename: '[name].chunk.[contenthash:5].css'
}), }),
new OptimizeCssAssetsPlugin({ new OptimizeCssAssetsPlugin({
cssProcessorOptions: { cssProcessorOptions: {
postcssReduceIdents: { zindex: false,
counterStyle: false, discardComments: { removeAll: true }
gridTemplate: false,
keyframes: false
}
} }
}), }),
@@ -231,7 +216,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: 'src/index.html',
minify: isProd && { minify: isProd && {
collapseWhitespace: true, collapseWhitespace: true,
removeScriptTypeAttributes: true, removeScriptTypeAttributes: true,
@@ -240,29 +225,32 @@ module.exports = async function (_, env) {
removeComments: true removeComments: true
}, },
manifest: readJson('./src/manifest.json'), manifest: readJson('./src/manifest.json'),
inject: 'body', inject: true,
compile: true compile: true
}), }),
new AutoSWPlugin({ version: VERSION }),
isProd && new AssetTemplatePlugin({
template: path.join(__dirname, '_headers.ejs'),
filename: '_headers',
}),
new ScriptExtHtmlPlugin({ new ScriptExtHtmlPlugin({
inline: ['first'] defaultAttribute: 'async'
}), }),
// Inline constants during build, so they can be folded by UglifyJS. // Inline constants during build, so they can be folded by UglifyJS.
new webpack.DefinePlugin({ new webpack.DefinePlugin({
VERSION: JSON.stringify(VERSION),
// We set node.process=false later in this config. // We set node.process=false later in this config.
// Here we make sure if (process && process.foo) still works: // Here we make sure if (process && process.foo) still works:
process: '{}' process: '{}'
}), }),
// Babel embeds helpful error messages into transpiled classes that we don't need in production.
// Here we replace the constructor and message with a static throw, leaving the message to be DCE'd.
// This is useful since it shows the message in SourceMapped code when debugging.
isProd && new ReplacePlugin({
include: /babel-helper$/,
patterns: [{
regex: /throw\s+(?:new\s+)?((?:Type|Reference)?Error)\s*\(/g,
value: (s, type) => `throw 'babel error'; (`
}]
}),
// Copying files via Webpack allows them to be served dynamically by `webpack serve` // Copying files via Webpack allows them to be served dynamically by `webpack serve`
new CopyPlugin([ new CopyPlugin([
{ from: 'src/manifest.json', to: 'manifest.json' }, { from: 'src/manifest.json', to: 'manifest.json' },
@@ -274,31 +262,17 @@ module.exports = async function (_, env) {
analyzerMode: 'static', analyzerMode: 'static',
defaultSizes: 'gzip', defaultSizes: 'gzip',
openAnalyzer: false openAnalyzer: false
}),
// Inline Critical CSS (for the intro screen, essentially)
isProd && new CrittersPlugin({
// use <link rel="stylesheet" media="not x" onload="this.media='all'"> hack to load async css:
preload: 'media',
// inline all styles from any stylesheet below this size:
inlineThreshold: 2000,
// don't bother lazy-loading non-critical stylesheets below this size, just inline the non-critical styles too:
minimumExternalSize: 4000,
// don't emit <noscript> external stylesheet links since the app fundamentally requires JS anyway:
noscriptFallback: false,
// inline the tiny data URL fonts we have for the intro screen:
inlineFonts: true,
// (and don't lazy load them):
preloadFonts: false
}) })
].filter(Boolean), // Filter out any falsey plugin array entries. ].filter(Boolean), // Filter out any falsey plugin array entries.
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
}, },