Compare commits

..

8 Commits

Author SHA1 Message Date
Jake Archibald
3aaf4a279c Fixing edge bug 2018-10-28 09:26:13 +00:00
Jake Archibald
f42427cf13 two-up closer design match & smaller line 2018-10-28 09:26:13 +00:00
Jake Archibald
a27f76397a Fixing 'container' scaleTo 2018-10-28 09:26:12 +00:00
Jake Archibald
3ba25554d7 Fixing zoom input in Firefox 2018-10-28 09:26:12 +00:00
Jake Archibald
d216fd4b0b Flattening CSS 2018-10-28 09:26:12 +00:00
Jake Archibald
51ee851811 We don't need the invalid state, as we'll accept all types. 2018-10-28 09:26:12 +00:00
Jake Archibald
3cf5221114 OCD 2018-10-28 09:26:12 +00:00
Jake Archibald
64da19e2fc Class for file drop 2018-10-28 09:26:12 +00:00
90 changed files with 1876 additions and 5169 deletions

View File

@@ -1,32 +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'll get an error on first build because of [a stupid bug we haven't fixed
yet](https://github.com/GoogleChromeLabs/squoosh/issues/251).
You can run the development server with:
```sh
npm start
```

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

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

4
global.d.ts vendored
View File

@@ -1,5 +1,4 @@
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;
@@ -12,9 +11,6 @@ declare interface Window {
declare namespace JSX { declare namespace JSX {
interface Element { } interface Element { }
interface IntrinsicElements { } interface IntrinsicElements { }
interface HTMLAttributes {
decoding?: string;
}
} }
declare module 'classnames' { declare module 'classnames' {

2575
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{ {
"private": true, "private": true,
"name": "squoosh", "name": "squoosh",
"version": "1.0.2", "version": "0.0.0",
"license": "apache-2.0", "license": "apache-2.0",
"scripts": { "scripts": {
"start": "webpack serve --host 0.0.0.0 --hot", "start": "webpack serve --host 0.0.0.0 --hot",
@@ -15,11 +15,10 @@
} }
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^9.6.35", "@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",
"assets-webpack-plugin": "^3.9.7",
"babel-loader": "^7.1.5", "babel-loader": "^7.1.5",
"babel-plugin-jsx-pragmatic": "^1.0.2", "babel-plugin-jsx-pragmatic": "^1.0.2",
"babel-plugin-syntax-dynamic-import": "^6.18.0", "babel-plugin-syntax-dynamic-import": "^6.18.0",
@@ -28,33 +27,21 @@
"babel-plugin-transform-object-rest-spread": "^6.26.0", "babel-plugin-transform-object-rest-spread": "^6.26.0",
"babel-plugin-transform-react-constant-elements": "^6.23.0", "babel-plugin-transform-react-constant-elements": "^6.23.0",
"babel-plugin-transform-react-jsx": "^6.24.1", "babel-plugin-transform-react-jsx": "^6.24.1",
"babel-plugin-transform-react-remove-prop-types": "^0.4.19", "babel-plugin-transform-react-remove-prop-types": "^0.4.14",
"babel-preset-env": "^1.7.0", "babel-preset-env": "^1.7.0",
"babel-register": "^6.26.0", "babel-register": "^6.26.0",
"classnames": "^2.2.6",
"clean-webpack-plugin": "^0.1.19", "clean-webpack-plugin": "^0.1.19",
"comlink": "^3.0.3", "copy-webpack-plugin": "^4.5.2",
"copy-webpack-plugin": "^4.5.3",
"critters-webpack-plugin": "^2.0.1",
"css-loader": "^0.28.11", "css-loader": "^0.28.11",
"ejs": "^2.6.1",
"exports-loader": "^0.7.0", "exports-loader": "^0.7.0",
"file-drop-element": "^0.0.9",
"file-loader": "^1.1.11", "file-loader": "^1.1.11",
"html-webpack-plugin": "^3.2.0", "html-webpack-plugin": "^3.2.0",
"husky": "^1.1.2", "husky": "^1.0.0-rc.13",
"idb-keyval": "^3.1.0",
"if-env": "^1.0.4", "if-env": "^1.0.4",
"linkstate": "^1.1.1",
"loader-utils": "^1.1.0", "loader-utils": "^1.1.0",
"pointer-tracker": "^2.0.3", "mini-css-extract-plugin": "^0.3.0",
"minimatch": "^3.0.4", "node-sass": "^4.9.3",
"mini-css-extract-plugin": "^0.4.4",
"node-sass": "^4.9.4",
"optimize-css-assets-webpack-plugin": "^4.0.3", "optimize-css-assets-webpack-plugin": "^4.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",
@@ -68,11 +55,16 @@
"tslint-react": "^3.6.0", "tslint-react": "^3.6.0",
"typescript": "^2.9.2", "typescript": "^2.9.2",
"typings-for-css-modules-loader": "^1.7.0", "typings-for-css-modules-loader": "^1.7.0",
"url-loader": "^1.1.2",
"webpack": "^4.19.1", "webpack": "^4.19.1",
"webpack-bundle-analyzer": "^2.13.1", "webpack-bundle-analyzer": "^2.13.1",
"webpack-cli": "^2.1.5", "webpack-cli": "^2.1.5",
"webpack-dev-server": "^3.1.5", "webpack-dev-server": "^3.1.5",
"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

@@ -5,4 +5,4 @@ export const type = 'browser-jpeg';
export const label = 'Browser JPEG'; export const label = 'Browser JPEG';
export const mimeType = 'image/jpeg'; export const mimeType = 'image/jpeg';
export const extension = 'jpg'; export const extension = 'jpg';
export const defaultOptions: EncodeOptions = { quality: 0.75 }; export const defaultOptions: EncodeOptions = { quality: 0.5 };

View File

@@ -1,3 +1,3 @@
import qualityOption from '../generic/quality-option'; import qualityOption from '../generic/quality-option';
export default qualityOption({ min: 0, max: 1, step: 0.01 }); export default qualityOption({ min: 0, max: 1, step: 0 });

View File

@@ -7,5 +7,5 @@ export const type = 'browser-webp';
export const label = 'Browser WebP'; export const label = 'Browser WebP';
export const mimeType = 'image/webp'; export const mimeType = 'image/webp';
export const extension = 'webp'; export const extension = 'webp';
export const defaultOptions: EncodeOptions = { quality: 0.75 }; export const defaultOptions: EncodeOptions = { quality: 0.5 };
export const featureTest = () => canvasEncodeTest(mimeType); export const featureTest = () => canvasEncodeTest(mimeType);

View File

@@ -1,3 +1,3 @@
import qualityOption from '../generic/quality-option'; import qualityOption from '../generic/quality-option';
export default qualityOption({ min: 0, max: 1, step: 0.01 }); export default qualityOption({ min: 0, max: 1, step: 0 });

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,7 +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 * as style from '../../components/Options/style.scss'; import '../../custom-els/RangeInput';
import Range from '../../components/range';
interface EncodeOptions { interface EncodeOptions {
quality: number; quality: number;
@@ -34,19 +33,18 @@ export default function qualityOption(opts: QualityOptionArg = {}) {
render({ options }: Props) { render({ options }: Props) {
return ( return (
<div class={style.optionsSection}> <div>
<div class={style.optionOneCell}> <label>
<Range Quality:
<range-input
name="quality" name="quality"
min={min} min={min}
max={max} max={max}
step={step || 'any'} step={step || 'any'}
value={options.quality} value={'' + options.quality}
onInput={this.onChange} onChange={this.onChange}
> />
Quality: </label>
</Range>
</div>
</div> </div>
); );
} }

View File

@@ -2,10 +2,6 @@ import { h, Component } from 'preact';
import { bind } from '../../lib/initial-util'; import { bind } from '../../lib/initial-util';
import { inputFieldValueAsNumber, konami } 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 Expander from '../../components/expander';
import Select from '../../components/select';
import Range from '../../components/range';
const konamiPromise = konami(); const konamiPromise = konami();
@@ -30,61 +26,50 @@ export default class QuantizerOptions extends Component<Props, State> {
@bind @bind
onChange(event: Event) { onChange(event: Event) {
const form = (event.currentTarget as HTMLInputElement).closest('form') as HTMLFormElement; const form = (event.currentTarget as HTMLInputElement).closest('form') as HTMLFormElement;
const { options } = this.props;
const newOptions: QuantizeOptions = { const options: QuantizeOptions = {
zx: inputFieldValueAsNumber(form.zx, options.zx), zx: inputFieldValueAsNumber(form.zx),
maxNumColors: inputFieldValueAsNumber(form.maxNumColors, options.maxNumColors), maxNumColors: inputFieldValueAsNumber(form.maxNumColors),
dither: inputFieldValueAsNumber(form.dither), dither: inputFieldValueAsNumber(form.dither),
}; };
this.props.onChange(newOptions); this.props.onChange(options);
} }
render({ options }: Props, { extendedSettings }: State) { render({ options }: Props, { extendedSettings }: State) {
return ( return (
<form class={style.optionsSection}> <form>
<Expander> <label style={{ display: extendedSettings ? '' : 'none' }}>
{extendedSettings ?
<label class={style.optionTextFirst}>
Type: Type:
<Select <select
name="zx" name="zx"
value={'' + options.zx} value={'' + options.zx}
onChange={this.onChange} onChange={this.onChange}
> >
<option value="0">Standard</option> <option value="0">Standard</option>
<option value="1">ZX</option> <option value="1">ZX</option>
</Select> </select>
</label> </label>
: null} <label style={{ display: options.zx ? 'none' : '' }}>
</Expander> Palette Colors:
<Expander> <range-input
{options.zx ? null :
<div class={style.optionOneCell}>
<Range
name="maxNumColors" name="maxNumColors"
min="2" min="2"
max="256" max="256"
value={options.maxNumColors} value={'' + options.maxNumColors}
onInput={this.onChange} onChange={this.onChange}
> />
Colors: </label>
</Range> <label>
</div> Dithering:
} <range-input
</Expander>
<div class={style.optionOneCell}>
<Range
name="dither" name="dither"
min="0" min="0"
max="1" max="1"
step="0.01" step="0.01"
value={options.dither} value={'' + options.dither}
onInput={this.onChange} onChange={this.onChange}
> />
Dithering: </label>
</Range>
</div>
</form> </form>
); );
} }

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

@@ -2,199 +2,108 @@ import { h, Component } from 'preact';
import { bind } from '../../lib/initial-util'; import { bind } from '../../lib/initial-util';
import { inputFieldChecked, inputFieldValueAsNumber } 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 '../../custom-els/RangeInput';
import Checkbox from '../../components/checkbox';
import Expander from '../../components/expander';
import Select from '../../components/select';
import Range from '../../components/range';
import linkState from 'linkstate';
interface Props { type Props = {
options: EncodeOptions; options: EncodeOptions,
onChange(newOptions: EncodeOptions): void; onChange(newOptions: EncodeOptions): void,
}
interface State {
showAdvanced: boolean;
}
export default class MozJPEGEncoderOptions extends Component<Props, State> {
state: State = {
showAdvanced: false,
}; };
export default class MozJPEGEncoderOptions extends Component<Props, {}> {
@bind @bind
onChange(event: Event) { onChange(event: Event) {
const form = (event.currentTarget as HTMLInputElement).closest('form') as HTMLFormElement; const form = (event.currentTarget as HTMLInputElement).closest('form') as HTMLFormElement;
const { options } = this.props;
const newOptions: EncodeOptions = { const options: EncodeOptions = {
// Copy over options the form doesn't currently care about, eg arithmetic // Copy over options the form doesn't currently care about, eg arithmetic
...this.props.options, ...this.props.options,
// And now stuff from the form: // And now stuff from the form:
// .checked // .checked
baseline: inputFieldChecked(form.baseline, options.baseline), baseline: inputFieldChecked(form.baseline),
progressive: inputFieldChecked(form.progressive, options.progressive), progressive: inputFieldChecked(form.progressive),
optimize_coding: inputFieldChecked(form.optimize_coding, options.optimize_coding), optimize_coding: inputFieldChecked(form.optimize_coding),
trellis_multipass: inputFieldChecked(form.trellis_multipass, options.trellis_multipass), trellis_multipass: inputFieldChecked(form.trellis_multipass),
trellis_opt_zero: inputFieldChecked(form.trellis_opt_zero, options.trellis_opt_zero), trellis_opt_zero: inputFieldChecked(form.trellis_opt_zero),
trellis_opt_table: inputFieldChecked(form.trellis_opt_table, options.trellis_opt_table), trellis_opt_table: inputFieldChecked(form.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),
chroma_quality: inputFieldValueAsNumber(form.chroma_quality, options.chroma_quality), smoothing: inputFieldValueAsNumber(form.smoothing),
chroma_subsample: inputFieldValueAsNumber(form.chroma_subsample, options.chroma_subsample), color_space: inputFieldValueAsNumber(form.color_space),
smoothing: inputFieldValueAsNumber(form.smoothing, options.smoothing), quant_table: inputFieldValueAsNumber(form.quant_table),
color_space: inputFieldValueAsNumber(form.color_space, options.color_space), trellis_loops: inputFieldValueAsNumber(form.trellis_loops),
quant_table: inputFieldValueAsNumber(form.quant_table, options.quant_table),
trellis_loops: inputFieldValueAsNumber(form.trellis_loops, options.trellis_loops),
}; };
this.props.onChange(newOptions); this.props.onChange(options);
} }
render({ options }: Props, { showAdvanced }: State) { render({ options }: Props) {
// 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}> <form>
<div class={style.optionOneCell}> <label>
<Range Quality:
<range-input
name="quality" name="quality"
min="0" min="0"
max="100" max="100"
value={options.quality} value={'' + options.quality}
onInput={this.onChange}
>
Quality:
</Range>
</div>
<label class={style.optionInputFirst}>
<Checkbox
checked={showAdvanced}
onChange={linkState(this, 'showAdvanced')}
/>
Show advanced settings
</label>
<Expander>
{showAdvanced ?
<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} onChange={this.onChange}
/> />
Auto subsample chroma
</label> </label>
<Expander> <label>
{options.auto_subsample ? null : <input
<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}>
<Checkbox
name="baseline" name="baseline"
type="checkbox"
checked={options.baseline} checked={options.baseline}
onChange={this.onChange} onChange={this.onChange}
/> />
Pointless spec compliance <span>Baseline (worse but legacy-compatible)</span>
</label> </label>
<Expander> <label style={{ display: options.baseline ? 'none' : '' }}>
{options.baseline ? null : <input
<label class={style.optionInputFirst}>
<Checkbox
name="progressive" name="progressive"
type="checkbox"
checked={options.progressive} checked={options.progressive}
onChange={this.onChange} onChange={this.onChange}
/> />
Progressive rendering <span>Progressive multi-pass rendering</span>
</label> </label>
} <label style={{ display: options.baseline ? '' : 'none' }}>
</Expander> <input
<Expander>
{options.baseline ?
<label class={style.optionInputFirst}>
<Checkbox
name="optimize_coding" name="optimize_coding"
type="checkbox"
checked={options.optimize_coding} checked={options.optimize_coding}
onChange={this.onChange} onChange={this.onChange}
/> />
Optimize Huffman table <span>Optimize Huffman table</span>
</label> </label>
: null <label>
} Smoothing:
</Expander> <range-input
<div class={style.optionOneCell}>
<Range
name="smoothing" name="smoothing"
min="0" min="0"
max="100" max="100"
value={options.smoothing} value={'' + options.smoothing}
onInput={this.onChange} onChange={this.onChange}
/>
</label>
<label>
Output color space:
<select
name="color_space"
value={'' + options.color_space}
onChange={this.onChange}
> >
Smoothing: <option value={MozJpegColorSpace.GRAYSCALE}>Grayscale</option>
</Range> <option value={MozJpegColorSpace.RGB}>RGB (sub-optimal)</option>
</div> <option value={MozJpegColorSpace.YCbCr}>YCbCr (optimized for color)</option>
<label class={style.optionTextFirst}> </select>
Quantization: </label>
<Select <label>
Quantization table:
<select
name="quant_table" name="quant_table"
value={options.quant_table} value={'' + options.quant_table}
onChange={this.onChange} onChange={this.onChange}
> >
<option value="0">JPEG Annex K</option> <option value="0">JPEG Annex K</option>
@@ -206,52 +115,45 @@ export default class MozJPEGEncoderOptions extends Component<Props, State> {
<option value="6">Watson et al</option> <option value="6">Watson et al</option>
<option value="7">Ahumada et al</option> <option value="7">Ahumada et al</option>
<option value="8">Peterson et al</option> <option value="8">Peterson et al</option>
</Select> </select>
</label> </label>
<label class={style.optionInputFirst}> <label>
<Checkbox <input
name="trellis_multipass" name="trellis_multipass"
type="checkbox"
checked={options.trellis_multipass} checked={options.trellis_multipass}
onChange={this.onChange} onChange={this.onChange}
/> />
Trellis multipass <span>Consider multiple scans during trellis quantization</span>
</label> </label>
<Expander> <label style={{ display: options.trellis_multipass ? '' : 'none' }}>
{options.trellis_multipass ? <input
<label class={style.optionInputFirst}>
<Checkbox
name="trellis_opt_zero" name="trellis_opt_zero"
type="checkbox"
checked={options.trellis_opt_zero} checked={options.trellis_opt_zero}
onChange={this.onChange} onChange={this.onChange}
/> />
Optimize zero block runs <span>Optimize runs of zero blocks</span>
</label> </label>
: null <label>
} <input
</Expander>
<label class={style.optionInputFirst}>
<Checkbox
name="trellis_opt_table" name="trellis_opt_table"
type="checkbox"
checked={options.trellis_opt_table} checked={options.trellis_opt_table}
onChange={this.onChange} onChange={this.onChange}
/> />
Optimize after trellis quantization <span>Optimize after trellis quantization</span>
</label> </label>
<div class={style.optionOneCell}> <label>
<Range Trellis quantization passes:
<range-input
name="trellis_loops" name="trellis_loops"
min="1" min="1"
max="50" max="50"
value={options.trellis_loops} value={'' + options.trellis_loops}
onInput={this.onChange} onChange={this.onChange}
> />
Trellis quantization passes: </label>
</Range>
</div>
</div>
: null
}
</Expander>
</form> </form>
); );
} }

View File

@@ -2,8 +2,6 @@ import { h, Component } from 'preact';
import { bind } from '../../lib/initial-util'; import { bind } from '../../lib/initial-util';
import { inputFieldValueAsNumber } from '../../lib/util'; import { inputFieldValueAsNumber } from '../../lib/util';
import { EncodeOptions } from './encoder-meta'; import { EncodeOptions } from './encoder-meta';
import Range from '../../components/range';
import * as style from '../../components/Options/style.scss';
type Props = { type Props = {
options: EncodeOptions; options: EncodeOptions;
@@ -23,19 +21,19 @@ export default class OptiPNGEncoderOptions extends Component<Props, {}> {
render({ options }: Props) { render({ options }: Props) {
return ( return (
<form class={style.optionsSection}> <form>
<div class={style.optionOneCell}> <label>
<Range Effort:
<input
name="level" name="level"
type="range"
min="0" min="0"
max="7" max="7"
step="1" step="1"
value={options.level} value={'' + options.level}
onInput={this.onChange} onChange={this.onChange}
> />
Effort: </label>
</Range>
</div>
</form> </form>
); );
} }

View File

@@ -7,46 +7,31 @@ import { EncodeOptions as WebPEncoderOptions } from './webp/encoder-meta';
async function mozjpegEncode( async function mozjpegEncode(
data: ImageData, options: MozJPEGEncoderOptions, data: ImageData, options: MozJPEGEncoderOptions,
): Promise<ArrayBuffer> { ): Promise<ArrayBuffer> {
const { encode } = await import( const { encode } = await import('./mozjpeg/encoder');
/* webpackChunkName: "process-mozjpeg-enc" */
'./mozjpeg/encoder',
);
return encode(data, options); return encode(data, options);
} }
async function quantize(data: ImageData, opts: QuantizeOptions): Promise<ImageData> { async function quantize(data: ImageData, opts: QuantizeOptions): Promise<ImageData> {
const { process } = await import( const { process } = await import('./imagequant/processor');
/* webpackChunkName: "process-imagequant" */
'./imagequant/processor',
);
return process(data, opts); return process(data, opts);
} }
async function optiPngEncode( async function optiPngEncode(
data: BufferSource, options: OptiPNGEncoderOptions, data: BufferSource, options: OptiPNGEncoderOptions,
): Promise<ArrayBuffer> { ): Promise<ArrayBuffer> {
const { compress } = await import( const { compress } = await import('./optipng/encoder');
/* webpackChunkName: "process-optipng" */
'./optipng/encoder',
);
return compress(data, options); return compress(data, options);
} }
async function webpEncode( async function webpEncode(
data: ImageData, options: WebPEncoderOptions, data: ImageData, options: WebPEncoderOptions,
): Promise<ArrayBuffer> { ): Promise<ArrayBuffer> {
const { encode } = await import( const { encode } = await import('./webp/encoder');
/* webpackChunkName: "process-webp-enc" */
'./webp/encoder',
);
return encode(data, options); return encode(data, options);
} }
async function webpDecode(data: ArrayBuffer): Promise<ImageData> { async function webpDecode(data: ArrayBuffer): Promise<ImageData> {
const { decode } = await import( const { decode } = await import('./webp/decoder');
/* webpackChunkName: "process-webp-dec" */
'./webp/decoder',
);
return decode(data); return decode(data);
} }

View File

@@ -61,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.ts',
{ 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;
} }
@@ -95,6 +92,7 @@ export default class Processor {
// If the worker is unused for 10 seconds, remove it to save memory. // If the worker is unused for 10 seconds, remove it to save memory.
this._workerTimeoutId = self.setTimeout( this._workerTimeoutId = self.setTimeout(
() => { () => {
if (this._busy) throw Error("Worker shouldn't be busy");
if (!this._worker) return; if (!this._worker) return;
this._worker.terminate(); this._worker.terminate();
this._worker = undefined; this._worker = undefined;
@@ -106,14 +104,14 @@ export default class Processor {
/** Abort the current job, if any */ /** Abort the current job, if any */
abortCurrent() { abortCurrent() {
if (!this._busy) return; if (!this._busy) return;
if (!this._abortRejector) throw Error("There must be a rejector if it's busy"); if (!this._worker || !this._abortRejector) {
throw Error("There must be a worker/rejector if it's busy");
}
this._abortRejector(new DOMException('Aborted', 'AbortError')); this._abortRejector(new DOMException('Aborted', 'AbortError'));
this._abortRejector = undefined;
this._busy = false;
if (!this._worker) return;
this._worker.terminate(); this._worker.terminate();
this._worker = undefined; this._worker = undefined;
this._abortRejector = undefined;
this._busy = false;
} }
// Off main thread jobs: // Off main thread jobs:

View File

@@ -1,12 +1,8 @@
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 } from '../../lib/initial-util';
import { inputFieldValueAsNumber, inputFieldValue } from '../../lib/util'; import { inputFieldValueAsNumber } from '../../lib/util';
import { ResizeOptions } from './processor-meta'; import { ResizeOptions } from './processor-meta';
import * as style from '../../components/Options/style.scss';
import Checkbox from '../../components/checkbox';
import Expander from '../../components/expander';
import Select from '../../components/select';
interface Props { interface Props {
isVector: Boolean; isVector: Boolean;
@@ -26,26 +22,23 @@ export default class ResizerOptions extends Component<Props, State> {
form?: HTMLFormElement; form?: HTMLFormElement;
private reportOptions() { reportOptions() {
const form = this.form!; const width = this.form!.width as HTMLInputElement;
const width = form.width as HTMLInputElement; const height = this.form!.height as HTMLInputElement;
const height = form.height as HTMLInputElement;
const { options } = this.props;
if (!width.checkValidity() || !height.checkValidity()) return; if (!width.checkValidity() || !height.checkValidity()) return;
const newOptions: ResizeOptions = { const options: ResizeOptions = {
width: inputFieldValueAsNumber(width), width: inputFieldValueAsNumber(width),
height: inputFieldValueAsNumber(height), height: inputFieldValueAsNumber(height),
method: form.resizeMethod.value, method: this.form!.resizeMethod.value,
// Casting, as the formfield only returns the correct values. fitMethod: this.form!.fitMethod.value,
fitMethod: inputFieldValue(form.fitMethod, options.fitMethod) as ResizeOptions['fitMethod'],
}; };
this.props.onChange(newOptions); this.props.onChange(options);
} }
@bind @bind
private onChange() { onChange(event: Event) {
this.reportOptions(); this.reportOptions();
} }
@@ -57,31 +50,27 @@ export default class ResizerOptions extends Component<Props, State> {
} }
@bind @bind
private onWidthInput() { onWidthInput(event: Event) {
if (this.state.maintainAspect) { if (!this.state.maintainAspect) return;
const width = inputFieldValueAsNumber(this.form!.width); const width = inputFieldValueAsNumber(this.form!.width);
this.form!.height.value = Math.round(width / this.props.aspect); this.form!.height.value = Math.round(width / this.props.aspect);
} }
this.reportOptions();
}
@bind @bind
private onHeightInput() { onHeightInput(event: Event) {
if (this.state.maintainAspect) { if (!this.state.maintainAspect) return;
const height = inputFieldValueAsNumber(this.form!.height); const height = inputFieldValueAsNumber(this.form!.height);
this.form!.width.value = Math.round(height * this.props.aspect); this.form!.width.value = Math.round(height * this.props.aspect);
} }
this.reportOptions(); render({ options, aspect, isVector }: Props, { maintainAspect }: State) {
}
render({ options, isVector }: Props, { maintainAspect }: State) {
return ( return (
<form ref={linkRef(this, 'form')} class={style.optionsSection}> <form ref={el => this.form = el}>
<label class={style.optionTextFirst}> <label>
Method: Method:
<Select <select
name="resizeMethod" name="resizeMethod"
value={options.method} value={options.method}
onChange={this.onChange} onChange={this.onChange}
@@ -91,55 +80,51 @@ export default class ResizerOptions extends Component<Props, State> {
<option value="browser-low">Browser low quality</option> <option value="browser-low">Browser low quality</option>
<option value="browser-medium">Browser medium quality</option> <option value="browser-medium">Browser medium quality</option>
<option value="browser-high">Browser high quality</option> <option value="browser-high">Browser high quality</option>
</Select> </select>
</label> </label>
<label class={style.optionTextFirst}> <label>
Width: Width:
<input <input
required required
class={style.textField}
name="width" name="width"
type="number" type="number"
min="1" min="1"
value={'' + options.width} value={'' + options.width}
onChange={this.onChange}
onInput={this.onWidthInput} onInput={this.onWidthInput}
/> />
</label> </label>
<label class={style.optionTextFirst}> <label>
Height: Height:
<input <input
required required
class={style.textField}
name="height" name="height"
type="number" type="number"
min="1" min="1"
value={'' + options.height} value={'' + options.height}
onInput={this.onHeightInput} onChange={this.onChange}
/> />
</label> </label>
<label class={style.optionInputFirst}> <label>
<Checkbox <input
name="maintainAspect" name="maintainAspect"
type="checkbox"
checked={maintainAspect} checked={maintainAspect}
onChange={linkState(this, 'maintainAspect')} onChange={linkState(this, 'maintainAspect')}
/> />
Maintain aspect ratio Maintain aspect ratio
</label> </label>
<Expander> <label style={{ display: maintainAspect ? 'none' : '' }}>
{maintainAspect ? null :
<label class={style.optionTextFirst}>
Fit method: Fit method:
<Select <select
name="fitMethod" name="fitMethod"
value={options.fitMethod} value={options.fitMethod}
onChange={this.onChange} onChange={this.onChange}
> >
<option value="stretch">Stretch</option> <option value="stretch">Stretch</option>
<option value="cover">Cover</option> <option value="cover">Cover</option>
</Select> </select>
</label> </label>
}
</Expander>
</form> </form>
); );
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 B

View File

@@ -2,21 +2,13 @@ import { h, Component } from 'preact';
import { bind } from '../../lib/initial-util'; import { bind } from '../../lib/initial-util';
import { inputFieldCheckedAsNumber, inputFieldValueAsNumber } 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 styles from './styles.scss';
import Checkbox from '../../components/checkbox'; import '../../custom-els/RangeInput';
import Expander from '../../components/expander';
import Select from '../../components/select';
import Range from '../../components/range';
import linkState from 'linkstate';
interface Props { type Props = {
options: EncodeOptions; options: EncodeOptions,
onChange(newOptions: EncodeOptions): void; onChange(newOptions: EncodeOptions): void,
} };
interface State {
showAdvanced: boolean;
}
// From kLosslessPresets in config_enc.c // From kLosslessPresets in config_enc.c
// The format is [method, quality]. // The format is [method, quality].
@@ -26,291 +18,257 @@ 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'.
return losslessPresetDefault; return losslessPresetDefault;
} }
export default class WebPEncoderOptions extends Component<Props, State> { export default class WebPEncoderOptions extends Component<Props, {}> {
state: State = {
showAdvanced: false,
};
@bind @bind
onChange(event: Event) { onChange(event: Event) {
const form = (event.currentTarget as HTMLInputElement).closest('form') as HTMLFormElement; const form = (event.currentTarget as HTMLInputElement).closest('form') as HTMLFormElement;
const lossless = inputFieldCheckedAsNumber(form.lossless); const lossless = inputFieldCheckedAsNumber(form.lossless);
const { options } = this.props; const losslessPresetInput = (form.lossless_preset as HTMLInputElement);
const losslessPresetValue = inputFieldValueAsNumber(
form.lossless_preset, determineLosslessQuality(options.quality, options.method),
);
const newOptions: EncodeOptions = { const options: EncodeOptions = {
// Copy over options the form doesn't care about, eg emulate_jpeg_size // Copy over options the form doesn't care about, eg emulate_jpeg_size
...options, ...this.props.options,
// And now stuff from the form: // And now stuff from the form:
lossless, lossless,
// Special-cased inputs: // Special-cased inputs:
// In lossless mode, the quality is derived from the preset. // In lossless mode, the quality is derived from the preset.
quality: lossless ? quality: lossless ?
losslessPresets[losslessPresetValue][1] : losslessPresets[Number(losslessPresetInput.value)][1] :
inputFieldValueAsNumber(form.quality, options.quality), inputFieldValueAsNumber(form.quality),
// In lossless mode, the method is derived from the preset. // In lossless mode, the method is derived from the preset.
method: lossless ? method: lossless ?
losslessPresets[losslessPresetValue][0] : losslessPresets[Number(losslessPresetInput.value)][0] :
inputFieldValueAsNumber(form.method_input, options.method), inputFieldValueAsNumber(form.method_input),
image_hint: inputFieldCheckedAsNumber(form.image_hint, options.image_hint) ? image_hint: (form.image_hint as HTMLInputElement).checked ?
WebPImageHint.WEBP_HINT_GRAPH : WebPImageHint.WEBP_HINT_GRAPH :
WebPImageHint.WEBP_HINT_DEFAULT, WebPImageHint.WEBP_HINT_DEFAULT,
// .checked // .checked
exact: inputFieldCheckedAsNumber(form.exact, options.exact), exact: inputFieldCheckedAsNumber(form.exact),
alpha_compression: inputFieldCheckedAsNumber( alpha_compression: inputFieldCheckedAsNumber(form.alpha_compression),
form.alpha_compression, options.alpha_compression, autofilter: inputFieldCheckedAsNumber(form.autofilter),
), filter_type: inputFieldCheckedAsNumber(form.filter_type),
autofilter: inputFieldCheckedAsNumber(form.autofilter, options.autofilter), use_sharp_yuv: inputFieldCheckedAsNumber(form.use_sharp_yuv),
filter_type: inputFieldCheckedAsNumber(form.filter_type, options.filter_type),
use_sharp_yuv: inputFieldCheckedAsNumber(form.use_sharp_yuv, options.use_sharp_yuv),
// .value // .value
near_lossless: 100 - inputFieldValueAsNumber(form.near_lossless, 100 - options.near_lossless), near_lossless: 100 - inputFieldValueAsNumber(form.near_lossless),
alpha_quality: inputFieldValueAsNumber(form.alpha_quality, options.alpha_quality), alpha_quality: inputFieldValueAsNumber(form.alpha_quality),
alpha_filtering: inputFieldValueAsNumber(form.alpha_filtering, options.alpha_filtering), alpha_filtering: inputFieldValueAsNumber(form.alpha_filtering),
sns_strength: inputFieldValueAsNumber(form.sns_strength, options.sns_strength), sns_strength: inputFieldValueAsNumber(form.sns_strength),
filter_strength: inputFieldValueAsNumber(form.filter_strength, options.filter_strength), filter_strength: inputFieldValueAsNumber(form.filter_strength),
filter_sharpness: filter_sharpness: 7 - inputFieldValueAsNumber(form.filter_sharpness),
7 - inputFieldValueAsNumber(form.filter_sharpness, 7 - options.filter_sharpness), pass: inputFieldValueAsNumber(form.pass),
pass: inputFieldValueAsNumber(form.pass, options.pass), preprocessing: inputFieldValueAsNumber(form.preprocessing),
preprocessing: inputFieldValueAsNumber(form.preprocessing, options.preprocessing), segments: inputFieldValueAsNumber(form.segments),
segments: inputFieldValueAsNumber(form.segments, options.segments), partitions: inputFieldValueAsNumber(form.partitions),
partitions: inputFieldValueAsNumber(form.partitions, options.partitions),
}; };
this.props.onChange(newOptions); this.props.onChange(options);
} }
private _losslessSpecificOptions(options: EncodeOptions) { private _losslessSpecificOptions(options: EncodeOptions) {
return ( return (
<div key="lossless"> <div>
<div class={style.optionOneCell}> <label>
<Range Effort:
<range-input
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} onChange={this.onChange}
> />
Effort: </label>
</Range> <label>
</div> Slight loss:
<div class={style.optionOneCell}> <range-input
<Range
name="near_lossless" name="near_lossless"
min="0" min="0"
max="100" max="100"
value={'' + (100 - options.near_lossless)} value={'' + (100 - options.near_lossless)}
onInput={this.onChange} onChange={this.onChange}
> />
Slight loss: </label>
</Range> <label>
</div>
<label class={style.optionInputFirst}>
{/* {/*
Although there are 3 different kinds of image hint, webp only Although there are 3 different kinds of image hint, webp only
seems to do something with the 'graph' type, and I don't really seems to do something with the 'graph' type, and I don't really
understand what it does. understand what it does.
*/} */}
<Checkbox <input
name="image_hint" name="image_hint"
type="checkbox"
checked={options.image_hint === WebPImageHint.WEBP_HINT_GRAPH} checked={options.image_hint === WebPImageHint.WEBP_HINT_GRAPH}
value={'' + WebPImageHint.WEBP_HINT_GRAPH}
onChange={this.onChange} onChange={this.onChange}
/> />
Discrete tone image <span>Discrete tone image (graph, map-tile etc)</span>
</label> </label>
</div> </div>
); );
} }
private _lossySpecificOptions(options: EncodeOptions) { private _lossySpecificOptions(options: EncodeOptions) {
const { showAdvanced } = this.state;
return ( return (
<div key="lossy"> <div>
<div class={style.optionOneCell}> <label>
<Range Effort:
<range-input
name="method_input" name="method_input"
min="0" min="0"
max="6" max="6"
value={options.method} value={'' + options.method}
onInput={this.onChange} onChange={this.onChange}
> />
Effort: </label>
</Range> <label>
</div> Quality:
<div class={style.optionOneCell}> <range-input
<Range
name="quality" name="quality"
min="0" min="0"
max="100" max="100"
step="0.1" step="0.01"
value={options.quality} value={'' + options.quality}
onInput={this.onChange} onChange={this.onChange}
>
Quality:
</Range>
</div>
<label class={style.optionInputFirst}>
<Checkbox
checked={showAdvanced}
onChange={linkState(this, 'showAdvanced')}
/> />
Show advanced settings
</label> </label>
<Expander> <hr />
{showAdvanced ? <label>
<div> <input
<label class={style.optionInputFirst}>
<Checkbox
name="alpha_compression" name="alpha_compression"
type="checkbox"
checked={!!options.alpha_compression} checked={!!options.alpha_compression}
onChange={this.onChange} onChange={this.onChange}
/> />
Compress alpha Compress alpha
</label> </label>
<div class={style.optionOneCell}> <label>
<Range Alpha quality:
<range-input
name="alpha_quality" name="alpha_quality"
min="0" min="0"
max="100" max="100"
value={options.alpha_quality} value={'' + options.alpha_quality}
onInput={this.onChange} onChange={this.onChange}
> />
Alpha quality: </label>
</Range> <label>
</div> Alpha filter quality:
<div class={style.optionOneCell}> <range-input
<Range
name="alpha_filtering" name="alpha_filtering"
min="0" min="0"
max="2" max="2"
value={options.alpha_filtering} value={'' + options.alpha_filtering}
onInput={this.onChange} onChange={this.onChange}
> />
Alpha filter quality: </label>
</Range> <hr />
</div> <label>
<label class={style.optionInputFirst}> <input
<Checkbox
name="autofilter" name="autofilter"
type="checkbox"
checked={!!options.autofilter} checked={!!options.autofilter}
onChange={this.onChange} onChange={this.onChange}
/> />
Auto adjust filter strength <span>Auto adjust filter strength</span>
</label> </label>
<Expander> <label>
{options.autofilter ? null : Filter strength:
<div class={style.optionOneCell}> <range-input
<Range
name="filter_strength" name="filter_strength"
min="0" min="0"
max="100" max="100"
value={options.filter_strength} disabled={!!options.autofilter}
onInput={this.onChange} value={'' + options.filter_strength}
> onChange={this.onChange}
Filter strength: />
</Range> </label>
</div> <label>
} <input
</Expander>
<label class={style.optionInputFirst}>
<Checkbox
name="filter_type" name="filter_type"
type="checkbox"
checked={!!options.filter_type} checked={!!options.filter_type}
onChange={this.onChange} onChange={this.onChange}
/> />
Strong filter Strong filter
</label> </label>
<div class={style.optionOneCell}> <label>
<Range Filter sharpness:
<range-input
name="filter_sharpness" name="filter_sharpness"
min="0" min="0"
max="7" max="7"
value={7 - options.filter_sharpness} value={'' + (7 - options.filter_sharpness)}
onInput={this.onChange} onChange={this.onChange}
> />
Filter sharpness: </label>
</Range> <label>
</div> <input
<label class={style.optionInputFirst}>
<Checkbox
name="use_sharp_yuv" name="use_sharp_yuv"
type="checkbox"
checked={!!options.use_sharp_yuv} checked={!!options.use_sharp_yuv}
onChange={this.onChange} onChange={this.onChange}
/> />
Sharp RGBYUV conversion Sharp RGB->YUV conversion
</label> </label>
<div class={style.optionOneCell}> <hr />
<Range <label>
Passes:
<range-input
name="pass" name="pass"
min="1" min="1"
max="10" max="10"
value={options.pass} value={'' + options.pass}
onInput={this.onChange} onChange={this.onChange}
> />
Passes: </label>
</Range> <label>
</div> Spacial noise shaping:
<div class={style.optionOneCell}> <range-input
<Range
name="sns_strength" name="sns_strength"
min="0" min="0"
max="100" max="100"
value={options.sns_strength} value={'' + options.sns_strength}
onInput={this.onChange} onChange={this.onChange}
> />
Spacial noise shaping: </label>
</Range> <label>
</div> Preprocessing type:
<label class={style.optionTextFirst}> <select
Preprocess:
<Select
name="preprocessing" name="preprocessing"
value={options.preprocessing} value={'' + options.preprocessing}
onChange={this.onChange} onChange={this.onChange}
> >
<option value="0">None</option> <option value="0">None</option>
<option value="1">Segment smooth</option> <option value="1">Segment smooth</option>
<option value="2">Pseudo-random dithering</option> <option value="2">Pseudo-random dithering</option>
</Select> </select>
</label> </label>
<div class={style.optionOneCell}> <label>
<Range Segments:
<range-input
name="segments" name="segments"
min="1" min="1"
max="4" max="4"
value={options.segments} value={'' + options.segments}
onInput={this.onChange} onChange={this.onChange}
> />
Segments: </label>
</Range> <label>
</div> Partitions:
<div class={style.optionOneCell}> <range-input
<Range
name="partitions" name="partitions"
min="0" min="0"
max="3" max="3"
value={options.partitions} value={'' + options.partitions}
onInput={this.onChange} onChange={this.onChange}
> />
Partitions: </label>
</Range>
</div>
</div>
: null
}
</Expander>
</div> </div>
); );
} }
@@ -319,26 +277,32 @@ 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}> <form>
<label class={style.optionInputFirst}> <label>
<Checkbox <input
name="lossless" name="lossless"
type="checkbox"
checked={!!options.lossless} checked={!!options.lossless}
onChange={this.onChange} onChange={this.onChange}
/> />
Lossless Lossless
</label> </label>
{options.lossless <div class={options.lossless ? '' : styles.hide}>
? this._losslessSpecificOptions(options) {this._losslessSpecificOptions(options)}
: this._lossySpecificOptions(options) </div>
} <div class={options.lossless ? styles.hide : ''}>
<label class={style.optionInputFirst}> {this._lossySpecificOptions(options)}
<Checkbox </div>
<label>
<input
name="exact" name="exact"
type="checkbox"
checked={!!options.exact} checked={!!options.exact}
onChange={this.onChange} onChange={this.onChange}
/> />
Preserve transparent data <span>
Preserve transparent data. Otherwise, pixels with zero alpha will have RGB also zeroed.
</span>
</label> </label>
</form> </form>
); );

View File

@@ -0,0 +1,3 @@
.hide {
display: none;
}

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,9 +2,9 @@ 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';
@@ -12,15 +12,6 @@ import '../custom-els/LoadingSpinner';
// This is imported for TypeScript only. It isn't used. // This is imported for TypeScript only. It isn't used.
import Compress from '../compress'; import Compress from '../compress';
const compressPromise = import(
/* webpackChunkName: "main-app" */
'../compress',
);
const offlinerPromise = import(
/* webpackChunkName: "offliner" */
'../../lib/offliner',
);
export interface SourceImage { export interface SourceImage {
file: File | Fileish; file: File | Fileish;
data: ImageData; data: ImageData;
@@ -45,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);
@@ -77,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) {
@@ -92,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

@@ -1,3 +1,7 @@
/*
Note: These styles are temporary. They will be replaced before going live.
*/
.app { .app {
position: absolute; position: absolute;
left: 0; left: 0;

View File

@@ -0,0 +1,87 @@
import { h, Component } from 'preact';
import * as prettyBytes from 'pretty-bytes';
type FileContents = ArrayBuffer | Blob;
interface Props extends Pick<JSX.HTMLAttributes, Exclude<keyof JSX.HTMLAttributes, 'data'>> {
file?: FileContents;
compareTo?: FileContents;
increaseClass?: string;
decreaseClass?: string;
}
interface State {
size?: number;
sizeFormatted?: string;
compareSize?: number;
compareSizeFormatted?: string;
}
function calculateSize(data: FileContents): number {
return data instanceof ArrayBuffer ? data.byteLength : data.size;
}
export default class FileSize extends Component<Props, State> {
constructor(props: Props) {
super(props);
if (props.file) {
this.computeSize('size', props.file);
}
if (props.compareTo) {
this.computeSize('compareSize', props.compareTo);
}
}
componentWillReceiveProps({ file, compareTo }: Props) {
if (file !== this.props.file) {
this.computeSize('size', file);
}
if (compareTo !== this.props.compareTo) {
this.computeSize('compareSize', compareTo);
}
}
componentDidMount() {
this.applyStyles();
}
componentDidUpdate() {
this.applyStyles();
}
applyStyles() {
const { size, compareSize = 0 } = this.state;
if (size != null && this.base) {
const delta = size && compareSize ? (size - compareSize) / compareSize : 0;
this.base.style.setProperty('--size', '' + size);
this.base.style.setProperty('--size-delta', '' + Math.round(Math.abs(delta * 100)));
}
}
computeSize(prop: keyof State, data?: FileContents) {
const size = data ? calculateSize(data) : 0;
const pretty = prettyBytes(size);
this.setState({
[prop]: size,
[prop + 'Formatted']: pretty,
});
}
render(
{ file, compareTo, increaseClass, decreaseClass, ...props }: Props,
{ size, sizeFormatted = '', compareSize }: State,
) {
const delta = size && compareSize ? (size - compareSize) / compareSize : 0;
return (
<span {...props}>
{sizeFormatted}
{compareTo && (
<span class={delta > 0 ? increaseClass : decreaseClass}>
{delta > 0 && '+'}
{Math.round(delta * 100)}%
</span>
)}
</span>
);
}
}

View File

@@ -1,7 +1,7 @@
import { h, Component } from 'preact'; import { h, Component } from 'preact';
import * as style from './style.scss'; import * as style from './style.scss';
import { bind } from '../../lib/initial-util'; import { bind, Fileish } from '../../lib/initial-util';
import { cleanSet, cleanMerge } from '../../lib/clean-modify'; import { cleanSet, cleanMerge } from '../../lib/clean-modify';
import OptiPNGEncoderOptions from '../../codecs/optipng/options'; import OptiPNGEncoderOptions from '../../codecs/optipng/options';
import MozJpegEncoderOptions from '../../codecs/mozjpeg/options'; import MozJpegEncoderOptions from '../../codecs/mozjpeg/options';
@@ -31,14 +31,14 @@ import {
encoders, encoders,
encodersSupported, encodersSupported,
EncoderSupportMap, EncoderSupportMap,
encoderMap,
} from '../../codecs/encoders'; } from '../../codecs/encoders';
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 FileSize from '../FileSize';
import { DownloadIcon } from '../../lib/icons';
import { SourceImage } from '../App'; import { SourceImage } from '../App';
import Checkbox from '../checkbox';
import Expander from '../expander';
import Select from '../select';
const encoderOptionsComponentMap = { const encoderOptionsComponentMap = {
[identity.type]: undefined, [identity.type]: undefined,
@@ -56,14 +56,23 @@ const encoderOptionsComponentMap = {
[browserPDF.type]: undefined, [browserPDF.type]: undefined,
}; };
const titles = {
horizontal: ['Left Image', 'Right Image'],
vertical: ['Top Image', 'Bottom Image'],
};
interface Props { interface Props {
mobileView: boolean; orientation: 'horizontal' | 'vertical';
source?: SourceImage; source?: SourceImage;
imageIndex: number;
imageFile?: Fileish;
downloadUrl?: string;
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 {
@@ -71,9 +80,7 @@ interface State {
} }
export default class Options extends Component<Props, State> { export default class Options extends Component<Props, State> {
state: State = { typeSelect?: HTMLSelectElement;
encoderSupportMap: undefined,
};
constructor() { constructor() {
super(); super();
@@ -114,9 +121,19 @@ export default class Options extends Component<Props, State> {
); );
} }
@bind
onCopyToOtherClick(event: Event) {
event.preventDefault();
this.props.onCopyToOtherClick();
}
render( render(
{ {
source, source,
imageIndex,
imageFile,
downloadUrl,
orientation,
encoderState, encoderState,
preprocessorState, preprocessorState,
onEncoderOptionsChange, onEncoderOptionsChange,
@@ -127,65 +144,64 @@ export default class Options extends Component<Props, State> {
const EncoderOptionComponent = encoderOptionsComponentMap[encoderState.type]; const EncoderOptionComponent = encoderOptionsComponentMap[encoderState.type];
return ( return (
<div class={style.optionsScroller}> <div class={`${style.options} ${style[orientation]}`}>
<Expander> <h2 class={style.title}>
{encoderState.type === identity.type ? null : {titles[orientation][imageIndex]}
<div> {', '}
<h3 class={style.optionsTitle}>Edit</h3> {encoderMap[encoderState.type].label}
<label class={style.sectionEnabler}> </h2>
<Checkbox
<div class={style.content}>
<section class={style.picker}>
{encoderSupportMap ?
<select value={encoderState.type} onChange={this.onEncoderTypeChange}>
{encoders.filter(encoder => encoderSupportMap[encoder.type]).map(encoder => (
<option value={encoder.type}>{encoder.label}</option>
))}
</select>
:
<select><option>Loading</option></select>
}
</section>
{encoderState.type !== 'identity' && (
<div key="preprocessors" class={style.preprocessors}>
<label class={style.toggle}>
<input
name="resize.enable" name="resize.enable"
type="checkbox"
checked={!!preprocessorState.resize.enabled} checked={!!preprocessorState.resize.enabled}
onChange={this.onPreprocessorEnabledChange} onChange={this.onPreprocessorEnabledChange}
/> />
Resize Resize
</label> </label>
<Expander> {preprocessorState.resize.enabled &&
{preprocessorState.resize.enabled ?
<ResizeOptionsComponent <ResizeOptionsComponent
isVector={Boolean(source && source.vectorImage)} isVector={Boolean(source && source.vectorImage)}
aspect={source ? (source.data.width / source.data.height) : 1} aspect={source ? (source.data.width / source.data.height) : 1}
options={preprocessorState.resize} options={preprocessorState.resize}
onChange={this.onResizeOptionsChange} onChange={this.onResizeOptionsChange}
/> />
: null} }
</Expander> <label class={style.toggle}>
<label class={style.sectionEnabler}> <input
<Checkbox
name="quantizer.enable" name="quantizer.enable"
type="checkbox"
checked={!!preprocessorState.quantizer.enabled} checked={!!preprocessorState.quantizer.enabled}
onChange={this.onPreprocessorEnabledChange} onChange={this.onPreprocessorEnabledChange}
/> />
Reduce palette Quantize
</label> </label>
<Expander> {preprocessorState.quantizer.enabled &&
{preprocessorState.quantizer.enabled ?
<QuantizerOptionsComponent <QuantizerOptionsComponent
options={preprocessorState.quantizer} options={preprocessorState.quantizer}
onChange={this.onQuantizerOptionsChange} onChange={this.onQuantizerOptionsChange}
/> />
: null} }
</Expander>
</div> </div>
} )}
</Expander>
<h3 class={style.optionsTitle}>Compress</h3> {EncoderOptionComponent &&
<section class={`${style.optionOneCell} ${style.optionsSection}`}>
{encoderSupportMap ?
<Select value={encoderState.type} onChange={this.onEncoderTypeChange} large>
{encoders.filter(encoder => encoderSupportMap[encoder.type]).map(encoder => (
<option value={encoder.type}>{encoder.label}</option>
))}
</Select>
:
<Select large><option>Loading</option></Select>
}
</section>
<Expander>
{EncoderOptionComponent ?
<EncoderOptionComponent <EncoderOptionComponent
options={ options={
// Casting options, as encoderOptionsComponentMap[encodeData.type] ensures // Casting options, as encoderOptionsComponentMap[encodeData.type] ensures
@@ -194,8 +210,33 @@ export default class Options extends Component<Props, State> {
} }
onChange={onEncoderOptionsChange} onChange={onEncoderOptionsChange}
/> />
: null} }
</Expander> </div>
<div class={style.row}>
<button onClick={this.onCopyToOtherClick}>Copy settings to other side</button>
</div>
<div class={style.sizeDetails}>
<FileSize
class={style.size}
increaseClass={style.increase}
decreaseClass={style.decrease}
file={imageFile}
compareTo={(source && imageFile !== source.file) ? source.file : undefined}
/>
{(downloadUrl && imageFile) && (
<a
class={style.download}
href={downloadUrl}
download={imageFile.name}
title="Download"
>
<DownloadIcon />
</a>
)}
</div>
</div> </div>
); );
} }

View File

@@ -1,58 +1,225 @@
$horizontalPadding: 15px; /*
Note: These styles are temporary. They will be replaced before going live.
*/
.options-title { .row {
background: rgba(0, 0, 0, 0.9); padding: 5px;
margin: 0; margin: 0 10px;
padding: 10px $horizontalPadding;
font-weight: normal;
font-size: 1.4rem;
border-bottom: 1px solid #000;
} }
.option-text-first { .options {
display: grid;
align-items: center;
grid-template-columns: 87px 1fr;
grid-gap: 0.7em;
padding: 10px $horizontalPadding;
}
.option-one-cell {
display: grid;
grid-template-columns: 1fr;
padding: 10px $horizontalPadding;
}
.option-input-first,
.section-enabler {
cursor: pointer;
display: grid;
align-items: center;
grid-template-columns: auto 1fr;
grid-gap: 0.7em;
padding: 10px $horizontalPadding;
}
.section-enabler {
background: rgba(0, 0, 0, 0.8);
}
.options-section {
background: rgba(0, 0, 0, 0.7);
}
.text-field {
background: #fff;
font: inherit;
border: none;
padding: 2px 0 2px 10px;
width: 100%;
box-sizing: border-box; box-sizing: border-box;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.5); padding: 0;
background: rgba(40,40,40,0.8);
box-shadow: 0 1px 3px rgba(0,0,0,0.5);
color: #eee;
overflow: auto;
z-index: 1;
opacity: 0.9;
transform-origin: 50% 140%;
transition: opacity 300ms linear;
animation: options-open 500ms cubic-bezier(.6,1.6,.6,1) forwards 1;
&.horizontal {
border-radius: 1px 1px 5px 5px;
width: 230px;
> .inner {
max-height: 80vh;
overflow: auto;
-webkit-overflow-scrolling: touch;
-ms-touch-action: pan-y;
touch-action: pan-y;
}
} }
.options-scroller { &.vertical {
overflow-x: hidden; opacity: 1;
overflow-y: auto; margin: 0 5px 10px;
border-radius: 0 0 5px 5px;
}
&:hover, &:focus, &:focus-within {
opacity: 1;
}
@keyframes options-open {
from {
transform: translateY(100px) scale(.8);
}
}
.content {
max-height: calc(75vh - 100px);
overflow: auto;
touch-action: pan-y;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
.picker {
margin: 5px 15px;
select {
display: block;
width: 100%;
box-sizing: border-box;
-webkit-appearance: none;
appearance: none;
padding: 10px 30px 10px 10px;
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="25" height="5"><polygon fill="#fff" points="10,0 5,5 0,0"/></svg>') right center no-repeat;
background-color: var(--gray-dark);
opacity: 0.9;
border: none;
font: inherit;
color: white;
transition: box-shadow 150ms ease;
&:hover {
opacity: 1;
}
&:focus {
opacity: 1;
outline: none;
box-shadow: 0 0 0 2px var(--button-fg, #ccc);
}
}
}
.title {
display: flex;
align-items: center;
padding: 10px 15px;
margin: 0 0 12px;
background: rgba(0,0,0,0.9);
font: inherit;
}
label {
display: block;
padding: 5px;
margin: 0 10px;
display: flex;
flex-wrap: wrap;
// prevent labels from wrapping below checkboxes
> span {
flex: 1;
}
input[type=checkbox],
input[type=radio] {
flex: 0;
margin: 2px 8px 0 0;
}
range-input {
display: block;
flex: 1 0 100%;
margin: 2px 0;
}
}
hr {
height: 1px;
border: none;
margin: 5px 0;
box-shadow: inset 0 0.5px 0 rgba(0, 0, 0, 0.4), inset 0 -0.5px 0 rgba(255, 255, 255, 0.2);
}
}
.picker {
margin: 5px 15px;
select {
display: block;
width: 100%;
box-sizing: border-box;
-webkit-appearance: none;
appearance: none;
padding: 10px 30px 10px 10px;
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="25" height="5"><polygon fill="#fff" points="10,0 5,5 0,0"/></svg>') right center no-repeat;
background-color: var(--gray-dark);
opacity: 0.9;
border: none;
font: inherit;
color: white;
}
hr {
height: 1px;
border: none;
margin: 5px 0;
box-shadow: inset 0 0.5px 0 rgba(0, 0, 0, 0.4), inset 0 -0.5px 0 rgba(255, 255, 255, 0.2);
}
}
.size-details {
display: flex;
align-items: center;
padding: 5px 15px;
background: rgba(0,0,0,0.5);
}
.download {
flex: 0;
margin: 0 0 0 auto;
background: rgba(255,255,255,0.1);
border-radius: 50%;
padding: 5px;
width: 16px;
height: 16px;
text-decoration: none;
> svg {
width: 16px;
height: 16px;
fill: #fff;
}
&:hover {
background-color: rgba(255,255,255,0.3);
}
}
.size-details {
padding: 5px 15px;
background: rgba(0,0,0,0.5);
}
.size {
font-weight: normal;
}
.increase,
.decrease {
font-style: italic;
filter: #{"grayscale(calc(50% - var(--size-delta, 50) * 0.5%))"};
&:before {
content: ' (';
}
&:after {
content: ')';
}
}
.increase {
color: var(--negative);
}
.decrease {
color: var(--positive);
}
.preprocessors {
padding: 5px 0;
margin: 5px 0;
box-shadow: inset 0 -.5px 0 rgba(0,0,0,0.4), 0 .5px 0 rgba(255,255,255,0.2);
}
.toggle {
display: flex;
position: relative;
align-content: center;
font-size: 14px;
box-shadow: inset 0 -.5px 0 rgba(0,0,0,0.4), 0 .5px 0 rgba(255,255,255,0.2);
}

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();
if (!this._everConnected) {
this._handle.innerHTML = `<div class="${styles.scrubber}">${ this._handle.innerHTML = `<div class="${styles.scrubber}">${
`<svg viewBox="0 0 27 20" fill="currentColor">${ `<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"/>' '<path d="M17 19.2l9.5-9.6L16.9 0zM9.6 0L0 9.6l9.6 9.6z"/>'
}</svg>` }</svg>`
}</div>`; }</div>`;
if (!this._everConnected) {
this._resetPosition(); this._resetPosition();
this._everConnected = true; this._everConnected = true;
} }

View File

@@ -52,11 +52,11 @@ two-up[legacy-clip-compat] > :not(.two-up-handle) {
height: calc(var(--thumb-size) * 0.9); height: calc(var(--thumb-size) * 0.9);
background: var(--thumb-background); background: var(--thumb-background);
border: 1px solid rgba(0,0,0,0.2); border: 1px solid rgba(0,0,0,0.2);
border-radius: var(--thumb-size); border-radius: calc(var(--thumb-size) * 0.08);
box-shadow: 0 1px 4px rgba(0,0,0,0.1); box-shadow: 0 1px 4px rgba(0,0,0,0.1);
color: var(--thumb-color); color: var(--thumb-color);
box-sizing: border-box; box-sizing: border-box;
padding: 0 calc(var(--thumb-size) * 0.24); padding: 0 48%;
} }
.scrubber svg { .scrubber svg {

View File

@@ -5,17 +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 { ToggleIcon, AddIcon, RemoveIcon, BackIcon } from '../../lib/icons'; import { ToggleIcon, AddIcon, RemoveIcon } from '../../lib/icons';
import { twoUpHandle } from './custom-els/TwoUp/styles.css'; import { twoUpHandle } from './custom-els/TwoUp/styles.css';
interface Props { interface Props {
originalImage?: ImageData; originalImage?: ImageData;
mobileView: boolean; orientation: 'horizontal' | 'vertical';
leftCompressed?: ImageData; leftCompressed?: ImageData;
rightCompressed?: ImageData; rightCompressed?: ImageData;
leftImgContain: boolean; leftImgContain: boolean;
rightImgContain: boolean; rightImgContain: boolean;
onBack: () => void;
} }
interface State { interface State {
@@ -48,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);
} }
@@ -187,21 +177,13 @@ 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, originalImage, onBack }: Props, {
orientation, leftCompressed, rightCompressed, leftImgContain, rightImgContain,
originalImage,
}: Props,
{ scale, editingScale, altBackground }: State, { scale, editingScale, altBackground }: State,
) { ) {
const leftDraw = this.leftDrawable(); const leftDraw = this.leftDrawable();
@@ -212,7 +194,7 @@ export default class Output extends Component<Props, State> {
<two-up <two-up
legacy-clip-compat legacy-clip-compat
class={style.twoUp} class={style.twoUp}
orientation={mobileView ? 'vertical' : 'horizontal'} orientation={orientation}
// Event redirecting. See onRetargetableEvent. // Event redirecting. See onRetargetableEvent.
onTouchStartCapture={this.onRetargetableEvent} onTouchStartCapture={this.onRetargetableEvent}
onTouchEndCapture={this.onRetargetableEvent} onTouchEndCapture={this.onRetargetableEvent}
@@ -253,12 +235,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}>

View File

@@ -1,3 +1,7 @@
/*
Note: These styles are temporary. They will be replaced before going live.
*/
.output { .output {
composes: abs-fill from '../../lib/util.scss'; composes: abs-fill from '../../lib/util.scss';
@@ -38,22 +42,15 @@
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;
// Allow clicks to fall through to the pinch zoom area @media (min-width: 680px) {
pointer-events: none;
& > * {
pointer-events: auto;
}
@media (min-width: 860px) {
padding: 9px;
top: auto; top: auto;
left: 320px; left: 220px;
right: 320px; right: 220px;
bottom: 0; bottom: 0;
} }
} }
@@ -79,19 +76,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;
@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);
@@ -135,15 +127,4 @@
.output-canvas { .output-canvas {
flex-shrink: 0; flex-shrink: 0;
// This fixes a severe painting bug in Chrome.
// We should try to remove this once the issue is fixed.
// https://bugs.chromium.org/p/chromium/issues/detail?id=870222#c10
will-change: auto;
}
.back {
position: absolute;
top: 0;
left: 0;
padding: 9px;
} }

View File

@@ -1,20 +0,0 @@
import { h, Component } from 'preact';
import * as style from './style.scss';
import { UncheckedIcon, CheckedIcon } from '../../lib/icons';
interface Props extends JSX.HTMLAttributes {}
interface State {}
export default class Checkbox extends Component<Props, State> {
render(props: Props) {
return (
<div class={style.checkbox}>
{props.checked
? <CheckedIcon class={`${style.icon} ${style.checked}`} />
: <UncheckedIcon class={style.icon} />
}
<input class={style.realCheckbox} type="checkbox" {...props}/>
</div>
);
}
}

View File

@@ -1,22 +0,0 @@
.checkbox {
display: inline-block;
position: relative;
--size: 17px;
}
.real-checkbox {
top: 0;
position: absolute;
opacity: 0;
pointer-events: none;
}
.icon {
display: block;
width: var(--size);
height: var(--size);
}
.checked {
fill: #34B9EB;
}

View File

@@ -1,9 +0,0 @@
interface MultiPanelAttributes extends JSX.HTMLAttributes {
'open-one-only'?: boolean;
}
declare namespace JSX {
interface IntrinsicElements {
'multi-panel': MultiPanelAttributes;
}
}

View File

@@ -1,10 +0,0 @@
.panel-heading {
background: gray;
}
.panel-content {
height: 0px;
overflow: auto;
}
.panel-content[aria-expanded=true] {
height: auto;
}

View File

@@ -24,18 +24,18 @@ import {
EncoderOptions, EncoderOptions,
encoderMap, encoderMap,
} from '../../codecs/encoders'; } from '../../codecs/encoders';
import { import {
PreprocessorState, PreprocessorState,
defaultPreprocessorState, defaultPreprocessorState,
} from '../../codecs/preprocessors'; } from '../../codecs/preprocessors';
import { decodeImage } from '../../codecs/decoders'; import { decodeImage } from '../../codecs/decoders';
import { cleanMerge, cleanSet } from '../../lib/clean-modify'; import { cleanMerge, cleanSet } from '../../lib/clean-modify';
import Processor from '../../codecs/processor'; 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 Results from '../results'; type Orientation = 'horizontal' | 'vertical';
import { ExpandIcon, CopyAcrossIconProps } from '../../lib/icons';
import SnackBarElement from 'src/lib/SnackBar';
export interface SourceImage { export interface SourceImage {
file: File | Fileish; file: File | Fileish;
@@ -59,18 +59,15 @@ interface EncodedImage {
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;
images: [EncodedImage, EncodedImage]; images: [EncodedImage, EncodedImage];
/** Source image load */
loading: boolean; loading: boolean;
loadingCounter: number;
error?: string; error?: string;
mobileView: boolean; orientation: Orientation;
} }
interface UpdateImageOptions { interface UpdateImageOptions {
@@ -138,7 +135,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);
@@ -156,19 +153,12 @@ async function processSvg(blob: Blob): Promise<HTMLImageElement> {
return blobToImg(new Blob([newSource], { type: 'image/svg+xml' })); return blobToImg(new Blob([newSource], { type: 'image/svg+xml' }));
} }
// These are only used in the mobile view
const resultTitles = ['Top', 'Bottom'];
// These are only used in the desktop view
const buttonPositions =
['download-left', 'download-right'] as ('download-left' | 'download-right')[];
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('(min-width: 500px)');
state: State = { state: State = {
source: undefined, source: undefined,
loading: false, loading: false,
loadingCounter: 0,
images: [ images: [
{ {
preprocessorState: defaultPreprocessorState, preprocessorState: defaultPreprocessorState,
@@ -185,7 +175,7 @@ export default class Compress extends Component<Props, State> {
loading: false, loading: false,
}, },
], ],
mobileView: this.widthQuery.matches, orientation: this.widthQuery.matches ? 'horizontal' : 'vertical',
}; };
private readonly encodeCache = new ResultCache(); private readonly encodeCache = new ResultCache();
@@ -196,13 +186,11 @@ 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
private onMobileWidthChange() { private onMobileWidthChange() {
this.setState({ mobileView: this.widthQuery.matches }); this.setState({ orientation: this.widthQuery.matches ? 'horizontal' : 'vertical' });
} }
private onEncoderTypeChange(index: 0 | 1, newType: EncoderType): void { private onEncoderTypeChange(index: 0 | 1, newType: EncoderType): void {
@@ -254,31 +242,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.images[otherIndex];
this.setState({ this.setState({
images: cleanSet(this.state.images, otherIndex, this.state.images[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({
images: cleanSet(this.state.images, otherIndex, oldSettings),
});
} }
@bind @bind
private async updateFile(file: File | Fileish) { private async updateFile(file: File | Fileish) {
const loadingCounter = this.state.loadingCounter + 1; this.setState({ loading: true });
this.setState({ loadingCounter, loading: true });
// Abort any current encode jobs, as they're redundant now. // Abort any current encode jobs, as they're redundant now.
this.leftProcessor.abortCurrent(); this.leftProcessor.abortCurrent();
@@ -299,9 +273,6 @@ export default class Compress extends Component<Props, State> {
data = await decodeImage(file, this.leftProcessor); data = await decodeImage(file, this.leftProcessor);
} }
// Another file has been opened before this one processed.
if (this.state.loadingCounter !== loadingCounter) return;
let newState: State = { let newState: State = {
...this.state, ...this.state,
source: { data, file, vectorImage }, source: { data, file, vectorImage },
@@ -332,9 +303,7 @@ export default class Compress extends Component<Props, State> {
} 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 before this one processed. this.props.onError('Invalid image');
if (this.state.loadingCounter !== loadingCounter) return;
this.props.showSnack('Invalid image');
this.setState({ loading: false }); this.setState({ loading: false });
} }
} }
@@ -393,7 +362,7 @@ export default class Compress extends Component<Props, State> {
} }
} catch (err) { } catch (err) {
if (err.name === 'AbortError') return; if (err.name === 'AbortError') return;
this.props.showSnack(`Processing error (type=${image.encoderState.type}): ${err}`); this.props.onError(`Processing error (type=${image.encoderState.type}): ${err}`);
throw err; throw err;
} }
} }
@@ -416,74 +385,39 @@ export default class Compress extends Component<Props, State> {
this.setState({ images }); this.setState({ images });
} }
render({ onBack }: Props, { loading, images, source, mobileView }: State) { render({ }: Props, { loading, images, source, orientation }: State) {
const [leftImage, rightImage] = images; const [leftImage, rightImage] = images;
const [leftImageData, rightImageData] = images.map(i => i.data); const [leftImageData, rightImageData] = images.map(i => i.data);
const anyLoading = loading || images.some(image => image.loading);
const options = images.map((image, index) => (
<Options
source={source}
mobileView={mobileView}
preprocessorState={image.preprocessorState}
encoderState={image.encoderState}
onEncoderTypeChange={this.onEncoderTypeChange.bind(this, index)}
onEncoderOptionsChange={this.onEncoderOptionsChange.bind(this, index)}
onPreprocessorOptionsChange={this.onPreprocessorOptionsChange.bind(this, index)}
/>
));
const copyDirections =
(mobileView ? ['down', 'up'] : ['right', 'left']) as CopyAcrossIconProps['copyDirection'][];
const results = images.map((image, index) => (
<Results
downloadUrl={image.downloadUrl}
imageFile={image.file}
source={source}
loading={loading || image.loading}
copyDirection={copyDirections[index]}
onCopyToOtherClick={this.onCopyToOtherClick.bind(this, index)}
buttonPosition={mobileView ? 'stack-right' : buttonPositions[index]}
>
{!mobileView ? null : [
<ExpandIcon class={style.expandIcon} key="expand-icon"/>,
`${resultTitles[index]} (${encoderMap[image.encoderState.type].label})`,
]}
</Results>
));
return ( return (
<div class={style.compress}> <div class={style.compress}>
<Output <Output
originalImage={source && source.data} originalImage={source && source.data}
mobileView={mobileView} orientation={orientation}
leftCompressed={leftImageData} leftCompressed={leftImageData}
rightCompressed={rightImageData} rightCompressed={rightImageData}
leftImgContain={leftImage.preprocessorState.resize.fitMethod === 'cover'} leftImgContain={leftImage.preprocessorState.resize.fitMethod === 'cover'}
rightImgContain={rightImage.preprocessorState.resize.fitMethod === 'cover'} rightImgContain={rightImage.preprocessorState.resize.fitMethod === 'cover'}
onBack={onBack}
/> />
{mobileView <div class={`${style.optionPair} ${style[orientation]}`}>
? ( {images.map((image, index) => (
<div class={style.options}> <Options
<multi-panel class={style.multiPanel} open-one-only> source={source}
{results[0]} orientation={orientation}
{options[0]} imageIndex={index}
{results[1]} imageFile={image.file}
{options[1]} downloadUrl={image.downloadUrl}
</multi-panel> preprocessorState={image.preprocessorState}
encoderState={image.encoderState}
onEncoderTypeChange={this.onEncoderTypeChange.bind(this, index)}
onEncoderOptionsChange={this.onEncoderOptionsChange.bind(this, index)}
onPreprocessorOptionsChange={this.onPreprocessorOptionsChange.bind(this, index)}
onCopyToOtherClick={this.onCopyToOtherClick.bind(this, index)}
/>
))}
</div> </div>
) : ([ {anyLoading && <span style={{ position: 'fixed', top: 0, left: 0 }}>Loading...</span>}
<div class={style.options} key="options0">
{options[0]}
{results[0]}
</div>,
<div class={style.options} key="options1">
{options[1]}
{results[1]}
</div>,
])
}
</div> </div>
); );
} }

View File

@@ -1,74 +1,19 @@
.compress { .compress {
height: 100%;
}
.option-pair {
display: flex;
width: 100%; width: 100%;
height: 100%; height: 100%;
contain: strict;
display: grid;
align-items: end;
align-content: end;
grid-template-rows: 1fr auto;
@media (min-width: 600px) { &.horizontal {
grid-template-columns: 1fr auto; justify-content: space-between;
grid-template-rows: 100%; align-items: flex-end;
}
} }
.options { &.vertical {
color: #fff; flex-direction: column;
opacity: 0.9; justify-content: flex-end;
font-size: 1.2rem;
display: flex;
flex-flow: column;
max-width: 400px;
margin: 0 auto;
width: calc(100% - 60px);
max-height: calc(100% - 104px);
overflow: hidden;
@media (min-width: 600px) {
max-height: calc(100% - 75px);
width: 300px;
margin: 0;
}
@media (min-width: 860px) {
max-height: calc(100% - 40px);
} }
} }
.multi-panel {
position: relative;
display: flex;
flex-flow: column;
// Reorder so headings appear after content:
& > :nth-child(1) {
order: 2;
margin-bottom: 10px;
}
& > :nth-child(2) {
order: 1;
}
& > :nth-child(3) {
order: 4;
}
& > :nth-child(4) {
order: 3;
}
}
.expand-icon {
transform: rotate(180deg);
margin-left: -12px;
}
[content-expanded] .expand-icon {
transform: none;
}
:focus .expand-icon {
fill: #34B9EB;
}

View File

@@ -1,71 +0,0 @@
import { h, Component, ComponentChild, ComponentChildren } from 'preact';
import * as style from './style.scss';
import { transitionHeight } from '../../lib/util';
interface Props {
children: ComponentChildren;
}
interface State {
outgoingChildren: ComponentChild[];
}
export default class Expander extends Component<Props, State> {
state: State = {
outgoingChildren: [],
};
private lastElHeight: number = 0;
componentWillReceiveProps(nextProps: Props) {
const children = this.props.children as ComponentChild[];
const nextChildren = nextProps.children as ComponentChild[];
if (!nextChildren[0] && children[0]) {
// Cache the current children for the shrink animation.
this.setState({ outgoingChildren: children });
}
}
componentWillUpdate(nextProps: Props) {
const children = this.props.children as ComponentChild[];
const nextChildren = nextProps.children as ComponentChild[];
// Only interested if going from empty to not-empty, or not-empty to empty.
if ((children[0] && nextChildren[0]) || (!children[0] && !nextChildren[0])) return;
this.lastElHeight = this.base!.getBoundingClientRect().height;
}
async componentDidUpdate(previousProps: Props) {
const children = this.props.children as ComponentChild[];
const previousChildren = previousProps.children as ComponentChild[];
// Only interested if going from empty to not-empty, or not-empty to empty.
if ((children[0] && previousChildren[0]) || (!children[0] && !previousChildren[0])) return;
// What height do we need to transition to?
this.base!.style.height = '';
this.base!.style.overflow = 'hidden';
const newHeight = children[0] ? this.base!.getBoundingClientRect().height : 0;
await transitionHeight(this.base!, {
duration: 300,
from: this.lastElHeight,
to: newHeight,
});
// Unset the height & overflow, so element changes do the right thing.
this.base!.style.height = '';
this.base!.style.overflow = '';
if (this.state.outgoingChildren[0]) this.setState({ outgoingChildren: [] });
}
render(props: Props, { outgoingChildren }: State) {
const children = props.children as ComponentChild[];
const childrenExiting = !children[0] && outgoingChildren[0];
return (
<div class={childrenExiting ? style.childrenExiting : ''}>
{children[0] ? children : outgoingChildren}
</div>
);
}
}

View File

@@ -1,5 +0,0 @@
.children-exiting {
& > * {
pointer-events: none;
}
}

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

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,7 +38,7 @@
.logo-sizer { .logo-sizer {
width: 90%; width: 90%;
max-width: 52vh; max-width: 480px;
margin: 0 auto; margin: 0 auto;
} }
@@ -51,7 +47,7 @@
} }
.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) {

View File

@@ -1,75 +1,19 @@
import * as style from './styles.css'; import './styles.css';
import { transitionHeight } from '../../../../lib/util';
interface CloseAllOptions { function getClosestHeading(el: Element) {
exceptFirst?: boolean; const closestEl = el.closest('multi-panel > *');
} if (closestEl && closestEl.classList.contains('panel-heading')) {
return closestEl;
const openOneOnlyAttr = 'open-one-only';
function getClosestHeading(el: Element): HTMLElement | undefined {
// Look for the child of multi-panel, but stop at interactive elements like links & buttons
const closestEl = el.closest('multi-panel > *, a, button');
if (closestEl && closestEl.classList.contains(style.panelHeading)) {
return closestEl as HTMLElement;
} }
return undefined; return undefined;
} }
async function close(heading: HTMLElement) {
const content = heading.nextElementSibling as HTMLElement;
// if there is no content, nothing to expand
if (!content) return;
const from = content.getBoundingClientRect().height;
heading.removeAttribute('content-expanded');
content.setAttribute('aria-expanded', 'false');
// Wait a microtask so other calls to open/close can get the final sizes.
await null;
await transitionHeight(content, {
from,
to: 0,
duration: 300,
});
content.style.height = '';
}
async function open(heading: HTMLElement) {
const content = heading.nextElementSibling as HTMLElement;
// if there is no content, nothing to expand
if (!content) return;
const from = content.getBoundingClientRect().height;
heading.setAttribute('content-expanded', '');
content.setAttribute('aria-expanded', 'true');
const to = content.getBoundingClientRect().height;
// Wait a microtask so other calls to open/close can get the final sizes.
await null;
await transitionHeight(content, {
from, to,
duration: 300,
});
content.style.height = '';
}
/** /**
* A multi-panel view that the user can add any number of 'panels'. * A multi-panel view that the user can add any number of 'panels'.
* 'a panel' consists of two elements. Even index element becomes heading, * 'a panel' consists of two elements. Even index element becomes heading,
* and odd index element becomes the expandable content. * and odd index element becomes the expandable content.
*/ */
export default class MultiPanel extends HTMLElement { export default class MultiPanel extends HTMLElement {
static get observedAttributes() { return [openOneOnlyAttr]; }
constructor() { constructor() {
super(); super();
@@ -87,18 +31,12 @@ export default class MultiPanel extends HTMLElement {
this._childrenChange(); this._childrenChange();
} }
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) {
if (name === openOneOnlyAttr && newValue === null) {
this._closeAll({ exceptFirst: true });
}
}
// Click event handler // Click event handler
private _onClick(event: MouseEvent) { private _onClick(event: MouseEvent) {
const el = event.target as HTMLElement; const el = event.target as Element;
const heading = getClosestHeading(el); const heading = getClosestHeading(el);
if (!heading) return; if (!heading) return;
this._toggle(heading); this._expand(heading);
} }
// KeyDown event handler // KeyDown event handler
@@ -116,7 +54,6 @@ export default class MultiPanel extends HTMLElement {
if (event.altKey) return; if (event.altKey) return;
let newHeading:HTMLElement | undefined; let newHeading:HTMLElement | undefined;
switch (event.key) { switch (event.key) {
case 'ArrowLeft': case 'ArrowLeft':
case 'ArrowUp': case 'ArrowUp':
@@ -140,7 +77,7 @@ export default class MultiPanel extends HTMLElement {
case 'Enter': case 'Enter':
case ' ': case ' ':
case 'Spacebar': case 'Spacebar':
this._toggle(heading); this._expand(heading);
break; break;
// Any other key press is ignored and passed back to the browser. // Any other key press is ignored and passed back to the browser.
@@ -156,32 +93,26 @@ export default class MultiPanel extends HTMLElement {
} }
} }
private _toggle(heading: HTMLElement) { private _expand(heading: Element) {
if (!heading) return; if (!heading) return;
const content = heading.nextElementSibling;
// if there is no content, nothing to expand
if (!content) return;
// toggle expanded and aria-expanded attributes // toggle expanded and aria-expanded attributes
if (heading.hasAttribute('content-expanded')) { if (content.hasAttribute('expanded')) {
close(heading); content.removeAttribute('expanded');
content.setAttribute('aria-expanded', 'false');
} else { } else {
if (this.openOneOnly) this._closeAll(); content.setAttribute('expanded', '');
open(heading); content.setAttribute('aria-expanded', 'true');
} }
} }
private _closeAll(options: CloseAllOptions = {}): void {
const { exceptFirst = false } = options;
let els = [...this.children].filter(el => el.matches('[content-expanded]')) as HTMLElement[];
if (exceptFirst) {
els = els.slice(1);
}
for (const el of els) close(el);
}
// children of multi-panel should always be even number (heading/content pair) // children of multi-panel should always be even number (heading/content pair)
private _childrenChange() { private _childrenChange() {
let preserveTabIndex = false; let preserveTabIndex : boolean = false;
let heading = this.firstElementChild; let heading = this.firstElementChild;
while (heading) { while (heading) {
@@ -192,23 +123,31 @@ export default class MultiPanel extends HTMLElement {
// it means it has odd number of elements. log error and set heading to end the loop. // it means it has odd number of elements. log error and set heading to end the loop.
if (!content) { if (!content) {
console.error('<multi-panel> requires an even number of element children.'); console.error('<multi-panel> requires an even number of element children.');
break; heading = null;
continue;
} }
// When odd number of elements were inserted in the middle, // When odd number of elements were inserted in the middle,
// what was heading before may become content after the insertion. // what was heading before may become content after the insertion.
// Remove classes and attributes to prepare for this change. // Remove classes and attributes to prepare for this change.
heading.classList.remove(style.panelContent); heading.classList.remove('panel-content');
content.classList.remove(style.panelHeading);
if (content.classList.contains('panel-heading')) {
content.classList.remove('panel-heading');
}
if (heading.hasAttribute('expanded') && heading.hasAttribute('aria-expanded')) {
heading.removeAttribute('expanded');
heading.removeAttribute('aria-expanded'); heading.removeAttribute('aria-expanded');
heading.removeAttribute('content-expanded'); }
// If appreciable, remove tabindex from content which used to be header. // If appreciable, remove tabindex from content which used to be header.
if (content.hasAttribute('tabindex')) {
content.removeAttribute('tabindex'); content.removeAttribute('tabindex');
}
// Assign heading and content classes // Assign heading and content classes
heading.classList.add(style.panelHeading); heading.classList.add('panel-heading');
content.classList.add(style.panelContent); content.classList.add('panel-content');
// Assign ids and aria-X for heading/content pair. // Assign ids and aria-X for heading/content pair.
heading.id = `panel-heading-${randomId}`; heading.id = `panel-heading-${randomId}`;
@@ -224,13 +163,6 @@ export default class MultiPanel extends HTMLElement {
heading.setAttribute('tabindex', '-1'); heading.setAttribute('tabindex', '-1');
} }
// It's possible that the heading & content expanded attributes are now out of sync. Resync
// them using the heading as the source of truth.
content.setAttribute(
'aria-expanded',
heading.hasAttribute('content-expanded') ? 'true' : 'false',
);
// next sibling of content = next heading // next sibling of content = next heading
heading = content.nextElementSibling; heading = content.nextElementSibling;
} }
@@ -239,9 +171,6 @@ export default class MultiPanel extends HTMLElement {
if (!preserveTabIndex && this.firstElementChild) { if (!preserveTabIndex && this.firstElementChild) {
this.firstElementChild.setAttribute('tabindex', '0'); this.firstElementChild.setAttribute('tabindex', '0');
} }
// In case we're openOneOnly, and an additional open item has been added:
if (this.openOneOnly) this._closeAll({ exceptFirst: true });
} }
// returns heading that is before currently selected one. // returns heading that is before currently selected one.
@@ -279,7 +208,7 @@ export default class MultiPanel extends HTMLElement {
private _lastHeading() { private _lastHeading() {
// if the last element is heading, return last element // if the last element is heading, return last element
const lastEl = this.lastElementChild as HTMLElement; const lastEl = this.lastElementChild as HTMLElement;
if (lastEl && lastEl.classList.contains(style.panelHeading)) { if (lastEl && lastEl.classList.contains('panel-heading')) {
return lastEl; return lastEl;
} }
// otherwise return 2nd from the last // otherwise return 2nd from the last
@@ -288,21 +217,6 @@ export default class MultiPanel extends HTMLElement {
return lastContent.previousElementSibling as HTMLElement; return lastContent.previousElementSibling as HTMLElement;
} }
} }
/**
* If true, only one panel can be open at once. When one opens, others close.
*/
get openOneOnly() {
return this.hasAttribute(openOneOnlyAttr);
}
set openOneOnly(val: boolean) {
if (val) {
this.setAttribute(openOneOnlyAttr, '');
} else {
this.removeAttribute(openOneOnlyAttr);
}
}
} }
customElements.define('multi-panel', MultiPanel); customElements.define('multi-panel', MultiPanel);

View File

@@ -0,0 +1,11 @@
multi-panel > .panel-heading {
background:gray;
}
multi-panel > .panel-content {
height:0px;
overflow:scroll;
transition: height 1s;
}
multi-panel > .panel-content[expanded] {
height:auto;
}

View File

@@ -1,55 +0,0 @@
import { h, Component } from 'preact';
import * as style from './style.scss';
import RangeInputElement from '../../custom-els/RangeInput';
import '../../custom-els/RangeInput';
import { linkRef, bind } from '../../lib/initial-util';
interface Props extends JSX.HTMLAttributes {}
interface State {}
export default class Range extends Component<Props, State> {
rangeWc?: RangeInputElement;
@bind
private onTextInput(event: Event) {
const input = event.target as HTMLInputElement;
this.rangeWc!.value = input.value;
const { onInput } = this.props;
if (onInput) onInput(event);
}
render(props: Props) {
const {
children,
...otherProps
} = props;
const {
value, min, max, step,
} = props;
return (
<label class={style.range}>
<span class={style.labelText}>{children}</span>
{/* On interaction, Safari gives focus to the first element in the label, so the
<range-input> is deliberately first. */}
<div class={style.rangeWcContainer}>
<range-input
ref={linkRef(this, 'rangeWc')}
class={style.rangeWc}
{...otherProps}
/>
</div>
<input
type="number"
class={style.textInput}
value={value}
min={min}
max={max}
step={step}
onInput={this.onTextInput}
/>
</label>
);
}
}

View File

@@ -1,55 +0,0 @@
.range {
position: relative;
z-index: 0;
display: grid;
grid-template-columns: 1fr auto;
}
.label-text {
color: #fff; /* TEMP */
}
.range-wc-container {
position: relative;
z-index: 1;
grid-row: 2 / 3;
grid-column: 1 / 3;
}
.range-wc {
width: 100%;
}
.text-input {
grid-row: 1 / 2;
grid-column: 2 / 3;
text-align: right;
background: transparent;
color: inherit;
font: inherit;
border: none;
padding: 2px 5px;
box-sizing: border-box;
text-decoration: underline;
text-decoration-style: dotted;
text-underline-position: under;
width: 48px;
position: relative;
left: 5px;
&:focus {
background: #fff;
color: #000;
}
// Remove the number controls
-moz-appearance: textfield;
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-moz-appearance: none;
-webkit-appearance: none;
margin: 0;
}
}

View File

@@ -1,41 +0,0 @@
import { h, Component } from 'preact';
import * as prettyBytes from 'pretty-bytes';
import * as style from './style.scss';
interface Props {
blob: Blob;
compareTo?: Blob;
}
interface State {}
export default class FileSize extends Component<Props, State> {
render({ blob, compareTo }: Props) {
let comparison: JSX.Element | undefined;
if (compareTo) {
const delta = blob.size / compareTo.size;
if (delta > 1) {
const percent = Math.round((delta - 1) * 100) + '%';
comparison = (
<span class={`${style.sizeDelta} ${style.sizeIncrease}`}>
{percent === '0%' ? 'slightly' : percent} bigger
</span>
);
} else if (delta < 1) {
const percent = Math.round((1 - delta) * 100) + '%';
comparison = (
<span class={`${style.sizeDelta} ${style.sizeDecrease}`}>
{percent === '0%' ? 'slightly' : percent} smaller
</span>
);
} else {
comparison = (
<span class={style.sizeDelta}>no change</span>
);
}
}
return <span>{prettyBytes(blob.size)} {comparison}</span>;
}
}

View File

@@ -1,121 +0,0 @@
import { h, Component, ComponentChildren, ComponentChild } from 'preact';
import * as style from './style.scss';
import FileSize from './FileSize';
import { DownloadIcon, CopyAcrossIcon, CopyAcrossIconProps } from '../../lib/icons';
import '../custom-els/LoadingSpinner';
import { SourceImage } from '../compress';
import { Fileish, bind } from '../../lib/initial-util';
interface Props {
loading: boolean;
source?: SourceImage;
imageFile?: Fileish;
downloadUrl?: string;
children: ComponentChildren;
copyDirection: CopyAcrossIconProps['copyDirection'];
buttonPosition: keyof typeof buttonPositionClass;
onCopyToOtherClick(): void;
}
interface State {
showLoadingState: boolean;
}
const buttonPositionClass = {
'stack-right': style.stackRight,
'download-right': style.downloadRight,
'download-left': style.downloadLeft,
};
const loadingReactionDelay = 500;
export default class Results extends Component<Props, State> {
state: State = {
showLoadingState: false,
};
/** The timeout ID between entering the loading state, and changing UI */
private loadingTimeoutId: number = 0;
componentDidUpdate(prevProps: Props, prevState: State) {
if (prevProps.loading && !this.props.loading) {
// Just stopped loading
clearTimeout(this.loadingTimeoutId);
this.setState({ showLoadingState: false });
} else if (!prevProps.loading && this.props.loading) {
// Just started loading
this.loadingTimeoutId = self.setTimeout(
() => this.setState({ showLoadingState: true }),
loadingReactionDelay,
);
}
}
@bind
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 (
<div class={`${style.results} ${buttonPositionClass[buttonPosition]}`}>
<div class={style.resultData}>
{(children as ComponentChild[])[0]
? <div class={style.resultTitle}>{children}</div>
: null
}
{!imageFile || showLoadingState ? 'Working…' :
<FileSize
blob={imageFile}
compareTo={(source && imageFile !== source.file) ? source.file : undefined}
/>
}
</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}>
{(downloadUrl && imageFile) && (
<a
class={`${style.downloadLink} ${showLoadingState ? style.downloadLinkDisable : ''}`}
href={downloadUrl}
download={imageFile.name}
title="Download"
onClick={this.onDownload}
>
<DownloadIcon class={style.downloadIcon} />
</a>
)}
{showLoadingState && <loading-spinner class={style.spinner} />}
</div>
</div>
);
}
}

View File

@@ -1,131 +0,0 @@
@keyframes action-enter {
from {
transform: rotate(-90deg);
opacity: 0;
animation-timing-function: ease-out;
}
}
@keyframes action-leave {
from {
transform: rotate(0deg);
opacity: 1;
animation-timing-function: ease-out;
}
}
.results {
display: grid;
grid-template-columns: [text] 1fr [copy-button] auto [download-button] auto;
background: rgba(0, 0, 0, 0.9);
font-size: 1rem;
@media (min-width: 400px) {
font-size: 1.2rem;
}
@media (min-width: 600px) {
font-size: 1.4rem;
}
&:focus {
outline: none;
}
}
.result-data {
grid-row: 1;
grid-column: text;
display: flex;
align-items: center;
padding: 0 10px;
white-space: nowrap;
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 {
display: flex;
align-items: center;
margin-right: 0.4em;
}
.size-delta {
font-size: 0.8em;
font-style: italic;
position: relative;
top: -1px;
margin-left: 0.3em;
}
.size-increase {
color: #e35050;
}
.size-decrease {
color: #50e3c2;
}
.download {
grid-row: 1;
grid-column: download-button;
background: #34B9EB;
--size: 38px;
width: var(--size);
height: var(--size);
display: grid;
align-items: center;
justify-items: center;
}
.download-link {
animation: action-enter 0.2s;
grid-area: 1/1;
}
.download-link-disable {
pointer-events: none;
opacity: 0;
transform: rotate(90deg);
animation: action-leave 0.2s;
}
.download-icon,
.copy-icon {
color: #fff;
display: block;
--size: 24px;
width: var(--size);
height: var(--size);
padding: 7px;
filter: drop-shadow(0 1px 0 rgba(0, 0, 0, 0.7));
}
.spinner {
--color: #fff;
--delay: 0;
--size: 22px;
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,20 +0,0 @@
import { h, Component } from 'preact';
import * as style from './style.scss';
interface Props extends JSX.HTMLAttributes {
large?: boolean;
}
interface State {}
export default class Select extends Component<Props, State> {
render(props: Props) {
const { large, ...otherProps } = props;
return (
<div class={style.select}>
<select class={`${style.nativeSelect} ${large ? style.large : ''}`} {...otherProps}/>
<svg class={style.arrow} viewBox="0 0 10 5"><path d="M0 0l5 5 5-5z"/></svg>
</div>
);
}
}

View File

@@ -1,33 +0,0 @@
.select {
position: relative;
}
.native-select {
background: #2f2f2f;
border-radius: 4px;
font: inherit;
padding: 4px 25px 4px 10px;
-webkit-appearance: none;
-moz-appearance: none;
border: none;
color: #fff;
width: 100%;
}
.arrow {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
fill: #fff;
width: 10px;
}
.large {
padding: 10px 35px 10px 10px;
background: #151515;
& .arrow {
right: 13px;
}
}

View File

@@ -1,20 +1,15 @@
import PointerTracker from 'pointer-tracker';
import { bind } from '../../lib/initial-util'; import { bind } from '../../lib/initial-util';
import * as style from './styles.css'; import './styles.css';
const RETARGETED_EVENTS = ['focus', 'blur']; const RETARGETED_EVENTS = ['focus', 'blur'];
const UPDATE_EVENTS = ['input', 'change']; const UPDATE_EVENTS = ['input', 'change'];
const REFLECTED_PROPERTIES = ['name', 'min', 'max', 'step', 'value', 'disabled']; const REFLECTED_PROPERTIES = ['name', 'min', 'max', 'step', 'value', 'disabled'];
const REFLECTED_ATTRIBUTES = ['name', 'min', 'max', 'step', 'value', 'disabled']; const REFLECTED_ATTRIBUTES = ['name', 'min', 'max', 'step', 'value', 'disabled'];
function getPrescision(value: string): number {
const afterDecimal = value.split('.')[1];
return afterDecimal ? afterDecimal.length : 0;
}
class RangeInputElement extends HTMLElement { class RangeInputElement extends HTMLElement {
private _input: HTMLInputElement; private _input = document.createElement('input');
private _valueDisplay?: HTMLDivElement; private _valueDisplayWrapper = document.createElement('div');
private _valueDisplay = document.createElement('span');
private _ignoreChange = false; private _ignoreChange = false;
static get observedAttributes() { static get observedAttributes() {
@@ -23,20 +18,7 @@ class RangeInputElement extends HTMLElement {
constructor() { constructor() {
super(); super();
this._input = document.createElement('input');
this._input.type = 'range'; this._input.type = 'range';
this._input.className = style.input;
const tracker = new PointerTracker(this._input, {
start: (): boolean => {
if (tracker.currentPointers.length !== 0) return false;
this._input.classList.add(style.touchActive);
return true;
},
end: () => {
this._input.classList.remove(style.touchActive);
},
});
for (const event of RETARGETED_EVENTS) { for (const event of RETARGETED_EVENTS) {
this._input.addEventListener(event, this._retargetEvent, true); this._input.addEventListener(event, this._retargetEvent, true);
@@ -47,20 +29,6 @@ class RangeInputElement extends HTMLElement {
} }
} }
connectedCallback() {
if (this.contains(this._input)) return;
this.innerHTML =
`<div class="${style.thumbWrapper}">` +
`<div class="${style.thumb}"></div>` +
`<div class="${style.valueDisplay}"></div>` +
'</div>';
this.insertBefore(this._input, this.firstChild);
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 {
return this.getAttribute('label-precision') || ''; return this.getAttribute('label-precision') || '';
} }
@@ -69,6 +37,14 @@ class RangeInputElement extends HTMLElement {
this.setAttribute('label-precision', precision); this.setAttribute('label-precision', precision);
} }
connectedCallback() {
if (this._input.parentNode !== this) {
this.appendChild(this._input);
this._valueDisplayWrapper.appendChild(this._valueDisplay);
this.appendChild(this._valueDisplayWrapper);
}
}
attributeChangedCallback(name: string, oldValue: string, newValue: string | null) { attributeChangedCallback(name: string, oldValue: string, newValue: string | null) {
if (this._ignoreChange) return; if (this._ignoreChange) return;
if (newValue === null) { if (newValue === null) {
@@ -89,15 +65,15 @@ class RangeInputElement extends HTMLElement {
@bind @bind
private _update() { private _update() {
const value = Number(this.value) || 0; const value = parseFloat(this.value || '0');
const min = Number(this.min) || 0; const min = parseFloat(this.min || '0');
const max = Number(this.max) || 100; const max = parseFloat(this.max || '100');
const labelPrecision = Number(this.labelPrecision) || getPrescision(this.step) || 0; const labelPrecision = parseFloat(this.labelPrecision || '0');
const percent = 100 * (value - min) / (max - min); const percent = 100 * (value - min) / (max - min);
const displayValue = labelPrecision ? value.toFixed(labelPrecision) : const displayValue = labelPrecision ? value.toPrecision(labelPrecision) :
Math.round(value).toString(); Math.round(value).toString();
this._valueDisplay!.textContent = displayValue; this._valueDisplay.textContent = displayValue;
this.style.setProperty('--value-percent', percent + '%'); this.style.setProperty('--value-percent', percent + '%');
this.style.setProperty('--value-width', '' + displayValue.length); this.style.setProperty('--value-width', '' + displayValue.length);
} }

View File

@@ -1,5 +1,9 @@
declare namespace JSX { declare namespace JSX {
interface RangeInputAttributes extends HTMLAttributes {
reversed?: boolean;
}
interface IntrinsicElements { interface IntrinsicElements {
'range-input': HTMLAttributes; 'range-input': RangeInputAttributes;
} }
} }

View File

@@ -14,6 +14,41 @@ range-input[disabled] {
filter: grayscale(1); filter: grayscale(1);
} }
/* Reversed Variant */
range-input[reversed] input,
range-input[reversed]::before,
range-input[reversed] > div {
transform: scaleX(-1);
}
range-input[reversed] > div > span {
transform: scaleX(-1) scale(.2);
}
range-input[reversed] input:focus + div span {
transform: scaleX(-1) scale(1);
}
range-input input {
position: relative;
flex: 1;
vertical-align: middle;
width: 100%;
padding: 0 !important;
margin: 0 !important;
background: none;
appearance: none;
-webkit-appearance: none;
outline: none;
}
range-input input::-webkit-slider-runnable-track,
range-input input::-moz-range-track,
range-input input::-ms-track {
appearance: none;
-ms-appearance: none;
-moz-appearance: none;
-webkit-appearance: none;
}
range-input::before { range-input::before {
content: ''; content: '';
display: block; display: block;
@@ -22,33 +57,32 @@ range-input::before {
left: 0; left: 0;
width: 100%; width: 100%;
height: 2px; height: 2px;
outline: none;
border: none;
background: #eee;
border-radius: 1px; border-radius: 1px;
box-shadow: 0 -.5px 0 rgba(0,0,0,0.3), inset 0 .5px 0 rgba(255,255,255,0.2), 0 .5px 0 rgba(255,255,255,0.3); box-shadow: 0 -.5px 0 rgba(0,0,0,0.3), inset 0 .5px 0 rgba(255,255,255,0.2), 0 .5px 0 rgba(255,255,255,0.3);
background: linear-gradient(#34B9EB, #218ab1) 0/ var(--value-percent, 0%) 100% no-repeat #eee; background: linear-gradient(#34B9EB, #218ab1) 0/ var(--value-percent, 0%) 100% no-repeat #eee;
} }
.input { range-input input::-webkit-slider-thumb {
position: relative; appearance: none;
width: 100%; -webkit-appearance: none;
padding: 0; background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><circle cx="5" cy="5" r="1" fill="#5D509E" /></svg>') center no-repeat #34B9EB;
margin: 0; box-sizing: content-box;
opacity: 0;
}
.thumb {
pointer-events: none;
position: absolute;
bottom: 3px;
left: var(--value-percent, 0%);
margin-left: -6px;
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><circle cx="5" cy="5" r="1" fill="%235D509E" /></svg>') center no-repeat #34B9EB;
border-radius: 50%; border-radius: 50%;
width: 12px; width: 12px;
height: 12px; height: 12px;
border: none;
box-shadow: 0 0.5px 2px rgba(0,0,0,0.3); box-shadow: 0 0.5px 2px rgba(0,0,0,0.3);
outline: none;
} }
.thumb-wrapper { range-input input:focus::-webkit-slider-thumb {
box-shadow: 0 1px 3px rgba(0,0,0,0.5);
}
range-input > div {
position: absolute; position: absolute;
left: 6px; left: 6px;
right: 6px; right: 6px;
@@ -57,8 +91,8 @@ range-input::before {
overflow: visible; overflow: visible;
} }
.value-display { range-input > div > span {
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="32" height="62" fill="none"><path fill="%2334B9EB" d="M27.3 27.3C25 29.6 17 35.8 17 43v3c0 3 2.5 5 3.2 5.8a6 6 0 1 1-8.5 0C12.6 51 15 49 15 46v-3c0-7.2-8-13.4-10.3-15.7A16 16 0 0 1 16 0a16 16 0 0 1 11.3 27.3z"/><circle cx="16" cy="56" r="1" fill="%235D509E"/></svg>') top center no-repeat; background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="32" height="62" fill="none"><path fill="#34B9EB" d="M27.3 27.3C25 29.6 17 35.8 17 43v3c0 3 2.5 5 3.2 5.8a6 6 0 1 1-8.5 0C12.6 51 15 49 15 46v-3c0-7.2-8-13.4-10.3-15.7A16 16 0 0 1 16 0a16 16 0 0 1 11.3 27.3z"/><circle cx="16" cy="56" r="1" fill="#5D509E"/></svg>') top center no-repeat;
position: absolute; position: absolute;
box-sizing: border-box; box-sizing: border-box;
left: var(--value-percent, 0%); left: var(--value-percent, 0%);
@@ -77,18 +111,12 @@ range-input::before {
font-size: calc(100% - var(--value-width, 3) / 5 * .2em); font-size: calc(100% - var(--value-width, 3) / 5 * .2em);
text-overflow: clip; text-overflow: clip;
text-shadow: 0 -.5px 0 rgba(0,0,0,0.4); text-shadow: 0 -.5px 0 rgba(0,0,0,0.4);
transition: all 200ms ease; transition: transform 200ms ease, opacity 200ms ease;
transition-property: opacity, transform;
will-change: transform; will-change: transform;
pointer-events: none; pointer-events: none;
overflow: hidden; overflow: hidden;
} }
range-input input:focus + div span {
.touch-active + .thumb-wrapper .value-display {
opacity: 1; opacity: 1;
transform: scale(1); transform: scale(1);
} }
.touch-active + .thumb-wrapper .thumb {
box-shadow: 0 1px 3px rgba(0,0,0,0.5);
}

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 () {
require('./init-app.tsx');
}
if (!('customElements' in self)) { if (!('customElements' in self)) {
import( await import('@webcomponents/custom-elements');
/* webpackChunkName: "wc-polyfill" */
'@webcomponents/custom-elements',
).then(init);
} else {
init();
} }
if (typeof PRERENDER === 'undefined') { require('./init-app.tsx');
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,
);
} }
}
cancelTimer () {
if (this._closeTimer != null) clearTimeout(this._closeTimer);
}
show (parent: Element): Promise<SnackShowResult> {
if (this._showing) return Promise.resolve(this._result);
this._showing = true;
this.cancelTimer();
if (parent !== this._element.parentNode) {
parent.appendChild(this._element);
}
this._element.removeAttribute('aria-hidden');
this._closeTimer = setTimeout(this.hide.bind(this), this._options.timeout || DEFAULT_TIMEOUT);
return new Promise((resolve) => {
this._onremove.push(resolve);
}); });
}
return [el, result]; hide () {
if (!this._showing) return;
this._showing = false;
this.cancelTimer();
this._element.addEventListener('animationend', this.remove.bind(this));
this._element.setAttribute('aria-hidden', 'true');
}
remove () {
this.cancelTimer();
const parent = this._element.parentNode;
if (parent) parent.removeChild(this._element);
this._onremove.forEach(f => f(this._result));
this._onremove = [];
}
} }
export default class SnackBarElement extends HTMLElement { export default class SnackBarElement extends HTMLElement {
private _snackbars: [string, SnackOptions, (action: Promise<string>) => void][] = []; private _snackbars: Snack[] = [];
private _processingQueue = false; private _processingStack = false;
/** showSnackbar (options: SnackOptions): Promise<SnackShowResult> {
* Show a snackbar. Returns a promise for the name of the action clicked, or an empty string if no return new Promise((resolve) => {
* action is clicked. const snack = new Snack(options, resolve);
*/ this._snackbars.push(snack);
showSnackbar(message: string, options: SnackOptions = {}): Promise<string> { this._processStack();
return new Promise<string>((resolve) => {
this._snackbars.push([message, options, resolve]);
if (!this._processingQueue) this._processQueue();
}); });
} }
private async _processQueue() { private async _processStack () {
this._processingQueue = true; if (this._processingStack === true || this._snackbars.length === 0) return;
this._processingStack = true;
while (this._snackbars[0]) { await this._snackbars[0].show(this);
const [message, options, resolver] = this._snackbars[0];
const [el, result] = createSnack(message, options);
// Pass the result back to the original showSnackbar call.
resolver(result);
this.appendChild(el);
// Wait for the user to click an action, or for the snack to timeout.
await result;
// Transition the snack away.
el.setAttribute('aria-hidden', 'true');
await new Promise((resolve) => {
el.addEventListener('animationend', () => resolve());
});
el.remove();
this._snackbars.shift(); this._snackbars.shift();
} this._processingStack = false;
this._processStack();
this._processingQueue = false;
} }
} }

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

@@ -8,7 +8,7 @@ const Icon = (props: JSX.HTMLAttributes) => (
export const DownloadIcon = (props: JSX.HTMLAttributes) => ( export const DownloadIcon = (props: JSX.HTMLAttributes) => (
<Icon {...props}> <Icon {...props}>
<path d="M19 12v7H5v-7H3v7c0 1.1.9 2 2 2h14a2 2 0 0 0 2-2v-7h-2zm-6 .7l2.6-2.6 1.4 1.4-5 5-5-5 1.4-1.4 2.6 2.6V3h2z"/> <path d="M19.35 10.04A7.49 7.49 0 0 0 12 4C9.11 4 6.6 5.64 5.35 8.04A5.994 5.994 0 0 0 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM17 13l-5 5-5-5h3V9h4v4h3z" />
</Icon> </Icon>
); );
@@ -29,52 +29,3 @@ export const RemoveIcon = (props: JSX.HTMLAttributes) => (
<path d="M19 13H5v-2h14v2z"/> <path d="M19 13H5v-2h14v2z"/>
</Icon> </Icon>
); );
export const UncheckedIcon = (props: JSX.HTMLAttributes) => (
<Icon {...props}>
<path d="M21.3 2.7v18.6H2.7V2.7h18.6m0-2.7H2.7A2.7 2.7 0 0 0 0 2.7v18.6A2.7 2.7 0 0 0 2.7 24h18.6a2.7 2.7 0 0 0 2.7-2.7V2.7A2.7 2.7 0 0 0 21.3 0z"/>
</Icon>
);
export const CheckedIcon = (props: JSX.HTMLAttributes) => (
<Icon {...props}>
<path d="M21.3 0H2.7A2.7 2.7 0 0 0 0 2.7v18.6A2.7 2.7 0 0 0 2.7 24h18.6a2.7 2.7 0 0 0 2.7-2.7V2.7A2.7 2.7 0 0 0 21.3 0zm-12 18.7L2.7 12l1.8-1.9L9.3 15 19.5 4.8l1.8 1.9z"/>
</Icon>
);
export const ExpandIcon = (props: JSX.HTMLAttributes) => (
<Icon {...props}>
<path d="M16.6 8.6L12 13.2 7.4 8.6 6 10l6 6 6-6z"/>
</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,94 +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();
});
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);
} }
@@ -202,40 +203,25 @@ export function nativeResize(
/** /**
* @param field An HTMLInputElement, but the casting is done here to tidy up onChange. * @param field An HTMLInputElement, but the casting is done here to tidy up onChange.
* @param defaultVal Value to return if 'field' doesn't exist.
*/ */
export function inputFieldValueAsNumber(field: any, defaultVal: number = 0): number { export function inputFieldValueAsNumber(field: any): number {
if (!field) return defaultVal; return Number((field as HTMLInputElement).value);
return Number(inputFieldValue(field));
} }
/** /**
* @param field An HTMLInputElement, but the casting is done here to tidy up onChange. * @param field An HTMLInputElement, but the casting is done here to tidy up onChange.
* @param defaultVal Value to return if 'field' doesn't exist.
*/ */
export function inputFieldCheckedAsNumber(field: any, defaultVal: number = 0): number { export function inputFieldCheckedAsNumber(field: any): number {
if (!field) return defaultVal;
return Number(inputFieldChecked(field)); return Number(inputFieldChecked(field));
} }
/** /**
* @param field An HTMLInputElement, but the casting is done here to tidy up onChange. * @param field An HTMLInputElement, but the casting is done here to tidy up onChange.
* @param defaultVal Value to return if 'field' doesn't exist.
*/ */
export function inputFieldChecked(field: any, defaultVal: boolean = false): boolean { export function inputFieldChecked(field: any): boolean {
if (!field) return defaultVal;
return (field as HTMLInputElement).checked; return (field as HTMLInputElement).checked;
} }
/**
* @param field An HTMLInputElement, but the casting is done here to tidy up onChange.
* @param defaultVal Value to return if 'field' doesn't exist.
*/
export function inputFieldValue(field: any, defaultVal: string = ''): string {
if (!field) return defaultVal;
return (field as HTMLInputElement).value;
}
/** /**
* Creates a promise that resolves when the user types the konami code. * Creates a promise that resolves when the user types the konami code.
*/ */
@@ -257,43 +243,3 @@ export function konami(): Promise<void> {
window.addEventListener('keydown', listener); window.addEventListener('keydown', listener);
}); });
} }
interface TransitionOptions {
from?: number;
to?: number;
duration?: number;
easing?: string;
}
export async function transitionHeight(el: HTMLElement, opts: TransitionOptions): Promise<void> {
const {
from = el.getBoundingClientRect().height,
to = el.getBoundingClientRect().height,
duration = 1000,
easing = 'ease-in-out',
} = opts;
if (from === to || duration === 0) {
el.style.height = to + 'px';
return;
}
el.style.height = from + 'px';
// Force a style calc so the browser picks up the start value.
getComputedStyle(el).transform;
el.style.transition = `height ${duration}ms ${easing}`;
el.style.height = to + 'px';
return new Promise<void>((resolve) => {
const listener = (event: Event) => {
if (event.target !== el) return;
el.style.transition = '';
el.removeEventListener('transitionend', listener);
el.removeEventListener('transitioncancel', listener);
resolve();
};
el.addEventListener('transitionend', listener);
el.addEventListener('transitioncancel', listener);
});
}

View File

@@ -5,12 +5,12 @@
"display": "standalone", "display": "standalone",
"orientation": "portrait", "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,19 +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[];
};
interface Window {
ga: typeof ga;
}

View File

@@ -1,7 +1,12 @@
/*
Note: These styles are temporary. They will be replaced before going live.
*/
@import './reset.scss'; @import './reset.scss';
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,3 +1,7 @@
/*
Note: These styles are temporary. They will be replaced before going live.
*/
button, a, img, input, select, textarea { button, a, img, input, select, textarea {
-webkit-tap-highlight-color: rgba(0,0,0,0); -webkit-tap-highlight-color: rgba(0,0,0,0);
} }

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,6 +12,5 @@
"jsxFactory": "h", "jsxFactory": "h",
"allowJs": false, "allowJs": false,
"baseUrl": "." "baseUrl": "."
}, }
"exclude": ["src/sw/**/*"]
} }

View File

@@ -8,40 +8,32 @@ 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');
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 = 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/lib'),
]; ];
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'
@@ -114,7 +106,7 @@ module.exports = function (_, env) {
loader: 'typings-for-css-modules-loader', 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,
@@ -144,6 +136,12 @@ module.exports = 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$/,
@@ -154,17 +152,11 @@ module.exports = 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]',
},
} }
] ]
}, },
@@ -201,16 +193,13 @@ module.exports = 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
}
} }
}), }),
@@ -226,7 +215,7 @@ module.exports = 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,
@@ -235,29 +224,32 @@ module.exports = 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' },
@@ -269,22 +261,6 @@ module.exports = 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.