Compare commits

..

2 Commits

Author SHA1 Message Date
7a08815bcf Make emscripten with threads compile 2018-11-02 18:34:23 +00:00
30e78e8ab7 Attempt at threads for webp encoder 2018-11-01 22:36:38 +00:00
122 changed files with 4698 additions and 9627 deletions

13
.babelrc Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,2 +0,0 @@
/index.html / 301
/* /index.html 301

View File

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

View File

@ -8,6 +8,6 @@
"libimagequant": "ImageOptim/libimagequant#2.12.1"
},
"devDependencies": {
"napa": "3.0.0"
"napa": "^3.0.0"
}
}

View File

@ -39,9 +39,5 @@ struct MozJpegOptions {
bool trellis_opt_zero;
bool trellis_opt_table;
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));
const image = await loadImage('../example.png');
const result = module.encode(image.data, image.width, image.height, {
quality: 75,
quality: 40,
baseline: false,
arithmetic: false,
progressive: true,
@ -29,14 +29,10 @@
smoothing: 0,
color_space: 3,
quant_table: 3,
trellis_multipass: false,
trellis_opt_zero: false,
trellis_opt_table: false,
trellis_multipass: true,
trellis_opt_zero: true,
trellis_opt_table: true,
trellis_loops: 1,
auto_subsample: true,
chroma_subsample: 2,
separate_chroma_quality: false,
chroma_quality: 75,
});
const blob = new Blob([result], {type: 'image/jpeg'});

View File

@ -29,10 +29,6 @@ struct MozJpegOptions {
bool trellis_opt_zero;
bool trellis_opt_table;
int trellis_loops;
bool auto_subsample;
int chroma_subsample;
bool separate_chroma_quality;
int chroma_quality;
};
int version() {
@ -123,6 +119,9 @@ val encode(std::string image_in, int image_width, int image_height, MozJpegOptio
*/
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);
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_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);
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();
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) {
jpeg_simple_progression(&cinfo);
} else {
@ -222,10 +209,6 @@ EMSCRIPTEN_BINDINGS(my_module) {
.field("trellis_opt_zero", &MozJpegOptions::trellis_opt_zero)
.field("trellis_opt_table", &MozJpegOptions::trellis_opt_table)
.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);

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@ -8,6 +8,6 @@
"mozjpeg": "mozilla/mozjpeg#v3.3.1"
},
"devDependencies": {
"napa": "3.0.0"
"napa": "^3.0.0"
}
}

View File

@ -1,6 +1,6 @@
# OptiPNG
- Source: <http://optipng.sourceforge.net/>
- Source: <https://sourceforge.net/project/optipng>
- Version: v0.7.7
## Dependencies

View File

@ -16,7 +16,7 @@
"zlib": "emscripten-ports/zlib"
},
"dependencies": {
"napa": "3.0.0",
"napa": "^3.0.0",
"tar-dependency": "0.0.3"
}
}

BIN
codecs/really_big.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 MiB

View File

@ -8,6 +8,6 @@
"libwebp": "webmproject/libwebp#v1.0.0"
},
"devDependencies": {
"napa": "3.0.0"
"napa": "^3.0.0"
}
}

View File

@ -14,9 +14,12 @@ echo "============================================="
emcc \
${OPTIMIZE} \
--bind \
-s ALLOW_MEMORY_GROWTH=1 \
-s MODULARIZE=1 \
-s 'EXPORT_NAME="webp_enc"' \
-D WEBP_USE_THREAD=1 \
-s USE_PTHREADS=1 \
-s ASSERTIONS=1 \
-s PTHREAD_POOL_SIZE=4 \
-s TOTAL_MEMORY=268435456 \
-s WASM_MEM_MAX=268435456 \
--std=c++11 \
-I node_modules/libwebp \
-o ./webp_enc.js \

View File

