Compare commits

..

23 Commits

Author SHA1 Message Date
Mathias Bynens
68bb2edb39 Fix typos 🙈 2018-11-11 17:43:20 -08:00
Mariko Kosaka
9c85618aff Merge pull request #263 from GoogleChromeLabs/analytics-privacy
Adding readme, privacy section, reducing resolution of analytics data.
2018-11-11 06:07:52 -08:00
Jake Archibald
aebeff8b4c Adding readme, privacy section, reducing resolution of analytics data. 2018-11-11 05:11:28 -08:00
Jake Archibald
8d63125b13 Resetting pinch zoom (#261)
* Resetting pinch zoom

* Bumping version
2018-11-11 04:28:39 -08:00
Jake Archibald
2ca97ef586 Not entirely sure why this causes dev to fail, but this fixes it. 2018-11-10 16:10:25 -08:00
Jake Archibald
a1a00f0bfb Preload test (#262)
* Preload test

* Don't prerender analytics

* Version bump
2018-11-10 08:20:13 -08:00
Jake Archibald
6870b135b7 I'm calling this 1.0 2018-11-09 16:01:24 -08:00
Jake Archibald
a0f1379feb Adding manifest to headers 2018-11-09 12:08:55 -08:00
Jake Archibald
9b17322478 Removing old file from serviceworker 2018-11-09 11:11:34 -08:00
Surma
f562bad286 Add analytics script (fixes #174) (#245) 2018-11-09 10:53:10 -08:00
Jason Miller
6994cc3d15 _headers & _redirects generation (#240)
* Generate `_headers` and `_redirects` by passing assets through ejs templates.

* PR feedback

* Excluding service worker stuff from prerender

* Build SW in dev

* Let's give this a try

* lol

* Is this how it works?
2018-11-09 10:49:01 -08:00
Jake Archibald
9b572f9541 Ta-da. Back button. (#254) 2018-11-09 09:13:32 -08:00
Jake Archibald
71f893cb44 Enhanced offline (#249)
* Notification of updates & reloading

* Using version in service worker & allowing version to appear elsewhere

* Stupid file

* Ditching changelog for now. Using package json.

* Ugh.
2018-11-09 09:13:14 -08:00
Jake Archibald
6b76ea0a6f Update file drop (#253) 2018-11-09 08:53:36 -08:00
Jason Miller
7616d33883 Startup optimizations (#226)
* Startup optimisations

* I hate this file

* Inline main script

* Reverting change to do a fairer perf comparison

* Inlining again. Weeeeee!

* Lockfile
2018-11-09 16:01:02 +00:00
Jake Archibald
3c757bb2b2 Prevent browser pinch-zoom (#247) 2018-11-09 15:58:15 +00:00
Jake Archibald
a502df80ba Prevent logo taking over on smaller screens. (#250)
* Prevent logo taking over on smaller screens.

* I hate this file
2018-11-09 00:02:23 +00:00
Jake Archibald
921268ec58 Addressing nits from service worker PR. 2018-11-08 12:11:29 +00:00
Jason Miller
7d42d4f973 Add a serviceworker (#234)
* Add a serviceworker

* rename + fix random extra character

* Fixing worker typings

* Fixing types properly this time.

* Once of those rare cases where this matters.

* Naming the things.

* Move registration to the app (so we can use snackbar later)

* Moving SW plugin later so it picks up things like HTML

* MVP service worker

* Two stage-service worker

* Fix prerendering by conditionally awaiting Custom Elements polyfill.

* Fix icon 404's

* add doc comment to autoswplugin

* Fix type
2018-11-08 12:02:05 +00:00
Jake Archibald
e4e130c5d6 Mark private function as private 2018-11-08 11:21:57 +00:00
Jake Archibald
bcf7a63118 Android tablet bugs (#246)
* Prevent two-up being lost under options. Fixes #241.

* Working around some glitching when page was scrolled.

* Prevent software keyboard popping up again in Android Chrome.
2018-11-07 08:59:11 +00:00
Jake Archibald
66aac12db7 Caught a bit of repetition in our utils (#242)
Caught a bit of repetition in our utils
2018-11-06 14:19:48 +00:00
Jake Archibald
59cd1f8930 Splitting PointerTracker into its own project (#238) 2018-11-06 14:19:24 +00:00
45 changed files with 3285 additions and 299 deletions

View File

@@ -1,5 +1,32 @@
# Squoosh! # Squoosh!
Squoosh will be an image compression web app that allows you to dive into the Squoosh is an image compression web app that allows you to dive into the advanced options provided
advanced options provided by various image compressors. 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
```

18
_headers.ejs Normal file
View File

@@ -0,0 +1,18 @@
# 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

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

158
config/auto-sw-plugin.js Normal file
View File

@@ -0,0 +1,158 @@
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,4 +1,5 @@
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;
@@ -11,6 +12,9 @@ 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' {

2641
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": "0.0.0", "version": "1.0.2",
"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",
@@ -19,6 +19,7 @@
"@types/pretty-bytes": "^5.1.0", "@types/pretty-bytes": "^5.1.0",
"@types/webassembly-js-api": "0.0.1", "@types/webassembly-js-api": "0.0.1",
"@webcomponents/custom-elements": "^1.2.1", "@webcomponents/custom-elements": "^1.2.1",
"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",
@@ -34,20 +35,25 @@
"clean-webpack-plugin": "^0.1.19", "clean-webpack-plugin": "^0.1.19",
"comlink": "^3.0.3", "comlink": "^3.0.3",
"copy-webpack-plugin": "^4.5.3", "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.7", "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.1.2",
"idb-keyval": "^3.1.0",
"if-env": "^1.0.4", "if-env": "^1.0.4",
"linkstate": "^1.1.1", "linkstate": "^1.1.1",
"loader-utils": "^1.1.0", "loader-utils": "^1.1.0",
"mini-css-extract-plugin": "^0.3.0", "pointer-tracker": "^2.0.3",
"minimatch": "^3.0.4",
"mini-css-extract-plugin": "^0.4.4",
"node-sass": "^4.9.4", "node-sass": "^4.9.4",
"optimize-css-assets-webpack-plugin": "^4.0.3", "optimize-css-assets-webpack-plugin": "^4.0.3",
"pointer-tracker": "^2.0.3",
"preact": "^8.3.1", "preact": "^8.3.1",
"prerender-loader": "^1.2.0",
"pretty-bytes": "^5.1.0", "pretty-bytes": "^5.1.0",
"progress-bar-webpack-plugin": "^1.11.0", "progress-bar-webpack-plugin": "^1.11.0",
"raw-loader": "^0.5.1", "raw-loader": "^0.5.1",
@@ -62,11 +68,11 @@
"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",
"webpack": "=4.19.1", "url-loader": "^1.1.2",
"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",
"worker-plugin": "^1.1.1" "worker-plugin": "^1.1.1"
} }
} }

View File

@@ -1,9 +1,8 @@
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';
// tslint:disable-next-line:max-line-length Its a data URL. Whatcha gonna do? const nativeWebPSupported = canDecodeImage(webpDataUrl);
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

@@ -7,31 +7,46 @@ 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('./mozjpeg/encoder'); const { encode } = await import(
/* 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('./imagequant/processor'); const { process } = await import(
/* 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('./optipng/encoder'); const { compress } = await import(
/* 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('./webp/encoder'); const { encode } = await import(
/* 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('./webp/decoder'); const { decode } = await import(
/* webpackChunkName: "process-webp-dec" */
'./webp/decoder',
);
return decode(data); return decode(data);
} }

View File

@@ -61,7 +61,10 @@ 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('./processor-worker.ts', { type: 'module' }) as Worker; this._worker = new 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;
} }

BIN
src/codecs/tiny.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 B

View File

@@ -12,6 +12,15 @@ 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;
@@ -36,12 +45,14 @@ export default class App extends Component<Props, State> {
constructor() { constructor() {
super(); super();
import('../compress').then((module) => { compressPromise.then((module) => {
this.setState({ Compress: module.default }); this.setState({ Compress: module.default });
}).catch(() => { }).catch(() => {
this.showSnack('Failed to load app'); this.showSnack('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);
@@ -71,6 +82,11 @@ export default class App extends Component<Props, State> {
return this.snackbar.showSnackbar(message, options); return this.snackbar.showSnackbar(message, options);
} }
@bind
private onBack() {
this.setState({ file: undefined });
}
render({}: Props, { file, Compress }: State) { render({}: Props, { file, Compress }: State) {
return ( return (
<div id="app" class={style.app}> <div id="app" class={style.app}>
@@ -78,7 +94,7 @@ export default class App extends Component<Props, State> {
{(!file) {(!file)
? <Intro onFile={this.onIntroPickFile} showSnack={this.showSnack} /> ? <Intro onFile={this.onIntroPickFile} showSnack={this.showSnack} />
: (Compress) : (Compress)
? <Compress file={file} showSnack={this.showSnack} /> ? <Compress file={file} showSnack={this.showSnack} onBack={this.onBack} />
: <loading-spinner class={style.appLoader}/> : <loading-spinner class={style.appLoader}/>
} }
<snack-bar ref={linkRef(this, 'snackbar')} /> <snack-bar ref={linkRef(this, 'snackbar')} />

View File

@@ -54,4 +54,5 @@ $horizontalPadding: 15px;
.options-scroller { .options-scroller {
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
-webkit-overflow-scrolling: touch;
} }

View File

@@ -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.
*/ */
_updateTransform(scale: number, x: number, y: number, allowChangeEvent: boolean) { private _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

@@ -70,12 +70,13 @@ 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

@@ -5,7 +5,7 @@ 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 } from '../../lib/icons'; import { ToggleIcon, AddIcon, RemoveIcon, BackIcon } from '../../lib/icons';
import { twoUpHandle } from './custom-els/TwoUp/styles.css'; import { twoUpHandle } from './custom-els/TwoUp/styles.css';
interface Props { interface Props {
@@ -15,6 +15,7 @@ interface Props {
rightCompressed?: ImageData; rightCompressed?: ImageData;
leftImgContain: boolean; leftImgContain: boolean;
rightImgContain: boolean; rightImgContain: boolean;
onBack: () => void;
} }
interface State { interface State {
@@ -47,6 +48,15 @@ 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);
} }
@@ -177,10 +187,21 @@ 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 }: Props, { mobileView, leftImgContain, rightImgContain, originalImage, onBack }: Props,
{ scale, editingScale, altBackground }: State, { scale, editingScale, altBackground }: State,
) { ) {
const leftDraw = this.leftDrawable(); const leftDraw = this.leftDrawable();
@@ -232,6 +253,12 @@ 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

@@ -38,7 +38,7 @@
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
padding: 9px; padding: 9px 84px;
overflow: hidden; overflow: hidden;
flex-wrap: wrap; flex-wrap: wrap;
contain: content; contain: content;
@@ -50,6 +50,7 @@
} }
@media (min-width: 860px) { @media (min-width: 860px) {
padding: 9px;
top: auto; top: auto;
left: 320px; left: 320px;
right: 320px; right: 320px;
@@ -78,14 +79,19 @@
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);
@@ -134,3 +140,10 @@
// https://bugs.chromium.org/p/chromium/issues/detail?id=870222#c10 // https://bugs.chromium.org/p/chromium/issues/detail?id=870222#c10
will-change: auto; will-change: auto;
} }
.back {
position: absolute;
top: 0;
left: 0;
padding: 9px;
}

View File

@@ -60,6 +60,7 @@ interface EncodedImage {
interface Props { interface Props {
file: File | Fileish; file: File | Fileish;
showSnack: SnackBarElement['showSnackbar']; showSnack: SnackBarElement['showSnackbar'];
onBack: () => void;
} }
interface State { interface State {
@@ -195,6 +196,8 @@ 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
@@ -413,7 +416,7 @@ export default class Compress extends Component<Props, State> {
this.setState({ images }); this.setState({ images });
} }
render({ }: Props, { loading, images, source, mobileView }: State) { render({ onBack }: Props, { loading, images, source, mobileView }: 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);
@@ -458,6 +461,7 @@ export default class Compress extends Component<Props, State> {
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 {mobileView
? ( ? (

View File

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

View File

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 2.8 MiB

View File

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

Before

Width:  |  Height:  |  Size: 2.7 MiB

After

Width:  |  Height:  |  Size: 2.7 MiB

View File

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -4,13 +4,13 @@ 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/large-photo.jpg'; import largePhoto from './imgs/demos/demo-large-photo.jpg';
import artwork from './imgs/demos/artwork.jpg'; import artwork from './imgs/demos/demo-artwork.jpg';
import deviceScreen from './imgs/demos/device-screen.png'; import deviceScreen from './imgs/demos/demo-device-screen.png';
import largePhotoIcon from './imgs/demos/large-photo-icon.jpg'; import largePhotoIcon from './imgs/demos/icon-demo-large-photo.jpg';
import artworkIcon from './imgs/demos/artwork-icon.jpg'; import artworkIcon from './imgs/demos/icon-demo-artwork.jpg';
import deviceScreenIcon from './imgs/demos/device-screen-icon.jpg'; import deviceScreenIcon from './imgs/demos/icon-demo-device-screen.jpg';
import logoIcon from './imgs/demos/logo-icon.png'; import logoIcon from './imgs/demos/icon-demo-logo.png';
import * as style from './style.scss'; import * as style from './style.scss';
import SnackBarElement from '../../lib/SnackBar'; import SnackBarElement from '../../lib/SnackBar';
@@ -90,7 +90,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" /> <img src={logo} class={style.logo} alt="Squoosh" decoding="async" />
</div> </div>
</div> </div>
<p class={style.openImageGuide}> <p class={style.openImageGuide}>
@@ -111,7 +111,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=""/> <img class={style.demoIcon} src={demo.iconUrl} alt="" decoding="async" />
{fetchingDemoIndex === i && {fetchingDemoIndex === i &&
<div class={style.demoLoading}> <div class={style.demoLoading}>
<loading-spinner class={style.demoLoadingSpinner}/> <loading-spinner class={style.demoLoadingSpinner}/>
@@ -129,6 +129,11 @@ 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,6 +2,7 @@
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,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') format('woff2'); src: url('data:font/woff2;base64,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') format('woff2');
} }
@@ -10,6 +11,7 @@
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');
} }
@@ -19,7 +21,6 @@
} }
.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;
@@ -29,6 +30,9 @@
-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 {
@@ -38,7 +42,7 @@
.logo-sizer { .logo-sizer {
width: 90%; width: 90%;
max-width: 480px; max-width: 52vh;
margin: 0 auto; margin: 0 auto;
} }
@@ -47,7 +51,7 @@
} }
.open-image-guide { .open-image-guide {
font: 300 11vw intro-text; font: 300 11vw intro-text, sans-serif;
margin-bottom: 0; margin-bottom: 0;
@media (min-width: 460px) { @media (min-width: 460px) {

View File

@@ -58,6 +58,21 @@ export default class Results extends Component<Props, State> {
this.props.onCopyToOtherClick(); 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( render(
{ source, imageFile, downloadUrl, children, copyDirection, buttonPosition }: Props, { source, imageFile, downloadUrl, children, copyDirection, buttonPosition }: Props,
{ showLoadingState }: State, { showLoadingState }: State,
@@ -93,6 +108,7 @@ export default class Results extends Component<Props, State> {
href={downloadUrl} href={downloadUrl}
download={imageFile.name} download={imageFile.name}
title="Download" title="Download"
onClick={this.onDownload}
> >
<DownloadIcon class={style.downloadIcon} /> <DownloadIcon class={style.downloadIcon} />
</a> </a>

View File

@@ -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"> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<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">
<meta name="theme-color" content="#673ab8"> <link rel="shortcut icon" href="/assets/favicon.ico">
<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,9 +1,25 @@
declare module '@webcomponents/custom-elements'; declare module '@webcomponents/custom-elements';
(async function () { function init() {
if (!('customElements' in self)) { require('./init-app.tsx');
await import('@webcomponents/custom-elements');
} }
require('./init-app.tsx'); if (!('customElements' in self)) {
})(); import(
/* webpackChunkName: "wc-polyfill" */
'@webcomponents/custom-elements',
).then(init);
} else {
init();
}
if (typeof PRERENDER === 'undefined') {
window.ga = window.ga || ((...args) => (ga.q = ga.q || []).push(args));
ga('create', 'UA-128752250-1', 'auto');
ga('set', 'transport', 'beacon');
ga('send', 'pageview');
// Load the GA script
const s = document.createElement('script');
s.src = 'https://www.google-analytics.com/analytics.js';
document.head!.appendChild(s);
}

View File

@@ -4,13 +4,13 @@ import './style';
import App from './components/App'; import App from './components/App';
// Find the outermost Element in our server-rendered HTML structure. // Find the outermost Element in our server-rendered HTML structure.
let root = document.querySelector('#app') || undefined; let root = document.getElementById('app_root') as Element;
// "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');
// In production, this entire condition is removed. if (process.env.NODE_ENV !== 'production') {
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

@@ -8,12 +8,9 @@ export interface SnackOptions {
function createSnack(message: string, options: SnackOptions): [Element, Promise<string>] { function createSnack(message: string, options: SnackOptions): [Element, Promise<string>] {
const { const {
timeout = 0, timeout = 0,
actions = [], actions = ['dismiss'],
} = options; } = options;
// Provide a default 'dismiss' action
if (!timeout && actions.length === 0) actions.push('dismiss');
const el = document.createElement('div'); const el = document.createElement('div');
el.className = style.snackbar; el.className = style.snackbar;
el.setAttribute('aria-live', 'assertive'); el.setAttribute('aria-live', 'assertive');

View File

@@ -48,6 +48,12 @@ export const ExpandIcon = (props: JSX.HTMLAttributes) => (
</Icon> </Icon>
); );
export const BackIcon = (props: JSX.HTMLAttributes) => (
<Icon {...props}>
<path d="M20 11H7.8l5.6-5.6L12 4l-8 8 8 8 1.4-1.4L7.8 13H20v-2z"/>
</Icon>
);
const copyAcrossRotations = { const copyAcrossRotations = {
up: 90, right: 180, down: -90, left: 0, up: 90, right: 180, down: -90, left: 0,
}; };

94
src/lib/offliner.ts Normal file
View File

@@ -0,0 +1,94 @@
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,16 +57,32 @@ 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(data: string): Promise<boolean> { export function canDecodeImage(url: string): Promise<boolean> {
return new Promise((resolve) => { return decodeImage(url).then(() => true, () => false);
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> {
@@ -108,24 +124,7 @@ export async function blobToImg(blob: Blob): Promise<HTMLImageElement> {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
try { try {
const img = new Image(); return await decodeImage(url);
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);
} }

View File

@@ -5,12 +5,12 @@
"display": "standalone", "display": "standalone",
"orientation": "portrait", "orientation": "portrait",
"background_color": "#fff", "background_color": "#fff",
"theme_color": "#673ab8", "theme_color": "#f78f21",
"icons": [ "icons": [
{ {
"src": "/assets/icon.png", "src": "/assets/icon-large.png",
"type": "image/png", "type": "image/png",
"sizes": "512x512" "sizes": "1024x1024"
} }
] ]
} }

View File

@@ -27,3 +27,19 @@ 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

@@ -2,7 +2,6 @@
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;

69
src/sw/index.ts Normal file
View File

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

1
src/sw/missing-types.d.ts vendored Normal file
View File

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

18
src/sw/tsconfig.json Normal file
View File

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

106
src/sw/util.ts Normal file
View File

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

View File

@@ -8,16 +8,20 @@ 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');
@@ -30,12 +34,14 @@ module.exports = function (_, env) {
return { return {
mode: isProd ? 'production' : 'development', mode: isProd ? 'production' : 'development',
entry: './src/index', entry: {
'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].chunk.[chunkhash:5].js', chunkFilename: '[name].[chunkhash:5].js',
path: path.join(__dirname, 'build'), path: path.join(__dirname, 'build'),
publicPath: '/', publicPath: '/',
globalObject: 'self' globalObject: 'self'
@@ -138,12 +144,6 @@ 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,11 +154,17 @@ 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]',
},
} }
] ]
}, },
@@ -195,7 +201,7 @@ 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].chunk.[contenthash:5].css' chunkFilename: '[name].[contenthash:5].css'
}), }),
new OptimizeCssAssetsPlugin({ new OptimizeCssAssetsPlugin({
@@ -220,7 +226,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: 'src/index.html', template: isProd ? '!!prerender-loader?string!src/index.html' : 'src/index.html',
minify: isProd && { minify: isProd && {
collapseWhitespace: true, collapseWhitespace: true,
removeScriptTypeAttributes: true, removeScriptTypeAttributes: true,
@@ -229,32 +235,29 @@ module.exports = function (_, env) {
removeComments: true removeComments: true
}, },
manifest: readJson('./src/manifest.json'), manifest: readJson('./src/manifest.json'),
inject: true, inject: 'body',
compile: true compile: true
}), }),
new AutoSWPlugin({ version: VERSION }),
isProd && new AssetTemplatePlugin({
template: path.join(__dirname, '_headers.ejs'),
filename: '_headers',
}),
new ScriptExtHtmlPlugin({ new ScriptExtHtmlPlugin({
defaultAttribute: 'async' inline: ['first']
}), }),
// 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' },
@@ -266,6 +269,22 @@ 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.