@ -1,7 +1,7 @@
<!doctype html>
<script src='webp_enc.js'></script>
<script>
const module = webp_enc();
// const Module = webp_enc();
async function loadImage(src) {
// Load image
@ -17,10 +17,11 @@
return ctx.getImageData(0, 0, img.width, img.height);
}
module.onRuntimeInitialized = async _ => {
console.log('Version:', module.version().toString(16));
const image = await loadImage('../example.png');
const result = module.encode(image.data, image.width, image.height, {
Module.onRuntimeInitialized = async _ => {
console.log('Version:', Module.version().toString(16));
const image = await loadImage('../really_big.jpg');
let start = performance.now();
const result = Module.encode(image.data, image.width, image.height, {
quality: 75,
target_size: 0,
target_PSNR: 0,
@ -43,16 +44,18 @@
exact: 0,
image_hint: 0,
emulate_jpeg_size: 0,
thread_level: 0,
thread_level: 1,
low_memory: 0,
near_lossless: 100,
use_delta_palette: 0,
use_sharp_yuv: 0,
});
let stop = performance.now();
console.log('size', result.length);
const blob = new Blob([result], {type: 'image/webp'});
console.log('time', stop - start);
const blob = new Blob([new Uint8Array(result)], {type: 'image/webp'});
module.free_result();
Module.free_result();
const blobURL = URL.createObjectURL(blob);
const img = document.createElement('img');

View File

@ -8,6 +8,6 @@
"libwebp": "webmproject/libwebp#v1.0.0"
},
"devDependencies": {
"napa": "3.0.0"
"napa": "^3.0.0"
}
}

View File

@ -0,0 +1,192 @@
// Copyright 2015 The Emscripten Authors. All rights reserved.
// Emscripten is available under two separate licenses, the MIT license and the
// University of Illinois/NCSA Open Source License. Both these licenses can be
// found in the LICENSE file.
// Pthread Web Worker startup routine:
// This is the entry point file that is loaded first by each Web Worker
// that executes pthreads on the Emscripten application.
// Thread-local:
var threadInfoStruct = 0; // Info area for this thread in Emscripten HEAP (shared). If zero, this worker is not currently hosting an executing pthread.
var selfThreadId = 0; // The ID of this thread. 0 if not hosting a pthread.
var parentThreadId = 0; // The ID of the parent pthread that launched this thread.
var tempDoublePtr = 0; // A temporary memory area for global float and double marshalling operations.
// Thread-local: Each thread has its own allocated stack space.
var STACK_BASE = 0;
var STACKTOP = 0;
var STACK_MAX = 0;
// These are system-wide memory area parameters that are set at main runtime startup in main thread, and stay constant throughout the application.
var buffer; // All pthreads share the same Emscripten HEAP as SharedArrayBuffer with the main execution thread.
var DYNAMICTOP_PTR = 0;
var TOTAL_MEMORY = 0;
var STATICTOP = 0;
var staticSealed = true; // When threads are being initialized, the static memory area has been already sealed a long time ago.
var DYNAMIC_BASE = 0;
var ENVIRONMENT_IS_PTHREAD = true;
// performance.now() is specced to return a wallclock time in msecs since that Web Worker/main thread launched. However for pthreads this can cause
// subtle problems in emscripten_get_now() as this essentially would measure time from pthread_create(), meaning that the clocks between each threads
// would be wildly out of sync. Therefore sync all pthreads to the clock on the main browser thread, so that different threads see a somewhat
// coherent clock across each of them (+/- 0.1msecs in testing)
var __performance_now_clock_drift = 0;
// Cannot use console.log or console.error in a web worker, since that would risk a browser deadlock! https://bugzilla.mozilla.org/show_bug.cgi?id=1049091
// Therefore implement custom logging facility for threads running in a worker, which queue the messages to main thread to print.
var Module = {};
// When error objects propagate from Web Worker to main thread, they lose helpful call stack and thread ID information, so print out errors early here,
// before that happens.
this.addEventListener('error', function(e) {
if (e.message.indexOf('SimulateInfiniteLoop') != -1) return e.preventDefault();
var errorSource = ' in ' + e.filename + ':' + e.lineno + ':' + e.colno;
console.error('Pthread ' + selfThreadId + ' uncaught exception' + (e.filename || e.lineno || e.colno ? errorSource : '') + ': ' + e.message + '. Error object:');
console.error(e.error);
});
function threadPrint() {
var text = Array.prototype.slice.call(arguments).join(' ');
console.log(text);
}
function threadPrintErr() {
var text = Array.prototype.slice.call(arguments).join(' ');
console.error(text);
console.error(new Error().stack);
}
function threadAlert() {
var text = Array.prototype.slice.call(arguments).join(' ');
postMessage({cmd: 'alert', text: text, threadId: selfThreadId});
}
out = threadPrint;
err = threadPrintErr;
this.alert = threadAlert;
// #if WASM
Module['instantiateWasm'] = function(info, receiveInstance) {
// Instantiate from the module posted from the main thread.
// We can just use sync instantiation in the worker.
instance = new WebAssembly.Instance(Module['wasmModule'], info);
// We don't need the module anymore; new threads will be spawned from the main thread.
delete Module['wasmModule'];
receiveInstance(instance);
return instance.exports;
}
//#endif
this.onmessage = function(e) {
try {
if (e.data.cmd === 'load') { // Preload command that is called once per worker to parse and load the Emscripten code.
// Initialize the thread-local field(s):
tempDoublePtr = e.data.tempDoublePtr;
// Initialize the global "process"-wide fields:
Module['TOTAL_MEMORY'] = TOTAL_MEMORY = e.data.TOTAL_MEMORY;
STATICTOP = e.data.STATICTOP;
DYNAMIC_BASE = e.data.DYNAMIC_BASE;
DYNAMICTOP_PTR = e.data.DYNAMICTOP_PTR;
//#if WASM
if (e.data.wasmModule) {
// Module and memory were sent from main thread
Module['wasmModule'] = e.data.wasmModule;
Module['wasmMemory'] = e.data.wasmMemory;
buffer = Module['wasmMemory'].buffer;
} else {
//#else
buffer = e.data.buffer;
}
//#endif
PthreadWorkerInit = e.data.PthreadWorkerInit;
if (typeof e.data.urlOrBlob === 'string') {
importScripts(e.data.urlOrBlob);
} else {
var objectUrl = URL.createObjectURL(e.data.urlOrBlob);
importScripts(objectUrl);
URL.revokeObjectURL(objectUrl);
}
//#if !ASMFS
if (typeof FS !== 'undefined' && typeof FS.createStandardStreams === 'function') FS.createStandardStreams();
//#endif
postMessage({ cmd: 'loaded' });
} else if (e.data.cmd === 'objectTransfer') {
PThread.receiveObjectTransfer(e.data);
} else if (e.data.cmd === 'run') { // This worker was idle, and now should start executing its pthread entry point.
__performance_now_clock_drift = performance.now() - e.data.time; // Sync up to the clock of the main thread.
threadInfoStruct = e.data.threadInfoStruct;
__register_pthread_ptr(threadInfoStruct, /*isMainBrowserThread=*/0, /*isMainRuntimeThread=*/0); // Pass the thread address inside the asm.js scope to store it for fast access that avoids the need for a FFI out.
assert(threadInfoStruct);
selfThreadId = e.data.selfThreadId;
parentThreadId = e.data.parentThreadId;
assert(selfThreadId);
assert(parentThreadId);
// TODO: Emscripten runtime has these variables twice(!), once outside the asm.js module, and a second time inside the asm.js module.
// Review why that is? Can those get out of sync?
STACK_BASE = STACKTOP = e.data.stackBase;
STACK_MAX = STACK_BASE + e.data.stackSize;
assert(STACK_BASE != 0);
assert(STACK_MAX > STACK_BASE);
Module['establishStackSpace'](e.data.stackBase, e.data.stackBase + e.data.stackSize);
var result = 0;
//#if STACK_OVERFLOW_CHECK
if (typeof writeStackCookie === 'function') writeStackCookie();
//#endif
PThread.receiveObjectTransfer(e.data);
PThread.setThreadStatus(_pthread_self(), 1/*EM_THREAD_STATUS_RUNNING*/);
try {
// pthread entry points are always of signature 'void *ThreadMain(void *arg)'
// Native codebases sometimes spawn threads with other thread entry point signatures,
// such as void ThreadMain(void *arg), void *ThreadMain(), or void ThreadMain().
// That is not acceptable per C/C++ specification, but x86 compiler ABI extensions
// enable that to work. If you find the following line to crash, either change the signature
// to "proper" void *ThreadMain(void *arg) form, or try linking with the Emscripten linker
// flag -s EMULATE_FUNCTION_POINTER_CASTS=1 to add in emulation for this x86 ABI extension.
result = Module['dynCall_ii'](e.data.start_routine, e.data.arg);
//#if STACK_OVERFLOW_CHECK
if (typeof checkStackCookie === 'function') checkStackCookie();
//#endif
} catch(e) {
if (e === 'Canceled!') {
PThread.threadCancel();
return;
} else if (e === 'SimulateInfiniteLoop') {
return;
} else {
Atomics.store(HEAPU32, (threadInfoStruct + 4 /*{{{ C_STRUCTS.pthread.threadExitCode }}}*/ ) >> 2, (e instanceof ExitStatus) ? e.status : -2 /*A custom entry specific to Emscripten denoting that the thread crashed.*/);
Atomics.store(HEAPU32, (threadInfoStruct + 0 /*{{{ C_STRUCTS.pthread.threadStatus }}}*/ ) >> 2, 1); // Mark the thread as no longer running.
_emscripten_futex_wake(threadInfoStruct + 0 /*{{{ C_STRUCTS.pthread.threadStatus }}}*/, 0x7FFFFFFF/*INT_MAX*/); // Wake all threads waiting on this thread to finish.
if (!(e instanceof ExitStatus)) throw e;
}
}
// The thread might have finished without calling pthread_exit(). If so, then perform the exit operation ourselves.
// (This is a no-op if explicit pthread_exit() had been called prior.)
PThread.threadExit(result);
} else if (e.data.cmd === 'cancel') { // Main thread is asking for a pthread_cancel() on this thread.
if (threadInfoStruct && PThread.thisThreadCancelState == 0/*PTHREAD_CANCEL_ENABLE*/) {
PThread.threadCancel();
}
} else if (e.data.target === 'setimmediate') {
// no-op
} else if (e.data.cmd === 'processThreadQueue') {
if (threadInfoStruct) { // If this thread is actually running?
_emscripten_current_thread_process_queued_calls();
}
} else {
err('pthread-main.js received unknown command ' + e.data.cmd);
console.error(e.data);
}
} catch(e) {
console.error('pthread-main.js onmessage() captured an uncaught exception: ' + e);
console.error(e.stack);
throw e;
}
}

View File

@ -26,8 +26,7 @@ val encode(std::string img, int width, int height, WebPConfig config) {
throw std::runtime_error("Unexpected error");
}
// Only use use_argb if we really need it, as it's slower.
pic.use_argb = config.lossless || config.use_sharp_yuv || config.preprocessing > 0;
pic.use_argb = !!config.lossless;
pic.width = width;
pic.height = height;
pic.writer = WebPMemoryWrite;

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

View File

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

View File

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

View File

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

7
global.d.ts vendored
View File

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

8275
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,5 +0,0 @@
{
"extends": [
"config:base"
]
}

View File

@ -5,4 +5,4 @@ export const type = 'browser-jpeg';
export const label = 'Browser JPEG';
export const mimeType = 'image/jpeg';
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';
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 mimeType = 'image/webp';
export const extension = 'webp';
export const defaultOptions: EncodeOptions = { quality: 0.75 };
export const defaultOptions: EncodeOptions = { quality: 0.5 };
export const featureTest = () => canvasEncodeTest(mimeType);

View File

@ -1,3 +1,3 @@
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 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> {
const mimeType = await sniffMimeType(blob);

View File

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

View File

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

View File

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

View File

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

View File

@ -1,257 +1,159 @@
import { h, Component } from 'preact';
import { bind } from '../../lib/initial-util';
import { inputFieldChecked, inputFieldValueAsNumber, preventDefault } from '../../lib/util';
import { inputFieldChecked, inputFieldValueAsNumber } from '../../lib/util';
import { EncodeOptions, MozJpegColorSpace } from './encoder-meta';
import * as style from '../../components/Options/style.scss';
import Checkbox from '../../components/checkbox';
import Expander from '../../components/expander';
import Select from '../../components/select';
import Range from '../../components/range';
import linkState from 'linkstate';
import '../../custom-els/RangeInput';
interface Props {
options: EncodeOptions;
onChange(newOptions: EncodeOptions): void;
}
interface State {
showAdvanced: boolean;
}
export default class MozJPEGEncoderOptions extends Component<Props, State> {
state: State = {
showAdvanced: false,
};
type Props = {
options: EncodeOptions,
onChange(newOptions: EncodeOptions): void,
};
export default class MozJPEGEncoderOptions extends Component<Props, {}> {
@bind
onChange(event: Event) {
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
...this.props.options,
// And now stuff from the form:
// .checked
baseline: inputFieldChecked(form.baseline, options.baseline),
progressive: inputFieldChecked(form.progressive, options.progressive),
optimize_coding: inputFieldChecked(form.optimize_coding, options.optimize_coding),
trellis_multipass: inputFieldChecked(form.trellis_multipass, options.trellis_multipass),
trellis_opt_zero: inputFieldChecked(form.trellis_opt_zero, options.trellis_opt_zero),
trellis_opt_table: inputFieldChecked(form.trellis_opt_table, options.trellis_opt_table),
auto_subsample: inputFieldChecked(form.auto_subsample, options.auto_subsample),
separate_chroma_quality:
inputFieldChecked(form.separate_chroma_quality, options.separate_chroma_quality),
baseline: inputFieldChecked(form.baseline),
progressive: inputFieldChecked(form.progressive),
optimize_coding: inputFieldChecked(form.optimize_coding),
trellis_multipass: inputFieldChecked(form.trellis_multipass),
trellis_opt_zero: inputFieldChecked(form.trellis_opt_zero),
trellis_opt_table: inputFieldChecked(form.trellis_opt_table),
// .value
quality: inputFieldValueAsNumber(form.quality, options.quality),
chroma_quality: inputFieldValueAsNumber(form.chroma_quality, options.chroma_quality),
chroma_subsample: inputFieldValueAsNumber(form.chroma_subsample, options.chroma_subsample),
smoothing: inputFieldValueAsNumber(form.smoothing, options.smoothing),
color_space: inputFieldValueAsNumber(form.color_space, options.color_space),
quant_table: inputFieldValueAsNumber(form.quant_table, options.quant_table),
trellis_loops: inputFieldValueAsNumber(form.trellis_loops, options.trellis_loops),
quality: inputFieldValueAsNumber(form.quality),
smoothing: inputFieldValueAsNumber(form.smoothing),
color_space: inputFieldValueAsNumber(form.color_space),
quant_table: inputFieldValueAsNumber(form.quant_table),
trellis_loops: inputFieldValueAsNumber(form.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
// gathering the data.
return (
<form class={style.optionsSection} onSubmit={preventDefault}>
<div class={style.optionOneCell}>
<Range
<form>
<label>
Quality:
<range-input
name="quality"
min="0"
max="100"
value={options.quality}
onInput={this.onChange}
>
Quality:
</Range>
</div>
<label class={style.optionInputFirst}>
<Checkbox
checked={showAdvanced}
onChange={linkState(this, 'showAdvanced')}
value={'' + options.quality}
onChange={this.onChange}
/>
</label>
<label>
<input
name="baseline"
type="checkbox"
checked={options.baseline}
onChange={this.onChange}
/>
<span>Baseline (worse but legacy-compatible)</span>
</label>
<label style={{ display: options.baseline ? 'none' : '' }}>
<input
name="progressive"
type="checkbox"
checked={options.progressive}
onChange={this.onChange}
/>
<span>Progressive multi-pass rendering</span>
</label>
<label style={{ display: options.baseline ? '' : 'none' }}>
<input
name="optimize_coding"
type="checkbox"
checked={options.optimize_coding}
onChange={this.onChange}
/>
<span>Optimize Huffman table</span>
</label>
<label>
Smoothing:
<range-input
name="smoothing"
min="0"
max="100"
value={'' + options.smoothing}
onChange={this.onChange}
/>
</label>
<label>
Output color space:
<select
name="color_space"
value={'' + options.color_space}
onChange={this.onChange}
>
<option value={MozJpegColorSpace.GRAYSCALE}>Grayscale</option>
<option value={MozJpegColorSpace.RGB}>RGB (sub-optimal)</option>
<option value={MozJpegColorSpace.YCbCr}>YCbCr (optimized for color)</option>
</select>
</label>
<label>
Quantization table:
<select
name="quant_table"
value={'' + options.quant_table}
onChange={this.onChange}
>
<option value="0">JPEG Annex K</option>
<option value="1">Flat</option>
<option value="2">MSSIM-tuned Kodak</option>
<option value="3">ImageMagick</option>
<option value="4">PSNR-HVS-M-tuned Kodak</option>
<option value="5">Klein et al</option>
<option value="6">Watson et al</option>
<option value="7">Ahumada et al</option>
<option value="8">Peterson et al</option>
</select>
</label>
<label>
<input
name="trellis_multipass"
type="checkbox"
checked={options.trellis_multipass}
onChange={this.onChange}
/>
<span>Consider multiple scans during trellis quantization</span>
</label>
<label style={{ display: options.trellis_multipass ? '' : 'none' }}>
<input
name="trellis_opt_zero"
type="checkbox"
checked={options.trellis_opt_zero}
onChange={this.onChange}
/>
<span>Optimize runs of zero blocks</span>
</label>
<label>
<input
name="trellis_opt_table"
type="checkbox"
checked={options.trellis_opt_table}
onChange={this.onChange}
/>
<span>Optimize after trellis quantization</span>
</label>
<label>
Trellis quantization passes:
<range-input
name="trellis_loops"
min="1"
max="50"
value={'' + options.trellis_loops}
onChange={this.onChange}
/>
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}
/>
Auto subsample chroma
</label>
<Expander>
{options.auto_subsample ? null :
<div class={style.optionOneCell}>
<Range
name="chroma_subsample"
min="1"
max="4"
value={options.chroma_subsample}
onInput={this.onChange}
>
Subsample chroma by:
</Range>
</div>
}
</Expander>
<label class={style.optionInputFirst}>
<Checkbox
name="separate_chroma_quality"
checked={options.separate_chroma_quality}
onChange={this.onChange}
/>
Separate chroma quality
</label>
<Expander>
{options.separate_chroma_quality ?
<div class={style.optionOneCell}>
<Range
name="chroma_quality"
min="0"
max="100"
value={options.chroma_quality}
onInput={this.onChange}
>
Chroma quality:
</Range>
</div>
: null
}
</Expander>
</div>
: null
}
</Expander>
<label class={style.optionInputFirst}>
<Checkbox
name="baseline"
checked={options.baseline}
onChange={this.onChange}
/>
Pointless spec compliance
</label>
<Expander>
{options.baseline ? null :
<label class={style.optionInputFirst}>
<Checkbox
name="progressive"
checked={options.progressive}
onChange={this.onChange}
/>
Progressive rendering
</label>
}
</Expander>
<Expander>
{options.baseline ?
<label class={style.optionInputFirst}>
<Checkbox
name="optimize_coding"
checked={options.optimize_coding}
onChange={this.onChange}
/>
Optimize Huffman table
</label>
: null
}
</Expander>
<div class={style.optionOneCell}>
<Range
name="smoothing"
min="0"
max="100"
value={options.smoothing}
onInput={this.onChange}
>
Smoothing:
</Range>
</div>
<label class={style.optionTextFirst}>
Quantization:
<Select
name="quant_table"
value={options.quant_table}
onChange={this.onChange}
>
<option value="0">JPEG Annex K</option>
<option value="1">Flat</option>
<option value="2">MSSIM-tuned Kodak</option>
<option value="3">ImageMagick</option>
<option value="4">PSNR-HVS-M-tuned Kodak</option>
<option value="5">Klein et al</option>
<option value="6">Watson et al</option>
<option value="7">Ahumada et al</option>
<option value="8">Peterson et al</option>
</Select>
</label>
<label class={style.optionInputFirst}>
<Checkbox
name="trellis_multipass"
checked={options.trellis_multipass}
onChange={this.onChange}
/>
Trellis multipass
</label>
<Expander>
{options.trellis_multipass ?
<label class={style.optionInputFirst}>
<Checkbox
name="trellis_opt_zero"
checked={options.trellis_opt_zero}
onChange={this.onChange}
/>
Optimize zero block runs
</label>
: null
}
</Expander>
<label class={style.optionInputFirst}>
<Checkbox
name="trellis_opt_table"
checked={options.trellis_opt_table}
onChange={this.onChange}
/>
Optimize after trellis quantization
</label>
<div class={style.optionOneCell}>
<Range
name="trellis_loops"
min="1"
max="50"
value={options.trellis_loops}
onInput={this.onChange}
>
Trellis quantization passes:
</Range>
</div>
</div>
: null
}
</Expander>
</form>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
import { proxy } from 'comlink';
import { QuantizeOptions } from './imagequant/processor-meta';
import { ProcessorWorkerApi } from './processor-worker';
import { canvasEncode, blobToArrayBuffer } from '../lib/util';
import { EncodeOptions as MozJPEGEncoderOptions } from './mozjpeg/encoder-meta';
import { EncodeOptions as OptiPNGEncoderOptions } from './optipng/encoder-meta';
@ -17,10 +18,8 @@ import * as browserTIFF from './browser-tiff/encoder';
import * as browserJP2 from './browser-jp2/encoder';
import * as browserPDF from './browser-pdf/encoder';
type ProcessorWorkerApi = import('./processor-worker').ProcessorWorkerApi;
/** How long the worker should be idle before terminating. */
const workerTimeout = 10000;
const workerTimeout = 1000;
interface ProcessingJobOptions {
needsWorker?: boolean;
@ -62,10 +61,7 @@ export default class Processor {
// worker-loader does magic here.
// @ts-ignore - Typescript doesn't know about the 2nd param to new Worker, and the
// definition can't be overwritten.
this._worker = new Worker(
'./processor-worker',
{ name: 'processor-worker', type: 'module' },
) as Worker;
this._worker = new Worker('./processor-worker.ts', { type: 'module' }) as Worker;
// Need to do some TypeScript trickery to make the type match.
this._workerApi = proxy(this._worker) as any as ProcessorWorkerApi;
}
@ -96,6 +92,7 @@ export default class Processor {
// If the worker is unused for 10 seconds, remove it to save memory.
this._workerTimeoutId = self.setTimeout(
() => {
if (this._busy) throw Error("Worker shouldn't be busy");
if (!this._worker) return;
this._worker.terminate();
this._worker = undefined;
@ -107,29 +104,23 @@ export default class Processor {
/** Abort the current job, if any */
abortCurrent() {
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 = undefined;
this._busy = false;
if (!this._worker) return;
this._worker.terminate();
this._worker = undefined;
this._abortRejector = undefined;
this._busy = false;
}
// Off main thread jobs:
@Processor._processingJob({ needsWorker: true })
imageQuant(data: ImageData, opts: QuantizeOptions): Promise<ImageData> {
return this._workerApi!.quantize(data, opts);
}
@Processor._processingJob({ needsWorker: true })
rotate(
data: ImageData, opts: import('./rotate/processor-meta').RotateOptions,
): Promise<ImageData> {
return this._workerApi!.rotate(data, opts);
}
@Processor._processingJob({ needsWorker: true })
mozjpegEncode(
data: ImageData, opts: MozJPEGEncoderOptions,

View File

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

View File

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

View File

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

View File

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

View File

@ -1,76 +0,0 @@
import { RotateOptions } from './processor-meta';
const bpp = 4;
export function rotate(data: ImageData, opts: RotateOptions): ImageData {
const { rotate } = opts;
const flipDimensions = rotate % 180 !== 0;
const { width: inputWidth, height: inputHeight } = data;
const outputWidth = flipDimensions ? inputHeight : inputWidth;
const outputHeight = flipDimensions ? inputWidth : inputHeight;
const out = new ImageData(outputWidth, outputHeight);
let i = 0;
// In the straight-copy case, d1 is x, d2 is y.
// x starts at 0 and increases.
// y starts at 0 and increases.
let d1Start = 0;
let d1Limit = inputWidth;
let d1Advance = 1;
let d1Multiplier = 1;
let d2Start = 0;
let d2Limit = inputHeight;
let d2Advance = 1;
let d2Multiplier = inputWidth;
if (rotate === 90) {
// d1 is y, d2 is x.
// y starts at its max value and decreases.
// x starts at 0 and increases.
d1Start = inputHeight - 1;
d1Limit = inputHeight;
d1Advance = -1;
d1Multiplier = inputWidth;
d2Start = 0;
d2Limit = inputWidth;
d2Advance = 1;
d2Multiplier = 1;
} else if (rotate === 180) {
// d1 is x, d2 is y.
// x starts at its max and decreases.
// y starts at its max and decreases.
d1Start = inputWidth - 1;
d1Limit = inputWidth;
d1Advance = -1;
d1Multiplier = 1;
d2Start = inputHeight - 1;
d2Limit = inputHeight;
d2Advance = -1;
d2Multiplier = inputWidth;
} else if (rotate === 270) {
// d1 is y, d2 is x.
// y starts at 0 and increases.
// x starts at its max and decreases.
d1Start = 0;
d1Limit = inputHeight;
d1Advance = 1;
d1Multiplier = inputWidth;
d2Start = inputWidth - 1;
d2Limit = inputWidth;
d2Advance = -1;
d2Multiplier = 1;
}
for (let d2 = d2Start; d2 >= 0 && d2 < d2Limit; d2 += d2Advance) {
for (let d1 = d1Start; d1 >= 0 && d1 < d1Limit; d1 += d1Advance) {
// Iterate over channels:
const start = ((d1 * d1Multiplier) + (d2 * d2Multiplier)) * bpp;
for (let j = 0; j < bpp; j += 1) {
out.data[i] = data.data[start + j];
i += 1;
}
}
}
return out;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 B

View File

@ -1,22 +1,14 @@
import { h, Component } from 'preact';
import { bind } from '../../lib/initial-util';
import { inputFieldCheckedAsNumber, inputFieldValueAsNumber, preventDefault } from '../../lib/util';
import { inputFieldCheckedAsNumber, inputFieldValueAsNumber } from '../../lib/util';
import { EncodeOptions, WebPImageHint } from './encoder-meta';
import * as style from '../../components/Options/style.scss';
import Checkbox from '../../components/checkbox';
import Expander from '../../components/expander';
import Select from '../../components/select';
import Range from '../../components/range';
import linkState from 'linkstate';
import * as styles from './styles.scss';
import '../../custom-els/RangeInput';
interface Props {
options: EncodeOptions;
onChange(newOptions: EncodeOptions): void;
}
interface State {
showAdvanced: boolean;
}
type Props = {
options: EncodeOptions,
onChange(newOptions: EncodeOptions): void,
};
// From kLosslessPresets in config_enc.c
// The format is [method, quality].
@ -26,291 +18,257 @@ const losslessPresets:[number, number][] = [
];
const losslessPresetDefault = 6;
function determineLosslessQuality(quality: number, method: number): number {
const index = losslessPresets.findIndex(
([presetMethod, presetQuality]) => presetMethod === method && presetQuality === quality,
);
function determineLosslessQuality(quality: number): number {
const index = losslessPresets.findIndex(item => item[1] === quality);
if (index !== -1) return index;
// Quality doesn't match one of the presets.
// This can happen when toggling 'lossless'.
return losslessPresetDefault;
}
export default class WebPEncoderOptions extends Component<Props, State> {
state: State = {
showAdvanced: false,
};
export default class WebPEncoderOptions extends Component<Props, {}> {
@bind
onChange(event: Event) {
const form = (event.currentTarget as HTMLInputElement).closest('form') as HTMLFormElement;
const lossless = inputFieldCheckedAsNumber(form.lossless);
const { options } = this.props;
const losslessPresetValue = inputFieldValueAsNumber(
form.lossless_preset, determineLosslessQuality(options.quality, options.method),
);
const losslessPresetInput = (form.lossless_preset as HTMLInputElement);
const newOptions: EncodeOptions = {
const options: EncodeOptions = {
// Copy over options the form doesn't care about, eg emulate_jpeg_size
...options,
...this.props.options,
// And now stuff from the form:
lossless,
// Special-cased inputs:
// In lossless mode, the quality is derived from the preset.
quality: lossless ?
losslessPresets[losslessPresetValue][1] :
inputFieldValueAsNumber(form.quality, options.quality),
losslessPresets[Number(losslessPresetInput.value)][1] :
inputFieldValueAsNumber(form.quality),
// In lossless mode, the method is derived from the preset.
method: lossless ?
losslessPresets[losslessPresetValue][0] :
inputFieldValueAsNumber(form.method_input, options.method),
image_hint: inputFieldCheckedAsNumber(form.image_hint, options.image_hint) ?
losslessPresets[Number(losslessPresetInput.value)][0] :
inputFieldValueAsNumber(form.method_input),
image_hint: (form.image_hint as HTMLInputElement).checked ?
WebPImageHint.WEBP_HINT_GRAPH :
WebPImageHint.WEBP_HINT_DEFAULT,
// .checked
exact: inputFieldCheckedAsNumber(form.exact, options.exact),
alpha_compression: inputFieldCheckedAsNumber(
form.alpha_compression, options.alpha_compression,
),
autofilter: inputFieldCheckedAsNumber(form.autofilter, options.autofilter),
filter_type: inputFieldCheckedAsNumber(form.filter_type, options.filter_type),
use_sharp_yuv: inputFieldCheckedAsNumber(form.use_sharp_yuv, options.use_sharp_yuv),
exact: inputFieldCheckedAsNumber(form.exact),
alpha_compression: inputFieldCheckedAsNumber(form.alpha_compression),
autofilter: inputFieldCheckedAsNumber(form.autofilter),
filter_type: inputFieldCheckedAsNumber(form.filter_type),
use_sharp_yuv: inputFieldCheckedAsNumber(form.use_sharp_yuv),
// .value
near_lossless: 100 - inputFieldValueAsNumber(form.near_lossless, 100 - options.near_lossless),
alpha_quality: inputFieldValueAsNumber(form.alpha_quality, options.alpha_quality),
alpha_filtering: inputFieldValueAsNumber(form.alpha_filtering, options.alpha_filtering),
sns_strength: inputFieldValueAsNumber(form.sns_strength, options.sns_strength),
filter_strength: inputFieldValueAsNumber(form.filter_strength, options.filter_strength),
filter_sharpness:
7 - inputFieldValueAsNumber(form.filter_sharpness, 7 - options.filter_sharpness),
pass: inputFieldValueAsNumber(form.pass, options.pass),
preprocessing: inputFieldValueAsNumber(form.preprocessing, options.preprocessing),
segments: inputFieldValueAsNumber(form.segments, options.segments),
partitions: inputFieldValueAsNumber(form.partitions, options.partitions),
near_lossless: 100 - inputFieldValueAsNumber(form.near_lossless),
alpha_quality: inputFieldValueAsNumber(form.alpha_quality),
alpha_filtering: inputFieldValueAsNumber(form.alpha_filtering),
sns_strength: inputFieldValueAsNumber(form.sns_strength),
filter_strength: inputFieldValueAsNumber(form.filter_strength),
filter_sharpness: 7 - inputFieldValueAsNumber(form.filter_sharpness),
pass: inputFieldValueAsNumber(form.pass),
preprocessing: inputFieldValueAsNumber(form.preprocessing),
segments: inputFieldValueAsNumber(form.segments),
partitions: inputFieldValueAsNumber(form.partitions),
};
this.props.onChange(newOptions);
this.props.onChange(options);
}
private _losslessSpecificOptions(options: EncodeOptions) {
return (
<div key="lossless">
<div class={style.optionOneCell}>
<Range
<div>
<label>
Effort:
<range-input
name="lossless_preset"
min="0"
max="9"
value={determineLosslessQuality(options.quality, options.method)}
onInput={this.onChange}
>
Effort:
</Range>
</div>
<div class={style.optionOneCell}>
<Range
value={'' + determineLosslessQuality(options.quality)}
onChange={this.onChange}
/>
</label>
<label>
Slight loss:
<range-input
name="near_lossless"
min="0"
max="100"
value={'' + (100 - options.near_lossless)}
onInput={this.onChange}
>
Slight loss:
</Range>
</div>
<label class={style.optionInputFirst}>
onChange={this.onChange}
/>
</label>
<label>
{/*
Although there are 3 different kinds of image hint, webp only
seems to do something with the 'graph' type, and I don't really
understand what it does.
*/}
<Checkbox
<input
name="image_hint"
type="checkbox"
checked={options.image_hint === WebPImageHint.WEBP_HINT_GRAPH}
value={'' + WebPImageHint.WEBP_HINT_GRAPH}
onChange={this.onChange}
/>
Discrete tone image
<span>Discrete tone image (graph, map-tile etc)</span>
</label>
</div>
);
}
private _lossySpecificOptions(options: EncodeOptions) {
const { showAdvanced } = this.state;
return (
<div key="lossy">
<div class={style.optionOneCell}>
<Range
<div>
<label>
Effort:
<range-input
name="method_input"
min="0"
max="6"
value={options.method}
onInput={this.onChange}
>
Effort:
</Range>
</div>
<div class={style.optionOneCell}>
<Range
value={'' + options.method}
onChange={this.onChange}
/>
</label>
<label>
Quality:
<range-input
name="quality"
min="0"
max="100"
step="0.1"
value={options.quality}
onInput={this.onChange}
>
Quality:
</Range>
</div>
<label class={style.optionInputFirst}>
<Checkbox
checked={showAdvanced}
onChange={linkState(this, 'showAdvanced')}
step="0.01"
value={'' + options.quality}
onChange={this.onChange}
/>
</label>
<hr />
<label>
<input
name="alpha_compression"
type="checkbox"
checked={!!options.alpha_compression}
onChange={this.onChange}
/>
Compress alpha
</label>
<label>
Alpha quality:
<range-input
name="alpha_quality"
min="0"
max="100"
value={'' + options.alpha_quality}
onChange={this.onChange}
/>
</label>
<label>
Alpha filter quality:
<range-input
name="alpha_filtering"
min="0"
max="2"
value={'' + options.alpha_filtering}
onChange={this.onChange}
/>
</label>
<hr />
<label>
<input
name="autofilter"
type="checkbox"
checked={!!options.autofilter}
onChange={this.onChange}
/>
<span>Auto adjust filter strength</span>
</label>
<label>
Filter strength:
<range-input
name="filter_strength"
min="0"
max="100"
disabled={!!options.autofilter}
value={'' + options.filter_strength}
onChange={this.onChange}
/>
</label>
<label>
<input
name="filter_type"
type="checkbox"
checked={!!options.filter_type}
onChange={this.onChange}
/>
Strong filter
</label>
<label>
Filter sharpness:
<range-input
name="filter_sharpness"
min="0"
max="7"
value={'' + (7 - options.filter_sharpness)}
onChange={this.onChange}
/>
</label>
<label>
<input
name="use_sharp_yuv"
type="checkbox"
checked={!!options.use_sharp_yuv}
onChange={this.onChange}
/>
Sharp RGB->YUV conversion
</label>
<hr />
<label>
Passes:
<range-input
name="pass"
min="1"
max="10"
value={'' + options.pass}
onChange={this.onChange}
/>
</label>
<label>
Spacial noise shaping:
<range-input
name="sns_strength"
min="0"
max="100"
value={'' + options.sns_strength}
onChange={this.onChange}
/>
</label>
<label>
Preprocessing type:
<select
name="preprocessing"
value={'' + options.preprocessing}
onChange={this.onChange}
>
<option value="0">None</option>
<option value="1">Segment smooth</option>
<option value="2">Pseudo-random dithering</option>
</select>
</label>
<label>
Segments:
<range-input
name="segments"
min="1"
max="4"
value={'' + options.segments}
onChange={this.onChange}
/>
</label>
<label>
Partitions:
<range-input
name="partitions"
min="0"
max="3"
value={'' + options.partitions}
onChange={this.onChange}
/>
Show advanced settings
</label>
<Expander>
{showAdvanced ?
<div>
<label class={style.optionInputFirst}>
<Checkbox
name="alpha_compression"
checked={!!options.alpha_compression}
onChange={this.onChange}
/>
Compress alpha
</label>
<div class={style.optionOneCell}>
<Range
name="alpha_quality"
min="0"
max="100"
value={options.alpha_quality}
onInput={this.onChange}
>
Alpha quality:
</Range>
</div>
<div class={style.optionOneCell}>
<Range
name="alpha_filtering"
min="0"
max="2"
value={options.alpha_filtering}
onInput={this.onChange}
>
Alpha filter quality:
</Range>
</div>
<label class={style.optionInputFirst}>
<Checkbox
name="autofilter"
checked={!!options.autofilter}
onChange={this.onChange}
/>
Auto adjust filter strength
</label>
<Expander>
{options.autofilter ? null :
<div class={style.optionOneCell}>
<Range
name="filter_strength"
min="0"
max="100"
value={options.filter_strength}
onInput={this.onChange}
>
Filter strength:
</Range>
</div>
}
</Expander>
<label class={style.optionInputFirst}>
<Checkbox
name="filter_type"
checked={!!options.filter_type}
onChange={this.onChange}
/>
Strong filter
</label>
<div class={style.optionOneCell}>
<Range
name="filter_sharpness"
min="0"
max="7"
value={7 - options.filter_sharpness}
onInput={this.onChange}
>
Filter sharpness:
</Range>
</div>
<label class={style.optionInputFirst}>
<Checkbox
name="use_sharp_yuv"
checked={!!options.use_sharp_yuv}
onChange={this.onChange}
/>
Sharp RGBYUV conversion
</label>
<div class={style.optionOneCell}>
<Range
name="pass"
min="1"
max="10"
value={options.pass}
onInput={this.onChange}
>
Passes:
</Range>
</div>
<div class={style.optionOneCell}>
<Range
name="sns_strength"
min="0"
max="100"
value={options.sns_strength}
onInput={this.onChange}
>
Spacial noise shaping:
</Range>
</div>
<label class={style.optionTextFirst}>
Preprocess:
<Select
name="preprocessing"
value={options.preprocessing}
onChange={this.onChange}
>
<option value="0">None</option>
<option value="1">Segment smooth</option>
<option value="2">Pseudo-random dithering</option>
</Select>
</label>
<div class={style.optionOneCell}>
<Range
name="segments"
min="1"
max="4"
value={options.segments}
onInput={this.onChange}
>
Segments:
</Range>
</div>
<div class={style.optionOneCell}>
<Range
name="partitions"
min="0"
max="3"
value={options.partitions}
onInput={this.onChange}
>
Partitions:
</Range>
</div>
</div>
: null
}
</Expander>
</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
// gathering the data.
return (
<form class={style.optionsSection} onSubmit={preventDefault}>
<label class={style.optionInputFirst}>
<Checkbox
<form>
<label>
<input
name="lossless"
type="checkbox"
checked={!!options.lossless}
onChange={this.onChange}
/>
Lossless
</label>
{options.lossless
? this._losslessSpecificOptions(options)
: this._lossySpecificOptions(options)
}
<label class={style.optionInputFirst}>
<Checkbox
<div class={options.lossless ? '' : styles.hide}>
{this._losslessSpecificOptions(options)}
</div>
<div class={options.lossless ? styles.hide : ''}>
{this._lossySpecificOptions(options)}
</div>
<label>
<input
name="exact"
type="checkbox"
checked={!!options.exact}
onChange={this.onChange}
/>
Preserve transparent data
<span>
Preserve transparent data. Otherwise, pixels with zero alpha will have RGB also zeroed.
</span>
</label>
</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,39 +2,31 @@ import { h, Component } from 'preact';
import { bind, linkRef, Fileish } from '../../lib/initial-util';
import * as style from './style.scss';
import { FileDropEvent } from 'file-drop-element';
import 'file-drop-element';
import SnackBarElement, { SnackOptions } from '../../lib/SnackBar';
import { FileDropEvent } from './custom-els/FileDrop';
import './custom-els/FileDrop';
import SnackBarElement from '../../lib/SnackBar';
import '../../lib/SnackBar';
import Intro from '../intro';
import '../custom-els/LoadingSpinner';
const ROUTE_EDITOR = '/editor';
// This is imported for TypeScript only. It isn't used.
import Compress from '../compress';
const compressPromise = import(
/* webpackChunkName: "main-app" */
'../compress',
);
const offlinerPromise = import(
/* webpackChunkName: "offliner" */
'../../lib/offliner',
);
function back() {
window.history.back();
export interface SourceImage {
file: File | Fileish;
data: ImageData;
vectorImage?: HTMLImageElement;
}
interface Props {}
interface State {
file?: File | Fileish;
isEditorOpen: Boolean;
Compress?: typeof import('../compress').default;
Compress?: typeof Compress;
}
export default class App extends Component<Props, State> {
state: State = {
isEditorOpen: false,
file: undefined,
Compress: undefined,
};
@ -44,74 +36,49 @@ export default class App extends Component<Props, State> {
constructor() {
super();
compressPromise.then((module) => {
import('../compress').then((module) => {
this.setState({ Compress: module.default });
}).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:
if (process.env.NODE_ENV === 'development') {
this.setState(window.STATE);
const oldCDU = this.componentDidUpdate;
this.componentDidUpdate = (props, state, prev) => {
if (oldCDU) oldCDU.call(this, props, state, prev);
this.componentDidUpdate = (props, state) => {
if (oldCDU) oldCDU.call(this, props, state);
window.STATE = this.state;
};
}
// Since iOS 10, Apple tries to prevent disabling pinch-zoom. This is great in theory, but
// really breaks things on Squoosh, as you can easily end up zooming the UI when you mean to
// zoom the image. Once you've done this, it's really difficult to undo. Anyway, this seems to
// prevent it.
document.body.addEventListener('gesturestart', (event) => {
event.preventDefault();
});
window.addEventListener('popstate', this.onPopState);
}
@bind
private onFileDrop({ file }: FileDropEvent) {
private onFileDrop(event: FileDropEvent) {
const { file } = event;
if (!file) return;
this.openEditor();
this.setState({ file });
}
@bind
private onIntroPickFile(file: File | Fileish) {
this.openEditor();
this.setState({ file });
}
@bind
private showSnack(message: string, options: SnackOptions = {}): Promise<string> {
private showError(error: string) {
if (!this.snackbar) throw Error('Snackbar missing');
return this.snackbar.showSnackbar(message, options);
this.snackbar.showSnackbar({ message: error });
}
@bind
private onPopState() {
this.setState({ isEditorOpen: location.pathname === ROUTE_EDITOR });
}
@bind
private openEditor() {
if (this.state.isEditorOpen) return;
history.pushState(null, '', ROUTE_EDITOR);
this.setState({ isEditorOpen: true });
}
render({}: Props, { file, isEditorOpen, Compress }: State) {
render({}: Props, { file, Compress }: State) {
return (
<div id="app" class={style.app}>
<file-drop accept="image/*" onfiledrop={this.onFileDrop} class={style.drop}>
{!isEditorOpen
? <Intro onFile={this.onIntroPickFile} showSnack={this.showSnack} />
<file-drop accept="image/*" onfiledrop={this.onFileDrop}>
{(!file)
? <Intro onFile={this.onIntroPickFile} onError={this.showError} />
: (Compress)
? <Compress file={file!} showSnack={this.showSnack} onBack={back} />
? <Compress file={file} onError={this.showError} />
: <loading-spinner class={style.appLoader}/>
}
<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 {
position: absolute;
left: 0;
@ -8,14 +12,14 @@
contain: strict;
}
.drop {
overflow: hidden;
touch-action: none;
height: 100%;
width: 100%;
:global {
file-drop {
overflow: hidden;
touch-action: none;
height:100%;
width:100%;
&:global {
&::after {
&:after {
content: '';
position: absolute;
display: block;
@ -24,20 +28,28 @@
right: 10px;
bottom: 10px;
border: 2px dashed #fff;
background-color:rgba(88, 116, 88, 0.2);
border-color: rgba(65, 129, 65, 0.5);
border-radius: 10px;
opacity: 0;
transform: scale(0.95);
transition: all 200ms ease-in;
transition-property: transform, opacity;
transition: opacity 300ms ease, transform 300ms cubic-bezier(.6,2,.6,1), background-color 300ms step-end, border-color 300ms step-end;
pointer-events: none;
}
&.drop-valid::after {
&.drop-valid:after,
&.drop-invalid:after {
opacity: 1;
transform: scale(1);
transition-timing-function: ease-out;
transition: opacity 300ms ease, transform 300ms cubic-bezier(.6,2,.6,1);
}
&.drop-valid:after {
background-color:rgba(88, 116, 88, 0.2);
border-color: rgba(65, 129, 65, 0.5);
}
&.drop-invalid:after {
background-color:rgba(119, 85, 85, 0.2);
border-color:rgba(129, 63, 63, 0.5);
}
}
}

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 * 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 OptiPNGEncoderOptions from '../../codecs/optipng/options';
import MozJpegEncoderOptions from '../../codecs/mozjpeg/options';
@ -31,18 +31,16 @@ import {
encoders,
encodersSupported,
EncoderSupportMap,
encoderMap,
} from '../../codecs/encoders';
import { QuantizeOptions } from '../../codecs/imagequant/processor-meta';
import { ResizeOptions } from '../../codecs/resize/processor-meta';
import { PreprocessorState } from '../../codecs/preprocessors';
import { SourceImage } from '../compress';
import Checkbox from '../checkbox';
import Expander from '../expander';
import Select from '../select';
import FileSize from '../FileSize';
import { DownloadIcon } from '../../lib/icons';
import { SourceImage } from '../App';
const encoderOptionsComponentMap: {
[x: string]: (new (...args: any[]) => Component<any, any>) | undefined;
} = {
const encoderOptionsComponentMap = {
[identity.type]: undefined,
[optiPNG.type]: OptiPNGEncoderOptions,
[mozJPEG.type]: MozJpegEncoderOptions,
@ -58,14 +56,23 @@ const encoderOptionsComponentMap: {
[browserPDF.type]: undefined,
};
const titles = {
horizontal: ['Left Image', 'Right Image'],
vertical: ['Top Image', 'Bottom Image'],
};
interface Props {
mobileView: boolean;
orientation: 'horizontal' | 'vertical';
source?: SourceImage;
imageIndex: number;
imageFile?: Fileish;
downloadUrl?: string;
encoderState: EncoderState;
preprocessorState: PreprocessorState;
onEncoderTypeChange(newType: EncoderType): void;
onEncoderOptionsChange(newOptions: EncoderOptions): void;
onPreprocessorOptionsChange(newOptions: PreprocessorState): void;
onCopyToOtherClick(): void;
}
interface State {
@ -73,9 +80,7 @@ interface State {
}
export default class Options extends Component<Props, State> {
state: State = {
encoderSupportMap: undefined,
};
typeSelect?: HTMLSelectElement;
constructor() {
super();
@ -83,7 +88,7 @@ export default class Options extends Component<Props, State> {
}
@bind
private onEncoderTypeChange(event: Event) {
onEncoderTypeChange(event: Event) {
const el = event.currentTarget as HTMLSelectElement;
// The select element only has values matching encoder types,
@ -93,7 +98,7 @@ export default class Options extends Component<Props, State> {
}
@bind
private onPreprocessorEnabledChange(event: Event) {
onPreprocessorEnabledChange(event: Event) {
const el = event.currentTarget as HTMLInputElement;
const preprocessor = el.name.split('.')[0] as keyof PreprocessorState;
@ -103,22 +108,32 @@ export default class Options extends Component<Props, State> {
}
@bind
private onQuantizerOptionsChange(opts: QuantizeOptions) {
onQuantizerOptionsChange(opts: QuantizeOptions) {
this.props.onPreprocessorOptionsChange(
cleanMerge(this.props.preprocessorState, 'quantizer', opts),
);
}
@bind
private onResizeOptionsChange(opts: ResizeOptions) {
onResizeOptionsChange(opts: ResizeOptions) {
this.props.onPreprocessorOptionsChange(
cleanMerge(this.props.preprocessorState, 'resize', opts),
);
}
@bind
onCopyToOtherClick(event: Event) {
event.preventDefault();
this.props.onCopyToOtherClick();
}
render(
{
source,
imageIndex,
imageFile,
downloadUrl,
orientation,
encoderState,
preprocessorState,
onEncoderOptionsChange,
@ -129,65 +144,64 @@ export default class Options extends Component<Props, State> {
const EncoderOptionComponent = encoderOptionsComponentMap[encoderState.type];
return (
<div class={style.optionsScroller}>
<Expander>
{encoderState.type === identity.type ? null :
<div>
<h3 class={style.optionsTitle}>Edit</h3>
<label class={style.sectionEnabler}>
<Checkbox
<div class={`${style.options} ${style[orientation]}`}>
<h2 class={style.title}>
{titles[orientation][imageIndex]}
{', '}
{encoderMap[encoderState.type].label}
</h2>
<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"
type="checkbox"
checked={!!preprocessorState.resize.enabled}
onChange={this.onPreprocessorEnabledChange}
/>
Resize
</label>
<Expander>
{preprocessorState.resize.enabled ?
<ResizeOptionsComponent
isVector={Boolean(source && source.vectorImage)}
aspect={source ? source.processed.width / source.processed.height : 1}
options={preprocessorState.resize}
onChange={this.onResizeOptionsChange}
/>
: null}
</Expander>
<label class={style.sectionEnabler}>
<Checkbox
{preprocessorState.resize.enabled &&
<ResizeOptionsComponent
isVector={Boolean(source && source.vectorImage)}
aspect={source ? (source.data.width / source.data.height) : 1}
options={preprocessorState.resize}
onChange={this.onResizeOptionsChange}
/>
}
<label class={style.toggle}>
<input
name="quantizer.enable"
type="checkbox"
checked={!!preprocessorState.quantizer.enabled}
onChange={this.onPreprocessorEnabledChange}
/>
Reduce palette
Quantize
</label>
<Expander>
{preprocessorState.quantizer.enabled ?
<QuantizerOptionsComponent
options={preprocessorState.quantizer}
onChange={this.onQuantizerOptionsChange}
/>
: null}
</Expander>
{preprocessorState.quantizer.enabled &&
<QuantizerOptionsComponent
options={preprocessorState.quantizer}
onChange={this.onQuantizerOptionsChange}
/>
}
</div>
}
</Expander>
)}
<h3 class={style.optionsTitle}>Compress</h3>
<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={
// Casting options, as encoderOptionsComponentMap[encodeData.type] ensures
@ -196,8 +210,33 @@ export default class Options extends Component<Props, State> {
}
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>
);
}

View File

@ -1,59 +1,225 @@
$horizontalPadding: 15px;
/*
Note: These styles are temporary. They will be replaced before going live.
*/
.options-title {
background: rgba(0, 0, 0, 0.9);
margin: 0;
padding: 10px $horizontalPadding;
font-weight: normal;
font-size: 1.4rem;
border-bottom: 1px solid #000;
.row {
padding: 5px;
margin: 0 10px;
}
.option-text-first {
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;
color: #000;
font: inherit;
border: none;
padding: 2px 0 2px 10px;
width: 100%;
.options {
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;
}
}
&.vertical {
opacity: 1;
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;
}
.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);
}
}
.options-scroller {
overflow-x: hidden;
overflow-y: auto;
-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;
}
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 { PointerTracker, Pointer } from '../../../../lib/PointerTracker';
interface Point {
clientX: number;
@ -145,7 +145,7 @@ export default class PinchZoom extends HTMLElement {
const relativeToEl = (relativeTo === 'content' ? this._positioningEl : this);
// No content element? Fall back to just setting scale
if (!relativeToEl || !this._positioningEl) {
if (!relativeToEl) {
this.setTransform({ scale, allowChangeEvent });
return;
}
@ -157,10 +157,6 @@ export default class PinchZoom extends HTMLElement {
if (relativeTo === 'content') {
originX += this.x;
originY += this.y;
} else {
const currentRect = this._positioningEl.getBoundingClientRect();
originX -= currentRect.left;
originY -= currentRect.top;
}
this._applyChange({
@ -242,7 +238,7 @@ export default class PinchZoom extends HTMLElement {
/**
* 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
if (scale < MIN_SCALE) return;

View File

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

View File

@ -6,41 +6,29 @@ two-up {
--track-color: var(--accent-color);
--thumb-background: #fff;
--thumb-color: var(--accent-color);
--thumb-size: 62px;
--bar-size: 6px;
--bar-touch-size: 30px;
}
two-up > * {
/* Overlay all children on top of each other, and let two-up's layout contain all of them. */
/* Overlay all children on top of each other, and let
two-up's layout contain all of them. */
grid-area: 1/1;
}
two-up[legacy-clip-compat] > :not(.two-up-handle) {
/* Legacy mode uses clip rather than clip-path (Edge doesn't support clip-path), but clip requires
elements to be positioned absolutely */
two-up[legacy-clip-compat] > :not(.twoUpHandle) {
position: absolute;
}
.two-up-handle {
.twoUpHandle {
touch-action: none;
position: relative;
width: var(--bar-touch-size);
width: 10px;
background: var(--track-color);
transform: translateX(var(--split-point)) translateX(-50%);
box-shadow: inset 4px 0 0 rgba(0,0,0,0.1), 0 1px 4px rgba(0,0,0,0.4);
will-change: transform;
cursor: ew-resize;
}
.two-up-handle::before {
content: '';
display: block;
height: 100%;
width: var(--bar-size);
margin: 0 auto;
box-shadow: inset calc(var(--bar-size) / 2) 0 0 rgba(0,0,0,0.1), 0 1px 4px rgba(0,0,0,0.4);
background: var(--track-color);
}
.scrubber {
display: flex;
position: absolute;
@ -48,56 +36,51 @@ two-up[legacy-clip-compat] > :not(.two-up-handle) {
left: 50%;
transform-origin: 50% 50%;
transform: translate(-50%, -50%);
width: var(--thumb-size);
height: calc(var(--thumb-size) * 0.9);
width: 62px;
height: 56px;
background: var(--thumb-background);
border: 1px solid rgba(0,0,0,0.2);
border-radius: var(--thumb-size);
border-radius: 5px;
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
color: var(--thumb-color);
box-sizing: border-box;
padding: 0 calc(var(--thumb-size) * 0.24);
}
.scrubber svg {
flex: 1;
margin: 0 10px;
}
two-up[orientation='vertical'] .two-up-handle {
two-up[orientation='vertical'] .twoUpHandle {
width: auto;
height: var(--bar-touch-size);
height: 7px;
transform: translateY(var(--split-point)) translateY(-50%);
box-shadow: inset 0 3px 0 rgba(0,0,0,0.1), 0 1px 4px rgba(0,0,0,0.4);
cursor: ns-resize;
}
two-up[orientation='vertical'] .two-up-handle::before {
width: auto;
height: var(--bar-size);
box-shadow: inset 0 calc(var(--bar-size) / 2) 0 rgba(0,0,0,0.1), 0 1px 4px rgba(0,0,0,0.4);
margin: calc((var(--bar-touch-size) - var(--bar-size)) / 2) 0 0 0;
}
two-up[orientation='vertical'] .scrubber {
width: 46px;
height: 40px;
font-size: 18px;
box-shadow: 1px 0 4px rgba(0,0,0,0.1);
transform: translate(-50%, -50%) rotate(-90deg);
}
two-up > :nth-child(1):not(.two-up-handle) {
two-up > :nth-child(1):not(.twoUpHandle) {
-webkit-clip-path: inset(0 calc(100% - var(--split-point)) 0 0);
clip-path: inset(0 calc(100% - var(--split-point)) 0 0);
}
two-up > :nth-child(2):not(.two-up-handle) {
two-up > :nth-child(2):not(.twoUpHandle) {
-webkit-clip-path: inset(0 0 0 var(--split-point));
clip-path: inset(0 0 0 var(--split-point));
}
two-up[orientation='vertical'] > :nth-child(1):not(.two-up-handle) {
two-up[orientation='vertical'] > :nth-child(1):not(.twoUpHandle) {
-webkit-clip-path: inset(0 0 calc(100% - var(--split-point)) 0);
clip-path: inset(0 0 calc(100% - var(--split-point)) 0);
}
two-up[orientation='vertical'] > :nth-child(2):not(.two-up-handle) {
two-up[orientation='vertical'] > :nth-child(2):not(.twoUpHandle) {
-webkit-clip-path: inset(var(--split-point) 0 0 0);
clip-path: inset(var(--split-point) 0 0 0);
}
@ -107,19 +90,19 @@ two-up[orientation='vertical'] > :nth-child(2):not(.two-up-handle) {
It performs way better in Safari.
*/
@supports not ((clip-path: inset(0 0 0 0)) or (-webkit-clip-path: inset(0 0 0 0))) {
two-up[legacy-clip-compat] > :nth-child(1):not(.two-up-handle) {
two-up[legacy-clip-compat] > :nth-child(1):not(.twoUpHandle) {
clip: rect(auto var(--split-point) auto auto);
}
two-up[legacy-clip-compat] > :nth-child(2):not(.two-up-handle) {
two-up[legacy-clip-compat] > :nth-child(2):not(.twoUpHandle) {
clip: rect(auto auto auto var(--split-point));
}
two-up[orientation='vertical'][legacy-clip-compat] > :nth-child(1):not(.two-up-handle) {
two-up[orientation='vertical'][legacy-clip-compat] > :nth-child(1):not(.twoUpHandle) {
clip: rect(auto auto var(--split-point) auto);
}
two-up[orientation='vertical'][legacy-clip-compat] > :nth-child(2):not(.two-up-handle) {
two-up[orientation='vertical'][legacy-clip-compat] > :nth-child(2):not(.twoUpHandle) {
clip: rect(var(--split-point) auto auto auto);
}
}

View File

@ -5,29 +5,16 @@ import './custom-els/TwoUp';
import * as style from './style.scss';
import { bind, linkRef } from '../../lib/initial-util';
import { shallowEqual, drawDataToCanvas } from '../../lib/util';
import {
ToggleBackgroundIcon,
AddIcon,
RemoveIcon,
BackIcon,
ToggleBackgroundActiveIcon,
RotateIcon,
} from '../../lib/icons';
import { ToggleIcon, AddIcon, RemoveIcon } from '../../lib/icons';
import { twoUpHandle } from './custom-els/TwoUp/styles.css';
import { InputProcessorState } from '../../codecs/input-processors';
import { cleanSet } from '../../lib/clean-modify';
import { SourceImage } from '../compress';
interface Props {
source?: SourceImage;
inputProcessorState?: InputProcessorState;
mobileView: boolean;
originalImage?: ImageData;
orientation: 'horizontal' | 'vertical';
leftCompressed?: ImageData;
rightCompressed?: ImageData;
leftImgContain: boolean;
rightImgContain: boolean;
onBack: () => void;
onInputProcessorChange: (newState: InputProcessorState) => void;
}
interface State {
@ -60,15 +47,6 @@ export default class Output extends Component<Props, State> {
const leftDraw = this.leftDrawable();
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) {
drawDataToCanvas(this.canvasLeft, leftDraw);
}
@ -82,38 +60,6 @@ export default class Output extends Component<Props, State> {
const prevRightDraw = this.rightDrawable(prevProps);
const leftDraw = this.leftDrawable();
const rightDraw = this.rightDrawable();
const sourceFileChanged =
// Has the value become (un)defined?
(!!this.props.source !== !!prevProps.source) ||
// Or has the file changed?
(this.props.source && prevProps.source && this.props.source.file !== prevProps.source.file);
const oldSourceData = prevProps.source && prevProps.source.processed;
const newSourceData = this.props.source && this.props.source.processed;
const pinchZoom = this.pinchZoomLeft!;
if (sourceFileChanged) {
// New image? Reset the pinch-zoom.
pinchZoom.setTransform({
allowChangeEvent: true,
x: 0,
y: 0,
scale: 1,
});
} else if (oldSourceData && newSourceData && oldSourceData !== newSourceData) {
// Since the pinch zoom transform origin is the top-left of the content, we need to flip
// things around a bit when the content size changes, so the new content appears as if it were
// central to the previous content.
const scaleChange = 1 - pinchZoom.scale;
const oldXScaleOffset = oldSourceData.width / 2 * scaleChange;
const oldYScaleOffset = oldSourceData.height / 2 * scaleChange;
pinchZoom.setTransform({
allowChangeEvent: true,
x: pinchZoom.x - oldXScaleOffset + oldYScaleOffset,
y: pinchZoom.y - oldYScaleOffset + oldXScaleOffset,
});
}
if (leftDraw && leftDraw !== prevLeftDraw && this.canvasLeft) {
drawDataToCanvas(this.canvasLeft, leftDraw);
@ -121,6 +67,16 @@ export default class Output extends Component<Props, State> {
if (rightDraw && rightDraw !== prevRightDraw && this.canvasRight) {
drawDataToCanvas(this.canvasRight, rightDraw);
}
if (this.props.originalImage !== prevProps.originalImage && this.pinchZoomLeft) {
// New image? Reset the pinch-zoom.
this.pinchZoomLeft.setTransform({
allowChangeEvent: true,
x: 0,
y: 0,
scale: 1,
});
}
}
shouldComponentUpdate(nextProps: Props, nextState: State) {
@ -128,11 +84,11 @@ export default class Output extends Component<Props, State> {
}
private leftDrawable(props: Props = this.props): ImageData | undefined {
return props.leftCompressed || (props.source && props.source.processed);
return props.leftCompressed || props.originalImage;
}
private rightDrawable(props: Props = this.props): ImageData | undefined {
return props.rightCompressed || (props.source && props.source.processed);
return props.rightCompressed || props.originalImage;
}
@bind
@ -157,33 +113,14 @@ export default class Output extends Component<Props, State> {
}
@bind
private onRotateClick() {
const { inputProcessorState } = this.props;
if (!inputProcessorState) return;
const newState = cleanSet(
inputProcessorState,
'rotate.rotate',
(inputProcessorState.rotate.rotate + 90) % 360,
);
this.props.onInputProcessorChange(newState);
}
@bind
private onScaleValueFocus() {
private editScale() {
this.setState({ editingScale: true }, () => {
if (this.scaleInput) {
// Firefox unfocuses the input straight away unless I force a style calculation here. I have
// no idea why, but it's late and I'm quite tired.
getComputedStyle(this.scaleInput).transform;
this.scaleInput.focus();
}
if (this.scaleInput) this.scaleInput.focus();
});
}
@bind
private onScaleInputBlur() {
private cancelEditScale() {
this.setState({ editingScale: false });
}
@ -235,34 +172,23 @@ export default class Output extends Component<Props, State> {
const clonedEvent = new (event.constructor as typeof Event)(event.type, event);
this.retargetedEvents.add(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(
{ mobileView, leftImgContain, rightImgContain, source, onBack }: Props,
{
orientation, leftCompressed, rightCompressed, leftImgContain, rightImgContain,
originalImage,
}: Props,
{ scale, editingScale, altBackground }: State,
) {
const leftDraw = this.leftDrawable();
const rightDraw = this.rightDrawable();
// To keep position stable, the output is put in a square using the longest dimension.
const originalImage = source && source.processed;
return (
<div class={`${style.output} ${altBackground ? style.altBackground : ''}`}>
<two-up
legacy-clip-compat
class={style.twoUp}
orientation={mobileView ? 'vertical' : 'horizontal'}
orientation={orientation}
// Event redirecting. See onRetargetableEvent.
onTouchStartCapture={this.onRetargetableEvent}
onTouchEndCapture={this.onRetargetableEvent}
@ -272,12 +198,11 @@ export default class Output extends Component<Props, State> {
onWheelCapture={this.onRetargetableEvent}
>
<pinch-zoom
class={style.pinchZoom}
onChange={this.onPinchZoomLeftChange}
ref={linkRef(this, 'pinchZoomLeft')}
>
<canvas
class={style.pinchTarget}
class={style.outputCanvas}
ref={linkRef(this, 'canvasLeft')}
width={leftDraw && leftDraw.width}
height={leftDraw && leftDraw.height}
@ -288,9 +213,9 @@ export default class Output extends Component<Props, State> {
}}
/>
</pinch-zoom>
<pinch-zoom class={style.pinchZoom} ref={linkRef(this, 'pinchZoomRight')}>
<pinch-zoom ref={linkRef(this, 'pinchZoomRight')}>
<canvas
class={style.pinchTarget}
class={style.outputCanvas}
ref={linkRef(this, 'canvasRight')}
width={rightDraw && rightDraw.width}
height={rightDraw && rightDraw.height}
@ -303,14 +228,8 @@ export default class Output extends Component<Props, State> {
</pinch-zoom>
</two-up>
<div class={style.back}>
<button class={style.button} onClick={onBack}>
<BackIcon />
</button>
</div>
<div class={style.controls}>
<div class={style.zoomControls}>
<div class={style.group}>
<button class={style.button} onClick={this.zoomOut}>
<RemoveIcon />
</button>
@ -324,11 +243,11 @@ export default class Output extends Component<Props, State> {
class={style.zoom}
value={Math.round(scale * 100)}
onInput={this.onScaleInputChanged}
onBlur={this.onScaleInputBlur}
onBlur={this.cancelEditScale}
/>
) : (
<span class={style.zoom} tabIndex={0} onFocus={this.onScaleValueFocus}>
<span class={style.zoomValue}>{Math.round(scale * 100)}</span>
<span class={style.zoom} tabIndex={0} onFocus={this.editScale}>
<strong>{Math.round(scale * 100)}</strong>
%
</span>
)}
@ -336,21 +255,10 @@ export default class Output extends Component<Props, State> {
<AddIcon />
</button>
</div>
<div class={style.buttonsNoWrap}>
<button class={style.button} onClick={this.onRotateClick} title="Rotate image">
<RotateIcon />
</button>
<button
class={`${style.button} ${altBackground ? style.active : ''}`}
onClick={this.toggleBackground}
title="Change canvas color"
>
{altBackground
? <ToggleBackgroundActiveIcon />
: <ToggleBackgroundIcon />
}
</button>
</div>
<button class={style.button} onClick={this.toggleBackground}>
<ToggleIcon />
Toggle Background
</button>
</div>
</div>
);

View File

@ -1,82 +1,135 @@
.output {
composes: abs-fill from '../../lib/util.scss';
/*
Note: These styles are temporary. They will be replaced before going live.
*/
&::before {
%fill {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: hidden;
contain: strict;
}
.output {
@extend %fill;
&:before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
@extend %fill;
background: #000;
opacity: 0;
transition: opacity 500ms ease;
}
&.alt-background::before {
opacity: 0.6;
&.altBackground:before {
opacity: .6;
}
}
.two-up {
composes: abs-fill from '../../lib/util.scss';
--accent-color: var(--button-fg);
}
> two-up {
@extend %fill;
--accent-color: var(--button-fg);
.pinch-zoom {
composes: abs-fill from '../../lib/util.scss';
outline: none;
display: flex;
justify-content: center;
align-items: center;
}
.pinch-target {
// This fixes a severe painting bug in Chrome.
// We should try to remove this once the issue is fixed.
// https://bugs.chromium.org/p/chromium/issues/detail?id=870222#c10
will-change: auto;
// Prevent the image becoming misshapen due to default flexbox layout.
flex-shrink: 0;
> pinch-zoom {
@extend %fill;
outline: none;
display: flex;
justify-content: center;
align-items: center;
}
}
}
.controls {
position: absolute;
display: flex;
justify-content: center;
top: 0;
left: 0;
right: 0;
padding: 9px 84px;
left: 220px;
right: 220px;
bottom: 0;
padding: 9px;
overflow: hidden;
flex-wrap: wrap;
contain: content;
// Allow clicks to fall through to the pinch zoom area
pointer-events: none;
& > * {
pointer-events: auto;
@media (max-width: 680px) {
top: 0;
bottom: auto;
left: 0;
right: 0;
}
@media (min-width: 860px) {
padding: 9px;
top: auto;
left: 320px;
right: 320px;
bottom: 0;
flex-wrap: wrap-reverse;
> * {
z-index: 2;
}
}
.zoom-controls {
display: flex;
.group {
display: flex;
}
& :not(:first-child) {
.button,
.zoom {
display: flex;
align-items: center;
flex: 0;
box-sizing: border-box;
height: 48px;
padding: 0 16px;
margin: 4px;
background-color: #fff;
border: 1px solid rgba(0,0,0,0.2);
border-radius: 5px;
line-height: 1;
font-size: 110%;
white-space: nowrap;
&:focus {
box-shadow: 0 0 0 2px var(--button-fg);
outline: none;
z-index: 1;
}
}
.button {
text-transform: uppercase;
color: var(--button-fg);
cursor: pointer;
text-indent: 6px;
}
.button:hover {
background-color: #eee;
}
.zoom {
flex: 0 0 6em;
color: #625E80;
font: inherit;
cursor: text;
width: 6em;
text-align: center;
justify-content: center;
&:focus {
box-shadow: inset 0 1px 4px rgba(0,0,0,0.2), 0 0 0 2px var(--button-fg);
}
strong {
position: relative;
top: 1px;
margin: 0 3px 0 0;
color: #888;
font-weight: normal;
border-bottom: 1px dashed #999;
}
}
.group > :not(:first-child) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
margin-left: 0;
}
& :not(:last-child) {
.group > :not(:last-child) {
margin-right: 0;
border-right-width: 0;
border-top-right-radius: 0;
@ -84,83 +137,6 @@
}
}
.button,
.zoom {
display: flex;
align-items: center;
box-sizing: border-box;
margin: 4px;
background-color: #fff;
border: 1px solid rgba(0,0,0,0.2);
border-radius: 5px;
line-height: 1;
white-space: nowrap;
height: 36px;
padding: 0 8px;
cursor: pointer;
@media (min-width: 600px) {
height: 48px;
padding: 0 16px;
}
&:focus {
box-shadow: 0 0 0 2px var(--button-fg);
outline: none;
z-index: 1;
}
}
.button {
color: var(--button-fg);
&:hover {
background-color: #eee;
}
&.active {
background: #34B9EB;
color: #fff;
&:hover {
background: #32a3ce;
}
}
}
.zoom {
color: #625E80;
cursor: text;
width: 6em;
font: inherit;
text-align: center;
justify-content: center;
&:focus {
box-shadow: inset 0 1px 4px rgba(0,0,0,0.2), 0 0 0 2px var(--button-fg);
}
}
.zoom-value {
position: relative;
top: 1px;
margin: 0 3px 0 0;
color: #888;
border-bottom: 1px dashed #999;
}
.back {
position: absolute;
top: 0;
left: 0;
padding: 9px;
}
.buttons-no-wrap {
display: flex;
pointer-events: none;
& > * {
pointer-events: auto;
}
.output-canvas {
flex-shrink: 0;
}

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,40 +24,32 @@ import {
EncoderOptions,
encoderMap,
} from '../../codecs/encoders';
import {
PreprocessorState,
defaultPreprocessorState,
} from '../../codecs/preprocessors';
import { decodeImage } from '../../codecs/decoders';
import { cleanMerge, cleanSet } from '../../lib/clean-modify';
import Processor from '../../codecs/processor';
import { VectorResizeOptions, BitmapResizeOptions } from '../../codecs/resize/processor-meta';
import './custom-els/MultiPanel';
import Results from '../results';
import { ExpandIcon, CopyAcrossIconProps } from '../../lib/icons';
import SnackBarElement from '../../lib/SnackBar';
import { InputProcessorState, defaultInputProcessorState } from '../../codecs/input-processors';
type Orientation = 'horizontal' | 'vertical';
export interface SourceImage {
file: File | Fileish;
decoded: ImageData;
processed: ImageData;
data: ImageData;
vectorImage?: HTMLImageElement;
inputProcessorState: InputProcessorState;
}
interface SideSettings {
preprocessorState: PreprocessorState;
encoderState: EncoderState;
}
interface Side {
interface EncodedImage {
preprocessed?: ImageData;
file?: Fileish;
downloadUrl?: string;
data?: ImageData;
latestSettings: SideSettings;
encodedSettings?: SideSettings;
preprocessorState: PreprocessorState;
encoderState: EncoderState;
loading: boolean;
/** Counter of the latest bmp currently encoding */
loadingCounter: number;
@ -67,45 +59,27 @@ interface Side {
interface Props {
file: File | Fileish;
showSnack: SnackBarElement['showSnackbar'];
onBack: () => void;
onError: (msg: string) => void;
}
interface State {
source?: SourceImage;
sides: [Side, Side];
/** Source image load */
images: [EncodedImage, EncodedImage];
loading: boolean;
loadingCounter: number;
error?: string;
mobileView: boolean;
orientation: Orientation;
}
interface UpdateImageOptions {
skipPreprocessing?: boolean;
}
async function processInput(
data: ImageData,
inputProcessData: InputProcessorState,
processor: Processor,
) {
let processedData = data;
if (inputProcessData.rotate.rotate !== 0) {
processedData = await processor.rotate(processedData, inputProcessData.rotate);
}
return processedData;
}
async function preprocessImage(
source: SourceImage,
preprocessData: PreprocessorState,
processor: Processor,
): Promise<ImageData> {
let result = source.processed;
let result = source.data;
if (preprocessData.resize.enabled) {
if (preprocessData.resize.method === 'vector' && source.vectorImage) {
result = processor.vectorResize(
@ -154,26 +128,6 @@ async function compressImage(
);
}
function stateForNewSourceData(state: State, newSource: SourceImage): State {
let newState = { ...state };
for (const i of [0, 1]) {
// Ditch previous encodings
const downloadUrl = state.sides[i].downloadUrl;
if (downloadUrl) URL.revokeObjectURL(downloadUrl);
newState = cleanMerge(state, `sides.${i}`, {
preprocessed: undefined,
file: undefined,
downloadUrl: undefined,
data: undefined,
encodedSettings: undefined,
});
}
return newState;
}
async function processSvg(blob: Blob): Promise<HTMLImageElement> {
// Firefox throws if you try to draw an SVG to canvas that doesn't have width/height.
// In Chrome it loads, but drawImage behaves weirdly.
@ -181,7 +135,7 @@ async function processSvg(blob: Blob): Promise<HTMLImageElement> {
const parser = new DOMParser();
const text = await blobToText(blob);
const document = parser.parseFromString(text, 'image/svg+xml');
const svg = document.documentElement!;
const svg = document.documentElement;
if (svg.hasAttribute('width') && svg.hasAttribute('height')) {
return blobToImg(blob);
@ -199,66 +153,49 @@ async function processSvg(blob: Blob): Promise<HTMLImageElement> {
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')[];
const originalDocumentTitle = document.title;
export default class Compress extends Component<Props, State> {
widthQuery = window.matchMedia('(max-width: 599px)');
widthQuery = window.matchMedia('(min-width: 500px)');
state: State = {
source: undefined,
loading: false,
loadingCounter: 0,
sides: [
images: [
{
latestSettings: {
preprocessorState: defaultPreprocessorState,
encoderState: { type: identity.type, options: identity.defaultOptions },
},
preprocessorState: defaultPreprocessorState,
encoderState: { type: identity.type, options: identity.defaultOptions },
loadingCounter: 0,
loadedCounter: 0,
loading: false,
},
{
latestSettings: {
preprocessorState: defaultPreprocessorState,
encoderState: { type: mozJPEG.type, options: mozJPEG.defaultOptions },
},
preprocessorState: defaultPreprocessorState,
encoderState: { type: mozJPEG.type, options: mozJPEG.defaultOptions },
loadingCounter: 0,
loadedCounter: 0,
loading: false,
},
],
mobileView: this.widthQuery.matches,
orientation: this.widthQuery.matches ? 'horizontal' : 'vertical',
};
private readonly encodeCache = new ResultCache();
private readonly leftProcessor = new Processor();
private readonly rightProcessor = new Processor();
// For debouncing calls to updateImage for each side.
private readonly updateImageTimeoutIds: [number?, number?] = [undefined, undefined];
constructor(props: Props) {
super(props);
this.widthQuery.addListener(this.onMobileWidthChange);
this.updateFile(props.file);
import('../../lib/offliner').then(({ mainAppLoaded }) => mainAppLoaded());
}
@bind
private onMobileWidthChange() {
this.setState({ mobileView: this.widthQuery.matches });
this.setState({ orientation: this.widthQuery.matches ? 'horizontal' : 'vertical' });
}
private onEncoderTypeChange(index: 0 | 1, newType: EncoderType): void {
this.setState({
sides: cleanSet(this.state.sides, `${index}.latestSettings.encoderState`, {
images: cleanSet(this.state.images, `${index}.encoderState`, {
type: newType,
options: encoderMap[newType].defaultOptions,
}),
@ -267,146 +204,62 @@ export default class Compress extends Component<Props, State> {
private onPreprocessorOptionsChange(index: 0 | 1, options: PreprocessorState): void {
this.setState({
sides: cleanSet(this.state.sides, `${index}.latestSettings.preprocessorState`, options),
images: cleanSet(this.state.images, `${index}.preprocessorState`, options),
});
}
private onEncoderOptionsChange(index: 0 | 1, options: EncoderOptions): void {
this.setState({
sides: cleanSet(this.state.sides, `${index}.latestSettings.encoderState.options`, options),
images: cleanSet(this.state.images, `${index}.encoderState.options`, options),
});
}
private updateDocumentTitle(filename: string = ''): void {
document.title = filename ? `${filename} - ${originalDocumentTitle}` : originalDocumentTitle;
}
componentWillReceiveProps(nextProps: Props): void {
if (nextProps.file !== this.props.file) {
this.updateFile(nextProps.file);
}
}
componentWillUnmount(): void {
this.updateDocumentTitle();
}
componentDidUpdate(prevProps: Props, prevState: State): void {
const { source, sides } = this.state;
const { source, images } = this.state;
const sourceDataChanged =
// Has the source object become set/unset?
!!source !== !!prevState.source ||
// Or has the processed data changed?
(source && prevState.source && source.processed !== prevState.source.processed);
for (const [i, side] of sides.entries()) {
const prevSettings = prevState.sides[i].latestSettings;
const encoderChanged = side.latestSettings.encoderState !== prevSettings.encoderState;
const preprocessorChanged =
side.latestSettings.preprocessorState !== prevSettings.preprocessorState;
for (const [i, image] of images.entries()) {
const prevImage = prevState.images[i];
const sourceChanged = source !== prevState.source;
const encoderChanged = image.encoderState !== prevImage.encoderState;
const preprocessorChanged = image.preprocessorState !== prevImage.preprocessorState;
// The image only needs updated if the encoder/preprocessor settings have changed, or the
// source has changed.
if (sourceDataChanged || encoderChanged || preprocessorChanged) {
this.queueUpdateImage(i, {
skipPreprocessing: !sourceDataChanged && !preprocessorChanged,
if (sourceChanged || encoderChanged || preprocessorChanged) {
if (prevImage.downloadUrl) URL.revokeObjectURL(prevImage.downloadUrl);
this.updateImage(i, {
skipPreprocessing: !sourceChanged && !preprocessorChanged,
}).catch((err) => {
console.error(err);
});
}
}
}
private async onCopyToOtherClick(index: 0 | 1) {
private onCopyToOtherClick(index: 0 | 1) {
const otherIndex = (index + 1) % 2;
const oldSettings = this.state.sides[otherIndex];
const newSettings = { ...this.state.sides[index] };
// Create a new object URL for the new settings. This avoids both sides sharing a URL, which
// means it can be safely revoked without impacting the other side.
if (newSettings.file) newSettings.downloadUrl = URL.createObjectURL(newSettings.file);
this.setState({
sides: cleanSet(this.state.sides, otherIndex, newSettings),
images: cleanSet(this.state.images, otherIndex, this.state.images[index]),
});
const result = await this.props.showSnack('Settings copied across', {
timeout: 5000,
actions: ['undo', 'dismiss'],
});
if (result !== 'undo') return;
this.setState({
sides: cleanSet(this.state.sides, otherIndex, oldSettings),
});
}
@bind
private async onInputProcessorChange(options: InputProcessorState): Promise<void> {
const source = this.state.source;
if (!source) return;
const oldRotate = source.inputProcessorState.rotate.rotate;
const newRotate = options.rotate.rotate;
const orientationChanged = oldRotate % 180 !== newRotate % 180;
const loadingCounter = this.state.loadingCounter + 1;
// Either processor is good enough here.
const processor = this.leftProcessor;
this.setState({
loadingCounter, loading: true,
source: cleanSet(source, 'inputProcessorState', options),
});
// Abort any current encode jobs, as they're redundant now.
this.leftProcessor.abortCurrent();
this.rightProcessor.abortCurrent();
try {
const processed = await processInput(source.decoded, options, processor);
// Another file has been opened/processed before this one processed.
if (this.state.loadingCounter !== loadingCounter) return;
let newState = { ...this.state, loading: false };
newState = cleanSet(newState, 'source.processed', processed);
newState = stateForNewSourceData(newState, newState.source!);
if (orientationChanged) {
// If orientation has changed, we should flip the resize values.
for (const i of [0, 1]) {
const resizeSettings = newState.sides[i].latestSettings.preprocessorState.resize;
newState = cleanMerge(newState, `sides.${i}.latestSettings.preprocessorState.resize`, {
width: resizeSettings.height,
height: resizeSettings.width,
});
}
}
this.setState(newState);
} catch (err) {
if (err.name === 'AbortError') return;
console.error(err);
// Another file has been opened/processed before this one processed.
if (this.state.loadingCounter !== loadingCounter) return;
this.props.showSnack('Processing error');
this.setState({ loading: false });
}
}
@bind
private async updateFile(file: File | Fileish) {
const loadingCounter = this.state.loadingCounter + 1;
// Either processor is good enough here.
const processor = this.leftProcessor;
this.setState({ loadingCounter, loading: true });
this.setState({ loading: true });
// Abort any current encode jobs, as they're redundant now.
this.leftProcessor.abortCurrent();
this.rightProcessor.abortCurrent();
try {
let decoded: ImageData;
let data: ImageData;
let vectorImage: HTMLImageElement | undefined;
// Special-case SVG. We need to avoid createImageBitmap because of
@ -414,96 +267,68 @@ export default class Compress extends Component<Props, State> {
// Also, we cache the HTMLImageElement so we can perform vector resizing later.
if (file.type.startsWith('image/svg+xml')) {
vectorImage = await processSvg(file);
decoded = drawableToImageData(vectorImage);
data = drawableToImageData(vectorImage);
} else {
// Either processor is good enough here.
decoded = await decodeImage(file, processor);
data = await decodeImage(file, this.leftProcessor);
}
const processed = await processInput(decoded, defaultInputProcessorState, processor);
// Another file has been opened/processed before this one processed.
if (this.state.loadingCounter !== loadingCounter) return;
let newState: State = {
...this.state,
source: {
decoded, file, vectorImage, processed,
inputProcessorState: defaultInputProcessorState,
},
source: { data, file, vectorImage },
loading: false,
};
newState = stateForNewSourceData(newState, newState.source!);
for (const i of [0, 1]) {
// Ditch previous encodings
const downloadUrl = this.state.images[i].downloadUrl;
if (downloadUrl) URL.revokeObjectURL(downloadUrl!);
newState = cleanMerge(newState, `images.${i}`, {
preprocessed: undefined,
file: undefined,
downloadUrl: undefined,
data: undefined,
});
// Default resize values come from the image:
newState = cleanMerge(newState, `sides.${i}.latestSettings.preprocessorState.resize`, {
width: processed.width,
height: processed.height,
newState = cleanMerge(newState, `images.${i}.preprocessorState.resize`, {
width: data.width,
height: data.height,
method: vectorImage ? 'vector' : 'browser-high',
});
}
this.updateDocumentTitle(file.name);
this.setState(newState);
} catch (err) {
if (err.name === 'AbortError') return;
console.error(err);
// Another file has been opened/processed before this one processed.
if (this.state.loadingCounter !== loadingCounter) return;
this.props.showSnack('Invalid image');
this.props.onError('Invalid image');
this.setState({ loading: false });
}
}
/**
* Debounce the heavy lifting of updateImage.
* Otherwise, the thrashing causes jank, and sometimes crashes iOS Safari.
*/
private queueUpdateImage(index: number, options: UpdateImageOptions = {}): void {
// Call updateImage after this delay, unless queueUpdateImage is called again, in which case the
// timeout is reset.
const delay = 100;
clearTimeout(this.updateImageTimeoutIds[index]);
this.updateImageTimeoutIds[index] = self.setTimeout(
() => {
this.updateImage(index, options).catch((err) => {
console.error(err);
});
},
delay,
);
}
private async updateImage(index: number, options: UpdateImageOptions = {}): Promise<void> {
const {
skipPreprocessing = false,
} = options;
const { skipPreprocessing = false } = options;
const { source } = this.state;
if (!source) return;
// Each time we trigger an async encode, the counter changes.
const loadingCounter = this.state.sides[index].loadingCounter + 1;
const loadingCounter = this.state.images[index].loadingCounter + 1;
let sides = cleanMerge(this.state.sides, index, {
let images = cleanMerge(this.state.images, index, {
loadingCounter,
loading: true,
});
this.setState({ sides });
this.setState({ images });
const side = sides[index];
const settings = side.latestSettings;
const image = images[index];
let file: File | Fileish | undefined;
let preprocessed: ImageData | undefined;
let data: ImageData | undefined;
const cacheResult = this.encodeCache.match(
source.processed, settings.preprocessorState, settings.encoderState,
);
const cacheResult = this.encodeCache.match(source, image.preprocessorState, image.encoderState);
const processor = (index === 0) ? this.leftProcessor : this.rightProcessor;
// Abort anything the processor is currently doing.
@ -516,135 +341,83 @@ export default class Compress extends Component<Props, State> {
} else {
try {
// Special case for identity
if (settings.encoderState.type === identity.type) {
file = source.file;
data = source.processed;
if (image.encoderState.type === identity.type) {
({ file, data } = source);
} else {
preprocessed = (skipPreprocessing && side.preprocessed)
? side.preprocessed
: await preprocessImage(source, settings.preprocessorState, processor);
preprocessed = (skipPreprocessing && image.preprocessed)
? image.preprocessed
: await preprocessImage(source, image.preprocessorState, processor);
file = await compressImage(
preprocessed, settings.encoderState, source.file.name, processor,
);
file = await compressImage(preprocessed, image.encoderState, source.file.name, processor);
data = await decodeImage(file, processor);
this.encodeCache.add({
source,
data,
preprocessed,
file,
sourceData: source.processed,
encoderState: settings.encoderState,
preprocessorState: settings.preprocessorState,
encoderState: image.encoderState,
preprocessorState: image.preprocessorState,
});
}
} catch (err) {
if (err.name === 'AbortError') return;
this.props.showSnack(`Processing error (type=${settings.encoderState.type}): ${err}`);
this.props.onError(`Processing error (type=${image.encoderState.type}): ${err}`);
throw err;
}
}
const latestData = this.state.sides[index];
const latestImage = this.state.images[index];
// If a later encode has landed before this one, return.
if (loadingCounter < latestData.loadedCounter) {
if (loadingCounter < latestImage.loadedCounter) {
return;
}
if (latestData.downloadUrl) URL.revokeObjectURL(latestData.downloadUrl);
sides = cleanMerge(this.state.sides, index, {
images = cleanMerge(this.state.images, index, {
file,
data,
preprocessed,
downloadUrl: URL.createObjectURL(file),
loading: sides[index].loadingCounter !== loadingCounter,
loading: images[index].loadingCounter !== loadingCounter,
loadedCounter: loadingCounter,
encodedSettings: settings,
});
this.setState({ sides });
this.setState({ images });
}
render({ onBack }: Props, { loading, sides, source, mobileView }: State) {
const [leftSide, rightSide] = sides;
const [leftImageData, rightImageData] = sides.map(i => i.data);
const options = sides.map((side, index) => (
<Options
source={source}
mobileView={mobileView}
preprocessorState={side.latestSettings.preprocessorState}
encoderState={side.latestSettings.encoderState}
onEncoderTypeChange={this.onEncoderTypeChange.bind(this, index as 0|1)}
onEncoderOptionsChange={this.onEncoderOptionsChange.bind(this, index as 0|1)}
onPreprocessorOptionsChange={this.onPreprocessorOptionsChange.bind(this, index as 0|1)}
/>
));
const copyDirections =
(mobileView ? ['down', 'up'] : ['right', 'left']) as CopyAcrossIconProps['copyDirection'][];
const results = sides.map((side, index) => (
<Results
downloadUrl={side.downloadUrl}
imageFile={side.file}
source={source}
loading={loading || side.loading}
copyDirection={copyDirections[index]}
onCopyToOtherClick={this.onCopyToOtherClick.bind(this, index as 0|1)}
buttonPosition={mobileView ? 'stack-right' : buttonPositions[index]}
>
{!mobileView ? null : [
<ExpandIcon class={style.expandIcon} key="expand-icon"/>,
`${resultTitles[index]} (${encoderMap[side.latestSettings.encoderState.type].label})`,
]}
</Results>
));
// For rendering, we ideally want the settings that were used to create the data, not the latest
// settings.
const leftDisplaySettings = leftSide.encodedSettings || leftSide.latestSettings;
const rightDisplaySettings = rightSide.encodedSettings || rightSide.latestSettings;
const leftImgContain = leftDisplaySettings.preprocessorState.resize.enabled &&
leftDisplaySettings.preprocessorState.resize.fitMethod === 'contain';
const rightImgContain = rightDisplaySettings.preprocessorState.resize.enabled &&
rightDisplaySettings.preprocessorState.resize.fitMethod === 'contain';
render({ }: Props, { loading, images, source, orientation }: State) {
const [leftImage, rightImage] = images;
const [leftImageData, rightImageData] = images.map(i => i.data);
const anyLoading = loading || images.some(image => image.loading);
return (
<div class={style.compress}>
<Output
source={source}
mobileView={mobileView}
originalImage={source && source.data}
orientation={orientation}
leftCompressed={leftImageData}
rightCompressed={rightImageData}
leftImgContain={leftImgContain}
rightImgContain={rightImgContain}
onBack={onBack}
inputProcessorState={source && source.inputProcessorState}
onInputProcessorChange={this.onInputProcessorChange}
leftImgContain={leftImage.preprocessorState.resize.fitMethod === 'cover'}
rightImgContain={rightImage.preprocessorState.resize.fitMethod === 'cover'}
/>
{mobileView
? (
<div class={style.options}>
<multi-panel class={style.multiPanel} open-one-only>
{results[0]}
{options[0]}
{results[1]}
{options[1]}
</multi-panel>
</div>
) : ([
<div class={style.options} key="options0">
{options[0]}
{results[0]}
</div>,
<div class={style.options} key="options1">
{options[1]}
{results[1]}
</div>,
])
}
<div class={`${style.optionPair} ${style[orientation]}`}>
{images.map((image, index) => (
<Options
source={source}
orientation={orientation}
imageIndex={index}
imageFile={image.file}
downloadUrl={image.downloadUrl}
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>
{anyLoading && <span style={{ position: 'fixed', top: 0, left: 0 }}>Loading...</span>}
</div>
);
}

View File

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

View File

@ -1,74 +1,19 @@
.compress {
height: 100%;
}
.option-pair {
display: flex;
justify-content: flex-end;
width: 100%;
height: 100%;
contain: strict;
display: grid;
align-items: end;
align-content: end;
grid-template-rows: 1fr auto;
@media (min-width: 600px) {
grid-template-columns: 1fr auto;
grid-template-rows: 100%;
&.horizontal {
justify-content: space-between;
align-items: flex-end;
}
&.vertical {
flex-direction: column;
}
}
.options {
color: #fff;
opacity: 0.9;
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

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

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

View File

@ -2,7 +2,6 @@
font-family: 'intro-text';
font-style: normal;
font-weight: 300;
font-display: block;
// 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');
}
@ -11,7 +10,6 @@
font-family: 'intro-text';
font-style: normal;
font-weight: 500;
font-display: block;
// 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');
}
@ -21,6 +19,7 @@
}
.intro {
composes: abs-fill from '../../lib/util.scss';
display: grid;
grid-template-rows: 1fr min-content;
align-items: center;
@ -30,9 +29,6 @@
-webkit-overflow-scrolling: touch;
overflow: auto;
padding: 20px 0 0;
height: 100%;
box-sizing: border-box;
overscroll-behavior: contain;
}
.logo-container {
@ -42,17 +38,16 @@
.logo-sizer {
width: 90%;
max-width: 52vh;
max-width: 480px;
margin: 0 auto;
}
.logo {
composes: abs-fill from '../../lib/util.scss';
pointer-events: none;
}
.open-image-guide {
font: 300 11vw intro-text, sans-serif;
font: 300 11vw intro-text;
margin-bottom: 0;
@media (min-width: 460px) {
@ -145,7 +140,6 @@
.demo-icon {
composes: abs-fill from '../../lib/util.scss';
pointer-events: none;
}
.demo-description {

View File

@ -1,75 +1,19 @@
import * as style from './styles.css';
import { transitionHeight } from '../../../../lib/util';
import './styles.css';
interface CloseAllOptions {
exceptFirst?: boolean;
}
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;
function getClosestHeading(el: Element) {
const closestEl = el.closest('multi-panel > *');
if (closestEl && closestEl.classList.contains('panel-heading')) {
return closestEl;
}
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 panel' consists of two elements. Even index element becomes heading,
* and odd index element becomes the expandable content.
*/
export default class MultiPanel extends HTMLElement {
static get observedAttributes() { return [openOneOnlyAttr]; }
constructor() {
super();
@ -87,23 +31,17 @@ export default class MultiPanel extends HTMLElement {
this._childrenChange();
}
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) {
if (name === openOneOnlyAttr && newValue === null) {
this._closeAll({ exceptFirst: true });
}
}
// Click event handler
private _onClick(event: MouseEvent) {
const el = event.target as HTMLElement;
const el = event.target as Element;
const heading = getClosestHeading(el);
if (!heading) return;
this._toggle(heading);
this._expand(heading);
}
// KeyDown event handler
private _onKeyDown(event: KeyboardEvent) {
const selectedEl = document.activeElement!;
const selectedEl = document.activeElement;
const heading = getClosestHeading(selectedEl);
// if keydown event is not on heading element, ignore
@ -115,8 +53,7 @@ export default class MultiPanel extends HTMLElement {
// dont handle modifier shortcuts used by assistive technology.
if (event.altKey) return;
let newHeading: HTMLElement | undefined;
let newHeading:HTMLElement | undefined;
switch (event.key) {
case 'ArrowLeft':
case 'ArrowUp':
@ -140,7 +77,7 @@ export default class MultiPanel extends HTMLElement {
case 'Enter':
case ' ':
case 'Spacebar':
this._toggle(heading);
this._expand(heading);
break;
// 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;
const content = heading.nextElementSibling;
// if there is no content, nothing to expand
if (!content) return;
// toggle expanded and aria-expanded attributes
if (heading.hasAttribute('content-expanded')) {
close(heading);
if (content.hasAttribute('expanded')) {
content.removeAttribute('expanded');
content.setAttribute('aria-expanded', 'false');
} else {
if (this.openOneOnly) this._closeAll();
open(heading);
content.setAttribute('expanded', '');
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)
private _childrenChange() {
let preserveTabIndex = false;
let preserveTabIndex : boolean = false;
let heading = this.firstElementChild;
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.
if (!content) {
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,
// what was heading before may become content after the insertion.
// Remove classes and attributes to prepare for this change.
heading.classList.remove(style.panelContent);
content.classList.remove(style.panelHeading);
heading.removeAttribute('aria-expanded');
heading.removeAttribute('content-expanded');
heading.classList.remove('panel-content');
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');
}
// If appreciable, remove tabindex from content which used to be header.
content.removeAttribute('tabindex');
if (content.hasAttribute('tabindex')) {
content.removeAttribute('tabindex');
}
// Assign heading and content classes
heading.classList.add(style.panelHeading);
content.classList.add(style.panelContent);
heading.classList.add('panel-heading');
content.classList.add('panel-content');
// Assign ids and aria-X for heading/content pair.
heading.id = `panel-heading-${randomId}`;
@ -224,13 +163,6 @@ export default class MultiPanel extends HTMLElement {
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
heading = content.nextElementSibling;
}
@ -239,9 +171,6 @@ export default class MultiPanel extends HTMLElement {
if (!preserveTabIndex && this.firstElementChild) {
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.
@ -252,8 +181,8 @@ export default class MultiPanel extends HTMLElement {
return this.firstElementChild as HTMLElement;
}
// previous Element of active Element is previous Content,
// previous Element of previous Content is previousHeading
const previousContent = document.activeElement!.previousElementSibling;
// previous Element of previous Content is previousHeading
const previousContent = document.activeElement.previousElementSibling;
if (previousContent) {
return previousContent.previousElementSibling as HTMLElement;
}
@ -263,7 +192,7 @@ export default class MultiPanel extends HTMLElement {
private _nextHeading() {
// activeElement would be the currently selected heading
// 2 elemements after that would be the next heading.
const nextContent = document.activeElement!.nextElementSibling;
const nextContent = document.activeElement.nextElementSibling;
if (nextContent) {
return nextContent.nextElementSibling as HTMLElement;
}
@ -279,7 +208,7 @@ export default class MultiPanel extends HTMLElement {
private _lastHeading() {
// if the last element is heading, return last element
const lastEl = this.lastElementChild as HTMLElement;
if (lastEl && lastEl.classList.contains(style.panelHeading)) {
if (lastEl && lastEl.classList.contains('panel-heading')) {
return lastEl;
}
// otherwise return 2nd from the last
@ -288,21 +217,6 @@ export default class MultiPanel extends 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);

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,57 +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;
const value = input.value.trim();
if (!value) return;
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 * as style from './styles.css';
import './styles.css';
const RETARGETED_EVENTS = ['focus', 'blur'];
const UPDATE_EVENTS = ['input', 'change'];
const REFLECTED_PROPERTIES = ['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 {
private _input: HTMLInputElement;
private _valueDisplay?: HTMLDivElement;
private _input = document.createElement('input');
private _valueDisplayWrapper = document.createElement('div');
private _valueDisplay = document.createElement('span');
private _ignoreChange = false;
static get observedAttributes() {
@ -23,20 +18,7 @@ class RangeInputElement extends HTMLElement {
constructor() {
super();
this._input = document.createElement('input');
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) {
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 {
return this.getAttribute('label-precision') || '';
}
@ -69,6 +37,14 @@ class RangeInputElement extends HTMLElement {
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) {
if (this._ignoreChange) return;
if (newValue === null) {
@ -89,15 +65,15 @@ class RangeInputElement extends HTMLElement {
@bind
private _update() {
const value = Number(this.value) || 0;
const min = Number(this.min) || 0;
const max = Number(this.max) || 100;
const labelPrecision = Number(this.labelPrecision) || getPrescision(this.step) || 0;
const value = parseFloat(this.value || '0');
const min = parseFloat(this.min || '0');
const max = parseFloat(this.max || '100');
const labelPrecision = parseFloat(this.labelPrecision || '0');
const percent = 100 * (value - min) / (max - min);
const displayValue = labelPrecision ? value.toFixed(labelPrecision) :
const displayValue = labelPrecision ? value.toPrecision(labelPrecision) :
Math.round(value).toString();
this._valueDisplay!.textContent = displayValue;
this._valueDisplay.textContent = displayValue;
this.style.setProperty('--value-percent', percent + '%');
this.style.setProperty('--value-width', '' + displayValue.length);
}

View File

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

Some files were not shown because too many files have changed in this diff Show More