Compare commits

..

59 Commits

Author SHA1 Message Date
Surma
668acf2698 Add karma to run unit tests 2018-05-29 23:11:43 +01:00
Surma
7042491257 Add Dockerfile for Travis 2018-05-29 22:39:43 +01:00
Surma
307e1f9356 Implement e2e tests 2018-05-29 22:39:15 +01:00
Surma
60543dd0a5 Merge pull request #42 from GoogleChromeLabs/commit-binaries
Commit binaries
2018-05-29 16:21:51 +02:00
Surma
850a019212 Update README with dependencies 2018-05-29 15:39:46 +02:00
Surma
9c0e0b683e Add codec binaries 2018-05-29 15:37:27 +02:00
Jason Miller
79dfe39978 Remove superfluous mozjpeg dep 2018-05-23 13:01:53 +00:00
Surma
96a61eb0b2 Merge pull request #38 from GoogleChromeLabs/build-fixes
Fix codec integration
2018-05-23 11:46:08 +02:00
Surma
e62fc26dfd Properly enforce ArrayBuffers for codec results 2018-05-23 11:09:35 +02:00
Jason Miller
638c57b6fe Fix codec integration (builds and runs on chromebook!) 2018-05-23 01:00:48 +00:00
Surma
7ff18e6ae1 Merge pull request #35 from GoogleChromeLabs/load-codec
Load mozjpeg codec and encode image
2018-05-22 14:23:10 +02:00
Surma
9d8f885556 Remove SharedArrayBuffer as an option 2018-05-21 13:49:26 +01:00
Surma
5245c5ca6e Put bitmapToImageData into utils module 2018-05-21 13:46:29 +01:00
Surma
19342208d2 Add explanation on infinite loop bug 2018-05-21 13:38:13 +01:00
Surma
a9e1c38971 Style nitz 2018-05-21 13:36:05 +01:00
Surma
1533728f59 Add types to module initialize func 2018-05-21 13:34:42 +01:00
Surma
d4a616713a Simplify webpack config 2018-05-21 13:29:24 +01:00
Jake Archibald
a7598b6602 Integrating two-up (#34) 2018-05-18 14:52:00 +01:00
Surma
e38e7154a6 Disable auto-run just to be safe 2018-05-17 22:33:21 +01:00
Surma
7a5c8f5d6b Typings for cwrap API 2018-05-17 22:31:20 +01:00
Surma
49db0de05f Actually piping the data through the compressor 2018-05-17 22:27:24 +01:00
Surma
8daaea5768 Fixed the freeze bug thing 2018-05-17 16:19:16 +01:00
Surma
c2e2a1a0b6 Succesfully load wasm file via webpack 2018-05-17 16:04:56 +01:00
Surma
7edb7f0de8 Wrangling TypeScript and webpack to work with Emscripten wasm stuff 2018-05-17 11:24:40 +01:00
Surma
634dfe3717 Merge pull request #28 from GoogleChromeLabs/codecs
Basic codec setup
2018-05-15 17:18:29 +01:00
Surma
1b4526ca1e Deduplicate example image 2018-05-15 17:14:29 +01:00
Jason Miller
5e2c4be0c6 Merge pull request #20 from GoogleChromeLabs/firebase
Add firebase hosting
2018-05-15 11:59:48 -04:00
Jason Miller
e9eaf227bc Merge branch 'master' into firebase 2018-05-15 11:59:20 -04:00
Surma
6249ca8ac8 Add examples and codec-specific documentation 2018-05-15 16:22:23 +01:00
Surma
03a6716745 Generate proper version number for mozjpeg 2018-05-15 15:16:02 +01:00
Surma
ddf8409127 Properly split encoder and decoder 2018-05-15 13:23:41 +01:00
Jake Archibald
bcf71f4702 Using @bind (#29) 2018-05-15 05:06:04 -07:00
Jake Archibald
31db4b9719 Ignoring TS'd CSS 2018-05-15 11:49:08 +01:00
Surma
953a0c9124 Basic codec setup 2018-05-14 13:22:20 +01:00
Jake Archibald
444e59c69c Merging pinch-zoom (#27)
* Merging pinch-zoom

* Pixelated output
2018-05-04 20:03:57 +01:00
Jake Archibald
b619427237 Removing everything that isn't skeletonyy (#22)
* Simplifying

* Ignoring CSS defs
2018-05-04 09:20:34 -07:00
Jason Miller
5f7f9e32a8 Merge pull request #23 from GoogleChromeLabs/dont-transpile-classes
Don't transpile ES Classes.
2018-05-03 12:04:25 -07:00
Jason Miller
1196d4f54f Merge pull request #19 from GoogleChromeLabs/prerendering
Prerendering!
2018-05-03 12:01:30 -07:00
Jason Miller
e84d2dc7ee remove redundant PRERENDER define 2018-05-03 12:01:13 -07:00
Jason Miller
81aaadbabf Remove all prerendering & critical CSS stuff 2018-05-01 09:52:58 -04:00
Jason Miller
311d0524db improve tests 2018-04-24 14:03:44 -04:00
Jason Miller
da53b5fedc Add more long-term caching headers, fix missing hash in main.css, switch Workbox to use locally generated files instead of Fastly (improves TTI), delay SW install by 1s. 2018-04-22 00:36:58 -04:00
Jason Miller
c5e3f9e737 Remove env preset and decorator plugin 2018-04-18 14:44:43 -04:00
Jason Miller
540b3c8154 Merge branch 'master' into prerendering 2018-04-17 21:35:28 -04:00
Jason Miller
06642fd047 Merge pull request #18 from GoogleChromeLabs/critters-async-await
Critters: async/await & font handling
2018-04-17 21:33:59 -04:00
Jason Miller
3b47ee6fe5 Don't transpile ES Classes. 2018-04-17 21:26:28 -04:00
Jason Miller
058cce1d49 Merge pull request #17 from GoogleChromeLabs/babel-fixes
Only use Babel to process .js files.
2018-04-17 17:11:03 -04:00
Jason Miller
2078b57dae Merge pull request #12 from GoogleChromeLabs/css-inlining
CSS Inlining
2018-04-17 17:10:43 -04:00
Jason Miller
aa02cf2157 Add firebase hosting 2018-04-17 15:34:17 -04:00
Jason Miller
11bebfc836 A11y: add aria attribute to give the upload field a name 2018-04-17 14:08:49 -04:00
Jason Miller
dec93a724f Fix for Workbox precaching way too many files 2018-04-17 14:08:23 -04:00
Jason Miller
411614b731 Output minimal stats to the console during build 2018-04-17 14:07:57 -04:00
Jason Miller
896d267de5 Add plugin to make script loading async 2018-04-17 14:07:42 -04:00
Jason Miller
e0c59577a4 Lint webpack config, and only preload initial chunks 2018-04-17 14:07:22 -04:00
Jason Miller
5936c57a82 Clean up app to remove old prerendering bits 2018-04-17 14:05:50 -04:00
Jason Miller
3ba0a5a22a Adds single-pass prerendering via a new prerender-loader 2018-04-17 14:04:55 -04:00
Jason Miller
be8fae10f8 Critters: move to async/await (fixes #16), add critical font inlining / preloading 2018-04-17 13:57:48 -04:00
Jason Miller
b911e960a8 Merge pull request #1 from GoogleChromeLabs/app-skeleton
App skeleton
2018-04-17 13:46:16 -04:00
Jason Miller
718443de30 Only use Babel to process .js files. 2018-04-17 13:42:25 -04:00
70 changed files with 10172 additions and 5086 deletions

View File

@@ -1,33 +1,13 @@
{
"presets": [
[
"env",
{
"loose": true,
"uglify": true,
"modules": false,
"targets": {
"browsers": "last 2 versions"
},
"exclude": [
"transform-regenerator",
"transform-es2015-typeof-symbol"
]
}
]
],
"plugins": [
"syntax-dynamic-import",
"transform-decorators-legacy",
"transform-class-properties",
"transform-object-rest-spread",
"transform-react-constant-elements",
"transform-react-remove-prop-types",
[
"transform-react-jsx",
{
"pragma": "h"
}
]
]
}
"plugins": [
"transform-class-properties",
"transform-react-constant-elements",
"transform-react-remove-prop-types",
[
"transform-react-jsx",
{
"pragma": "h"
}
]
]
}

5
.firebaserc Normal file
View File

@@ -0,0 +1,5 @@
{
"projects": {
"default": "squoosh-beta"
}
}

4
.gitignore vendored
View File

@@ -1,3 +1,5 @@
node_modules
/build
/*.log
/*.log
*.scss.d.ts
*.css.d.ts

20
Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
FROM selenium/node-chrome:latest
USER root
RUN apt-get update -qqy \
&& apt-get -qqy install python git build-essential \
&& rm -rf /var/lib/apt/lists/* /var/cache/apt/* \
&& rm /bin/sh && ln -s /bin/bash /bin/sh \
&& chown seluser /usr/local
ENV NVM_DIR /usr/local/nvm
RUN wget -qO- https://raw.githubusercontent.com/creationix/nvm/v0.33.2/install.sh | bash \
&& source $NVM_DIR/nvm.sh \
&& nvm install v8
ENV CHROME_BIN /opt/google/chrome/chrome
ENV INSIDE_DOCKER=1
WORKDIR /usr/src
ENTRYPOINT source $NVM_DIR/nvm.sh && npm i && npm test

16
codecs/README.md Normal file
View File

@@ -0,0 +1,16 @@
# Codecs
This folder contains a self-contained sub-project for each encoder and decoder that squoosh supplies.
## Build
Each subproject can be built using [Docker](https://www.docker.com/) the following commands:
```
$ 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 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.

BIN
codecs/example.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

View File

@@ -0,0 +1,44 @@
# MozJPEG encoder
- Source: <https://github.com/mozilla/mozjpeg>
- Version: v3.3.1
## Dependencies
- Docker
- Automake
- pkg-config
## Example
See `example.html`
## API
### `int version()`
Returns the version of MozJPEG as a number. va.b.c is encoded as 0x0a0b0c
### `uint8_t* create_buffer(int width, int height)`
Allocates an RGBA buffer for an image with the given dimension.
### `void destroy_buffer(uint8_t* p)`
Frees a buffer created with `create_buffer`.
### `void encode(uint8_t* image_buffer, int image_width, int image_height, int quality)`
Encodes the given image with given dimension to JPEG. `quality` is a number between 0 and 100. The higher the number, the better the quality of the encoded image. The result is implicitly stored and can be accessed using the `get_result_*()` functions.
### `void free_result()`
Frees the result created by `encode()`.
### `int get_result_pointer()`
Returns the pointer to the start of the buffer holding the encoded data.
### `int get_result_size()`
Returns the length of the buffer holding the encoded data.

View File

@@ -0,0 +1,47 @@
<!doctype html>
<script src='mozjpeg_enc.js'></script>
<script>
const Module = mozjpeg_enc();
async function loadImage(src) {
// Load image
const img = document.createElement('img');
img.src = src;
await new Promise(resolve => img.onload = resolve);
// Make canvas same size as image
const canvas = document.createElement('canvas');
[canvas.width, canvas.height] = [img.width, img.height];
// Draw image onto canvas
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
return ctx.getImageData(0, 0, img.width, img.height);
}
Module.onRuntimeInitialized = async _ => {
const api = {
version: Module.cwrap('version', 'number', []),
create_buffer: Module.cwrap('create_buffer', 'number', ['number', 'number']),
destroy_buffer: Module.cwrap('destroy_buffer', '', ['number']),
encode: Module.cwrap('encode', '', ['number', 'number', 'number', 'number']),
free_result: Module.cwrap('free_result', '', ['number']),
get_result_pointer: Module.cwrap('get_result_pointer', 'number', []),
get_result_size: Module.cwrap('get_result_size', 'number', []),
};
const image = await loadImage('../example.png');
const p = api.create_buffer(image.width, image.height);
Module.HEAP8.set(image.data, p);
api.encode(p, image.width, image.height, 2);
const resultPointer = api.get_result_pointer();
const resultSize = api.get_result_size();
const resultView = new Uint8Array(Module.HEAP8.buffer, resultPointer, resultSize);
const result = new Uint8Array(resultView);
api.free_result(resultPointer);
api.destroy_buffer(p);
const blob = new Blob([result], {type: 'image/jpeg'});
const blobURL = URL.createObjectURL(blob);
const img = document.createElement('img');
img.src = blobURL;
document.body.appendChild(img);
};
</script>

View File

@@ -0,0 +1,178 @@
#include "emscripten.h"
#include <stdlib.h>
#include <inttypes.h>
#include <stdio.h>
#include <setjmp.h>
#include <string.h>
#include "jpeglib.h"
#include "config.h"
// MozJPEG doesnt expose a numeric version, so I have to do some fun C macro hackery to turn it into a string. More details here: https://gcc.gnu.org/onlinedocs/cpp/Stringizing.html
#define xstr(s) str(s)
#define str(s) #s
EMSCRIPTEN_KEEPALIVE
int version() {
char buffer[] = xstr(MOZJPEG_VERSION);
int version = 0;
int last_index = 0;
for(int i = 0; i < strlen(buffer); i++) {
if(buffer[i] == '.') {
buffer[i] = '\0';
version = version << 8 | atoi(&buffer[last_index]);
buffer[i] = '.';
last_index = i + 1;
}
}
version = version << 8 | atoi(&buffer[last_index]);
return version;
}
EMSCRIPTEN_KEEPALIVE
uint8_t* create_buffer(int width, int height) {
return malloc(width * height * 4 * sizeof(uint8_t));
}
EMSCRIPTEN_KEEPALIVE
void destroy_buffer(uint8_t* p) {
free(p);
}
int result[2];
EMSCRIPTEN_KEEPALIVE
void encode(uint8_t* image_buffer, int image_width, int image_height, int quality) {
// Manually convert RGBA data into RGB
for(int y = 0; y < image_height; y++) {
for(int x = 0; x < image_width; x++) {
image_buffer[(y*image_width + x)*3 + 0] = image_buffer[(y*image_width + x)*4 + 0];
image_buffer[(y*image_width + x)*3 + 1] = image_buffer[(y*image_width + x)*4 + 1];
image_buffer[(y*image_width + x)*3 + 2] = image_buffer[(y*image_width + x)*4 + 2];
}
}
// The code below is basically the `write_JPEG_file` function from
// https://github.com/mozilla/mozjpeg/blob/master/example.c
// I just write to memory instead of a file.
/* This struct contains the JPEG compression parameters and pointers to
* working space (which is allocated as needed by the JPEG library).
* It is possible to have several such structures, representing multiple
* compression/decompression processes, in existence at once. We refer
* to any one struct (and its associated working data) as a "JPEG object".
*/
struct jpeg_compress_struct cinfo;
/* This struct represents a JPEG error handler. It is declared separately
* because applications often want to supply a specialized error handler
* (see the second half of this file for an example). But here we just
* take the easy way out and use the standard error handler, which will
* print a message on stderr and call exit() if compression fails.
* Note that this struct must live as long as the main JPEG parameter
* struct, to avoid dangling-pointer problems.
*/
struct jpeg_error_mgr jerr;
/* More stuff */
JSAMPROW row_pointer[1]; /* pointer to JSAMPLE row[s] */
int row_stride; /* physical row width in image buffer */
uint8_t* output;
unsigned long size;
/* Step 1: allocate and initialize JPEG compression object */
/* We have to set up the error handler first, in case the initialization
* step fails. (Unlikely, but it could happen if you are out of memory.)
* This routine fills in the contents of struct jerr, and returns jerr's
* address which we place into the link field in cinfo.
*/
cinfo.err = jpeg_std_error(&jerr);
/* Now we can initialize the JPEG compression object. */
jpeg_create_compress(&cinfo);
/* Step 2: specify data destination (eg, a file) */
/* Note: steps 2 and 3 can be done in either order. */
/* Here we use the library-supplied code to send compressed data to a
* stdio stream. You can also write your own code to do something else.
* VERY IMPORTANT: use "b" option to fopen() if you are on a machine that
* requires it in order to write binary files.
*/
// if ((outfile = fopen(filename, "wb")) == NULL) {
// fprintf(stderr, "can't open %s\n", filename);
// exit(1);
// }
jpeg_mem_dest(&cinfo, &output, &size);
/* Step 3: set parameters for compression */
/* First we supply a description of the input image.
* Four fields of the cinfo struct must be filled in:
*/
cinfo.image_width = image_width; /* image width and height, in pixels */
cinfo.image_height = image_height;
cinfo.input_components = 3; /* # of color components per pixel */
cinfo.in_color_space = JCS_RGB; /* colorspace of input image */
/* Now use the library's routine to set default compression parameters.
* (You must set at least cinfo.in_color_space before calling this,
* since the defaults depend on the source color space.)
*/
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_quality(&cinfo, quality, TRUE /* limit to baseline-JPEG values */);
/* Step 4: Start compressor */
/* TRUE ensures that we will write a complete interchange-JPEG file.
* Pass TRUE unless you are very sure of what you're doing.
*/
jpeg_start_compress(&cinfo, TRUE);
/* Step 5: while (scan lines remain to be written) */
/* jpeg_write_scanlines(...); */
/* Here we use the library's state variable cinfo.next_scanline as the
* loop counter, so that we don't have to keep track ourselves.
* To keep things simple, we pass one scanline per call; you can pass
* more if you wish, though.
*/
row_stride = image_width * 3; /* JSAMPLEs per row in image_buffer */
while (cinfo.next_scanline < cinfo.image_height) {
/* jpeg_write_scanlines expects an array of pointers to scanlines.
* Here the array is only one element long, but you could pass
* more than one scanline at a time if that's more convenient.
*/
row_pointer[0] = & image_buffer[cinfo.next_scanline * row_stride];
(void) jpeg_write_scanlines(&cinfo, row_pointer, 1);
}
/* Step 6: Finish compression */
jpeg_finish_compress(&cinfo);
/* Step 7: release JPEG compression object */
result[0] = (int)output;
result[1] = size;
/* This is an important step since it will release a good deal of memory. */
jpeg_destroy_compress(&cinfo);
/* And we're done! */
}
EMSCRIPTEN_KEEPALIVE
void free_result() {
free(result[0]); // not sure if this is right with mozjpeg
}
EMSCRIPTEN_KEEPALIVE
int get_result_pointer() {
return result[0];
}
EMSCRIPTEN_KEEPALIVE
int get_result_size() {
return result[1];
}

1
codecs/mozjpeg_enc/mozjpeg_enc.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
export default function(opts: EmscriptenWasm.ModuleOpts): EmscriptenWasm.Module;

File diff suppressed because one or more lines are too long

Binary file not shown.

1147
codecs/mozjpeg_enc/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
{
"name": "mozjpeg_enc",
"scripts": {
"install": "napa",
"build": "npm run build:library && npm run build:wasm",
"build:library": "cd node_modules/mozjpeg && autoreconf -fiv && docker run --rm -v $(pwd):/src trzeci/emscripten emconfigure ./configure --without-simd && docker run --rm -v $(pwd):/src trzeci/emscripten emmake make libjpeg.la",
"build:wasm": "docker run --rm -v $(pwd):/src trzeci/emscripten emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='[\"cwrap\"]' -s ALLOW_MEMORY_GROWTH=1 -s MODULARIZE=1 -s 'EXPORT_NAME=\"mozjpeg_enc\"' -I node_modules/mozjpeg -o ./mozjpeg_enc.js mozjpeg_enc.c node_modules/mozjpeg/.libs/libjpeg.a"
},
"napa": {
"mozjpeg": "mozilla/mozjpeg#v3.3.1"
},
"devDependencies": {
"napa": "^3.0.0"
}
}

42
codecs/webp_enc/README.md Normal file
View File

@@ -0,0 +1,42 @@
# WebP encoder
- Source: <https://github.com/webmproject/libwebp>
- Version: v0.6.1
## Dependencies
- Docker
## Example
See `example.html`
## API
### `int version()`
Returns the version of libwebp as a number. va.b.c is encoded as 0x0a0b0c
### `uint8_t* create_buffer(int width, int height)`
Allocates an RGBA buffer for an image with the given dimension.
### `void destroy_buffer(uint8_t* p)`
Frees a buffer created with `create_buffer`.
### `void encode(uint8_t* image_buffer, int image_width, int image_height, float quality)`
Encodes the given image with given dimension to WebP. `quality` is a number between 0 and 100. The higher the number, the better the quality of the encoded image. The result is implicitly stored and can be accessed using the `get_result_*()` functions.
### `void free_result()`
Frees the result created by `encode()`.
### `int get_result_pointer()`
Returns the pointer to the start of the buffer holding the encoded data.
### `int get_result_size()`
Returns the length of the buffer holding the encoded data.

View File

@@ -0,0 +1,47 @@
<!doctype html>
<script src='webp_enc.js'></script>
<script>
const Module = webp_enc();
async function loadImage(src) {
// Load image
const img = document.createElement('img');
img.src = src;
await new Promise(resolve => img.onload = resolve);
// Make canvas same size as image
const canvas = document.createElement('canvas');
[canvas.width, canvas.height] = [img.width, img.height];
// Draw image onto canvas
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
return ctx.getImageData(0, 0, img.width, img.height);
}
Module.onRuntimeInitialized = async _ => {
const api = {
version: Module.cwrap('version', 'number', []),
create_buffer: Module.cwrap('create_buffer', 'number', ['number', 'number']),
destroy_buffer: Module.cwrap('destroy_buffer', '', ['number']),
encode: Module.cwrap('encode', '', ['number', 'number', 'number', 'number']),
free_result: Module.cwrap('free_result', '', ['number']),
get_result_pointer: Module.cwrap('get_result_pointer', 'number', []),
get_result_size: Module.cwrap('get_result_size', 'number', []),
};
const image = await loadImage('../example.png');
const p = api.create_buffer(image.width, image.height);
Module.HEAP8.set(image.data, p);
api.encode(p, image.width, image.height, 2);
const resultPointer = api.get_result_pointer();
const resultSize = api.get_result_size();
const resultView = new Uint8Array(Module.HEAP8.buffer, resultPointer, resultSize);
const result = new Uint8Array(resultView);
api.free_result(resultPointer);
api.destroy_buffer(p);
const blob = new Blob([result], {type: 'image/jpeg'});
const blobURL = URL.createObjectURL(blob);
const img = document.createElement('img');
img.src = blobURL;
document.body.appendChild(img);
};
</script>

1147
codecs/webp_enc/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
{
"name": "webp_enc",
"scripts": {
"install": "napa",
"build": "docker run --rm -v $(pwd):/src trzeci/emscripten emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='[\"cwrap\"]' -s ALLOW_MEMORY_GROWTH=1 -s MODULARIZE=1 -s 'EXPORT_NAME=\"webp_enc\"' -I node_modules/libwebp -o ./webp_enc.js webp_enc.c node_modules/libwebp/src/{dec,dsp,demux,enc,mux,utils}/*.c"
},
"napa": {
"libwebp": "webmproject/libwebp#v0.6.1"
},
"devDependencies": {
"napa": "^3.0.0"
}
}

View File

@@ -0,0 +1,44 @@
#include "emscripten.h"
#include "src/webp/encode.h"
#include <stdlib.h>
EMSCRIPTEN_KEEPALIVE
int version() {
return WebPGetEncoderVersion();
}
EMSCRIPTEN_KEEPALIVE
uint8_t* create_buffer(int width, int height) {
return malloc(width * height * 4 * sizeof(uint8_t));
}
EMSCRIPTEN_KEEPALIVE
void destroy_buffer(uint8_t* p) {
free(p);
}
int result[2];
EMSCRIPTEN_KEEPALIVE
void encode(uint8_t* img_in, int width, int height, float quality) {
uint8_t* img_out;
size_t size;
size = WebPEncodeRGBA(img_in, width, height, width * 4, quality, &img_out);
result[0] = (int)img_out;
result[1] = size;
}
EMSCRIPTEN_KEEPALIVE
void free_result() {
WebPFree(result[0]);
}
EMSCRIPTEN_KEEPALIVE
int get_result_pointer() {
return result[0];
}
EMSCRIPTEN_KEEPALIVE
int get_result_size() {
return result[1];
}

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@@ -1,322 +0,0 @@
const fs = require('fs');
const { promisify } = require('util');
const path = require('path');
const parse5 = require('parse5');
const nwmatcher = require('nwmatcher');
const css = require('css');
const prettyBytes = require('pretty-bytes');
const readFile = promisify(fs.readFile);
const treeAdapter = parse5.treeAdapters.htmlparser2;
const PLUGIN_NAME = 'critters-webpack-plugin';
const PARSE5_OPTS = {
treeAdapter
};
/** Critters: Webpack Plugin Edition!
* @class
* @param {Object} options
* @param {Boolean} [options.external=true] Fetch and inline critical styles from external stylesheets
* @param {Boolean} [options.async=false] Convert critical-inlined external stylesheets to load asynchronously (via link rel="preload" - see https://filamentgroup.com/lab/async-css.html)
* @param {Boolean} [options.preload=false] (requires `async` option) Append a new <link rel="stylesheet"> into <body> instead of swapping the preload's rel attribute
* @param {Boolean} [options.compress=true] Compress resulting critical CSS
*/
module.exports = class CrittersWebpackPlugin {
constructor (options) {
this.options = options || {};
this.urlFilter = this.options.filter;
if (this.urlFilter instanceof RegExp) {
this.urlFilter = this.urlFilter.test.bind(this.urlFilter);
}
}
/** Invoked by Webpack during plugin initialization */
apply (compiler) {
const outputPath = compiler.options.output.path;
// hook into the compiler to get a Compilation instance...
compiler.hooks.compilation.tap(PLUGIN_NAME, compilation => {
// ... which is how we get an "after" hook into html-webpack-plugin's HTML generation.
compilation.hooks.htmlWebpackPluginAfterHtmlProcessing.tapAsync(PLUGIN_NAME, (htmlPluginData, callback) => {
// Parse the generated HTML in a DOM we can mutate
const document = parse5.parse(htmlPluginData.html, PARSE5_OPTS);
makeDomInteractive(document);
let externalStylesProcessed = Promise.resolve();
// `external:false` skips processing of external sheets
if (this.options.external !== false) {
const externalSheets = document.querySelectorAll('link[rel="stylesheet"]');
externalStylesProcessed = Promise.all(externalSheets.map(
link => this.embedLinkedStylesheet(link, compilation, outputPath)
));
}
externalStylesProcessed
.then(() => {
// go through all the style tags in the document and reduce them to only critical CSS
const styles = document.querySelectorAll('style');
return Promise.all(styles.map(style => this.processStyle(style, document)));
})
.then(() => {
// serialize the document back to HTML and we're done
const html = parse5.serialize(document, PARSE5_OPTS);
callback(null, { html });
})
.catch(callback);
});
});
}
/** Inline the target stylesheet referred to by a <link rel="stylesheet"> (assuming it passes `options.filter`) */
embedLinkedStylesheet (link, compilation, outputPath) {
const href = link.getAttribute('href');
const document = link.ownerDocument;
// skip filtered resources, or network resources if no filter is provided
if (this.urlFilter ? this.urlFilter(href) : href.match(/^(https?:)?\/\//)) return Promise.resolve();
// path on disk
const filename = path.resolve(outputPath, href.replace(/^\//, ''));
// try to find a matching asset by filename in webpack's output (not yet written to disk)
const asset = compilation.assets[path.relative(outputPath, filename).replace(/^\.\//, '')];
// wait for a disk read if we had to go to disk
const promise = asset ? Promise.resolve(asset.source()) : readFile(filename, 'utf8');
return promise.then(sheet => {
// the reduced critical CSS gets injected into a new <style> tag
const style = document.createElement('style');
style.appendChild(document.createTextNode(sheet));
link.parentNode.insertBefore(style, link.nextSibling);
// drop a reference to the original URL onto the tag (used for reporting to console later)
style.$$name = href;
// the `async` option changes any critical'd <link rel="stylesheet"> tags to async-loaded equivalents
if (this.options.async) {
link.setAttribute('rel', 'preload');
link.setAttribute('as', 'style');
if (this.options.preload) {
const bodyLink = document.createElement('link');
bodyLink.setAttribute('rel', 'stylesheet');
bodyLink.setAttribute('href', href);
document.body.appendChild(bodyLink);
} else {
link.setAttribute('onload', "this.rel='stylesheet'");
}
}
});
}
/** Parse the stylesheet within a <style> element, then reduce it to contain only rules used by the document. */
processStyle (style) {
const done = Promise.resolve();
const document = style.ownerDocument;
// basically `.textContent`
let sheet = style.childNodes.length > 0 && style.childNodes.map(node => node.nodeValue).join('\n');
// store a reference to the previous serialized stylesheet for reporting stats
const before = sheet;
// Skip empty stylesheets
if (!sheet) return done;
const ast = css.parse(sheet);
// Walk all CSS rules, transforming unused rules to comments (which get removed)
visit(ast, rule => {
if (rule.type === 'rule') {
// Filter the selector list down to only those matche
rule.selectors = rule.selectors.filter(sel => {
// Strip pseudo-elements and pseudo-classes, since we only care that their associated elements exist.
// This means any selector for a pseudo-element or having a pseudo-class will be inlined if the rest of the selector matches.
sel = sel.replace(/::?(?:[a-z-]+)([.[#~&^:*]|\s|\n|$)/gi, '$1');
return document.querySelector(sel, document) != null;
});
// If there are no matched selectors, remove the rule:
if (rule.selectors.length === 0) {
return false;
}
}
// If there are no remaining rules, remove the whole rule.
return !rule.rules || rule.rules.length !== 0;
});
sheet = css.stringify(ast, { compress: this.options.compress !== false });
return done.then(() => {
// If all rules were removed, get rid of the style element entirely
if (sheet.trim().length === 0) {
sheet.parentNode.removeChild(sheet);
} else {
// replace the inline stylesheet with its critical'd counterpart
while (style.lastChild) {
style.removeChild(style.lastChild);
}
style.appendChild(document.createTextNode(sheet));
}
// output some stats
const name = style.$$name ? style.$$name.replace(/^\//, '') : 'inline CSS';
const percent = (before.length - sheet.length) / before.length * 100 | 0;
console.log('\u001b[32mCritters: inlined ' + prettyBytes(sheet.length) + ' (' + percent + '% of original ' + prettyBytes(before.length) + ') of ' + name + '.\u001b[39m');
});
}
};
/** Recursively walk all rules in a stylesheet.
* The iterator can explicitly return `false` to remove the current node.
*/
function visit (node, fn) {
if (node.stylesheet) return visit(node.stylesheet, fn);
node.rules = node.rules.filter(rule => {
if (rule.rules) {
visit(rule, fn);
}
return fn(rule) !== false;
});
}
/** Enhance an htmlparser2-style DOM with basic manipulation methods. */
function makeDomInteractive (document) {
defineProperties(document, DocumentExtensions);
// Find the first <html> element within the document
// document.documentElement = document.childNodes.filter( child => String(child.tagName).toLowerCase()==='html' )[0];
// Extend Element.prototype with DOM manipulation methods.
// Note: document.$$scratchElement is also used by createTextNode()
const scratch = document.$$scratchElement = document.createElement('div');
const elementProto = Object.getPrototypeOf(scratch);
defineProperties(elementProto, ElementExtensions);
elementProto.ownerDocument = document;
// nwmatcher is a selector engine that happens to work with Parse5's htmlparser2 DOM (they form the base of jsdom).
// It is exposed to the document so that it can be used within Element.prototype methods.
document.$match = nwmatcher({ document });
document.$match.configure({
CACHING: false,
USE_QSAPI: false,
USE_HTML5: false
});
}
/** Essentially Object.defineProperties() except any functions are assigned as values rather than descriptors. */
function defineProperties (obj, properties) {
for (const i in properties) {
const value = properties[i];
Object.defineProperty(obj, i, typeof value === 'function' ? { value } : value);
}
}
/** {document,Element}.getElementsByTagName() is the only traversal method required by nwmatcher.
* Note: if perf issues arise, 2 faster but more verbose implementations are benchmarked here:
* https://esbench.com/bench/5ac3b647f2949800a0f619e1
*/
function getElementsByTagName (tagName) {
// Only return Element/Document nodes
if ((this.nodeType !== 1 && this.nodeType !== 9) || this.type === 'directive') return [];
return Array.prototype.concat.apply(
// Add current element if it matches tag
(tagName === '*' || (this.tagName && (this.tagName === tagName || this.nodeName === tagName.toUpperCase()))) ? [this] : [],
// Check children recursively
this.children.map(child => getElementsByTagName.call(child, tagName))
);
}
/** Methods and descriptors to mix into Element.prototype */
const ElementExtensions = {
nodeName: {
get () {
return this.tagName.toUpperCase();
}
},
insertBefore (child, referenceNode) {
if (!referenceNode) return this.appendChild(child);
treeAdapter.insertBefore(this, child, referenceNode);
return child;
},
appendChild (child) {
treeAdapter.appendChild(this, child);
return child;
},
removeChild (child) {
treeAdapter.detachNode(child);
},
setAttribute (name, value) {
if (this.attribs == null) this.attribs = {};
if (value == null) value = '';
this.attribs[name] = value;
},
removeAttribute (name) {
if (this.attribs != null) {
delete this.attribs[name];
}
},
getAttribute (name) {
return this.attribs != null && this.attribs[name];
},
hasAttribute (name) {
return this.attribs != null && this.attribs[name] != null;
},
getAttributeNode (name) {
const value = this.getAttribute(name);
if (value != null) return { specified: true, value };
},
getElementsByTagName
};
/** Methods and descriptors to mix into the global document instance */
const DocumentExtensions = {
// document is just an Element in htmlparser2, giving it a nodeType of ELEMENT_NODE.
// nwmatcher requires that it at least report a correct nodeType of DOCUMENT_NODE.
nodeType: {
get () {
return 9;
}
},
nodeName: {
get () {
return '#document';
}
},
documentElement: {
get () {
// Find the first <html> element within the document
return this.childNodes.filter(child => String(child.tagName).toLowerCase() === 'html')[0];
}
},
body: {
get () {
return this.querySelector('body');
}
},
createElement (name) {
return treeAdapter.createElement(name, null, []);
},
createTextNode (text) {
// there is no dedicated createTextNode equivalent in htmlparser2's DOM, so
// we have to insert Text and then remove and return the resulting Text node.
const scratch = this.$$scratchElement;
treeAdapter.insertText(scratch, text);
const node = scratch.lastChild;
treeAdapter.detachNode(node);
return node;
},
querySelector (sel) {
return this.$match.first(sel, this.documentElement);
},
querySelectorAll (sel) {
return this.$match.select(sel, this.documentElement);
},
getElementsByTagName,
// nwmatcher uses inexistence of `document.addEventListener` to detect IE:
// https://github.com/dperini/nwmatcher/blob/3edb471e12ce7f7d46dc1606c7f659ff45675a29/src/nwmatcher.js#L353
addEventListener: Object
};

View File

@@ -1,64 +0,0 @@
const path = require('path');
const vm = require('vm');
module.exports = function (content) {
const jsdom = require('jsdom');
const preact = require('preact');
const renderToString = require('preact-render-to-string');
this.cacheable && this.cacheable();
const callback = this.async();
// const dom = new jsdom.JSDOM(`<!DOCTYPE html><html><head></head><body></body></html>`, {
const dom = new jsdom.JSDOM(content, {
includeNodeLocations: false,
runScripts: 'outside-only'
});
const { window } = dom;
const { document } = window;
// console.log(content);
const root = document.getElementById('app');
this.loadModule(path.join(__dirname, 'client-boot.js'), (err, source) => {
if (err) return callback(err);
console.log(source);
let mod = eval(source);
let props = {};
// console.log(mod);
let vnode = preact.createElement(mod, props);
let frag = document.createElement('div');
frag.innerHTML = renderToString(vnode);
root.parentNode.replaceChild(frag.firstChild, root);
let html = dom.serialize();
callback(null, html);
// return html = `module.exports = ${JSON.stringify(html)}`;
// return 'module.exports = ' + JSON.stringify(content).replace(/\{\{PRERENDER\}\}/gi, `" + require("preact-render-to-string")(require("app-entry-point")) + "`);
});
// global.window = global;
// global.document = {};
// return 'module.exports = ' + JSON.stringify(content).replace(/\{\{PRERENDER\}\}/gi, `" + require("preact-render-to-string")(require("app-entry-point")) + "`);
/*
let callback = this.async();
let parts = content.split(/\{\{prerender\}\}/gi);
if (parts.length<2) {
// callback(null, `module.exports = ${JSON.stringify(content)}`);
callback(null, content);
return;
}
// let html = `
// window = {};
// module.exports = ${JSON.stringify(parts[0])} + require("preact-render-to-string")(require("app-entry-point")) + ${JSON.stringify(parts[1])}`;
let html = `module.exports = ${JSON.stringify(parts[0])} + require("preact-render-to-string")(require("app-entry-point")) + ${JSON.stringify(parts[1])}`;
callback(null, html);
*/
};

View File

@@ -1,20 +0,0 @@
let path = require('path');
let preact = require('preact');
let renderToString = require('preact-render-to-string');
let appPath = path.join(__dirname, '../src/index');
module.exports = function(options) {
options = options || {};
let url = typeof options==='string' ? options : options.url;
global.history = {};
global.location = { href: url, pathname: url };
// let app = require('app-entry-point');
let app = require(appPath);
let html = renderToString(preact.h(app, { url }));
console.log(html);
return html;
};

107
emscripten-wasm.d.ts vendored Normal file
View File

@@ -0,0 +1,107 @@
// These types roughly model the object that the JS files generated by Emscripten define. Copied from https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/emscripten/index.d.ts and turned into a type definition rather than a global to support our way of using Emscripten.
// TODO(@surma): Upstream this?
declare namespace EmscriptenWasm {
type EnvironmentType = "WEB" | "NODE" | "SHELL" | "WORKER";
// Options object for modularized Emscripten files. Shoe-horned by @surma.
// FIXME: This an incomplete definition!
interface ModuleOpts {
noInitialRun?: boolean;
locateFile?: (url: string) => string;
onRuntimeInitialized?: () => void;
}
interface Module {
print(str: string): void;
printErr(str: string): void;
arguments: string[];
environment: EnvironmentType;
preInit: { (): void }[];
preRun: { (): void }[];
postRun: { (): void }[];
preinitializedWebGLContext: WebGLRenderingContext;
noInitialRun: boolean;
noExitRuntime: boolean;
logReadFiles: boolean;
filePackagePrefixURL: string;
wasmBinary: ArrayBuffer;
destroy(object: object): void;
getPreloadedPackage(remotePackageName: string, remotePackageSize: number): ArrayBuffer;
instantiateWasm(
imports: WebAssembly.Imports,
successCallback: (module: WebAssembly.Module) => void
): WebAssembly.Exports;
locateFile(url: string): string;
onCustomMessage(event: MessageEvent): void;
Runtime: any;
ccall(ident: string, returnType: string | null, argTypes: string[], args: any[]): any;
cwrap(ident: string, returnType: string | null, argTypes: string[]): any;
setValue(ptr: number, value: any, type: string, noSafe?: boolean): void;
getValue(ptr: number, type: string, noSafe?: boolean): number;
ALLOC_NORMAL: number;
ALLOC_STACK: number;
ALLOC_STATIC: number;
ALLOC_DYNAMIC: number;
ALLOC_NONE: number;
allocate(slab: any, types: string, allocator: number, ptr: number): number;
allocate(slab: any, types: string[], allocator: number, ptr: number): number;
Pointer_stringify(ptr: number, length?: number): string;
UTF16ToString(ptr: number): string;
stringToUTF16(str: string, outPtr: number): void;
UTF32ToString(ptr: number): string;
stringToUTF32(str: string, outPtr: number): void;
// USE_TYPED_ARRAYS == 1
HEAP: Int32Array;
IHEAP: Int32Array;
FHEAP: Float64Array;
// USE_TYPED_ARRAYS == 2
HEAP8: Int8Array;
HEAP16: Int16Array;
HEAP32: Int32Array;
HEAPU8: Uint8Array;
HEAPU16: Uint16Array;
HEAPU32: Uint32Array;
HEAPF32: Float32Array;
HEAPF64: Float64Array;
TOTAL_STACK: number;
TOTAL_MEMORY: number;
FAST_MEMORY: number;
addOnPreRun(cb: () => any): void;
addOnInit(cb: () => any): void;
addOnPreMain(cb: () => any): void;
addOnExit(cb: () => any): void;
addOnPostRun(cb: () => any): void;
// Tools
intArrayFromString(stringy: string, dontAddNull?: boolean, length?: number): number[];
intArrayToString(array: number[]): string;
writeStringToMemory(str: string, buffer: number, dontAddNull: boolean): void;
writeArrayToMemory(array: number[], buffer: number): void;
writeAsciiToMemory(str: string, buffer: number, dontAddNull: boolean): void;
addRunDependency(id: any): void;
removeRunDependency(id: any): void;
preloadedImages: any;
preloadedAudios: any;
_malloc(size: number): number;
_free(ptr: number): void;
// Augmentations below by @surma.
onRuntimeInitialized: () => void | null;
}
}

54
firebase.json Normal file
View File

@@ -0,0 +1,54 @@
{
"hosting": {
"public": "build",
"ignore": [
"firebase.json",
"**/.*",
"**/node_modules/**"
],
"rewrites": [
{
"source": "**",
"destination": "/index.html"
}
],
"headers": [
{
"source": "**/*.@(eot|otf|ttf|ttc|woff|font.css)",
"headers": [
{
"key": "Access-Control-Allow-Origin",
"value": "*"
}
]
},
{
"source": "**/*.@(jpg|jpeg|gif|png|ico)",
"headers": [
{
"key": "Cache-Control",
"value": "max-age=7200"
}
]
},
{
"source": "**/*.@(js|css|json|manifest|map)",
"headers": [
{
"key": "Cache-Control",
"value": "max-age=31536000"
}
]
},
{
"source": "sw.js",
"headers": [
{
"key": "Cache-Control",
"value": "max-age=0"
}
]
}
]
}
}

113
karma.conf.js Normal file
View File

@@ -0,0 +1,113 @@
const fs = require("fs");
function readJsonFile(path) {
// TypeScript puts lots of comments in the default `tsconfig.json`, so you
// cant use `require()` to read it. Hence this hack.
return eval("(" + fs.readFileSync(path).toString("utf-8") + ")");
}
const typeScriptConfig = readJsonFile("./tsconfig.json");
const babel = readJsonFile("./.babelrc");
module.exports = function(config) {
const options = {
// base path that will be used to resolve all patterns (eg. files, exclude)
basePath: "",
// frameworks to use
// available frameworks: https://npmjs.org/browse/keyword/karma-adapter
frameworks: ["mocha", "chai", "karma-typescript"],
// list of files / patterns to load in the browser
files: [
{
pattern: "test/**/*.ts",
type: "module"
},
{
pattern: "src/**/*.ts",
included: false
}
],
// list of files / patterns to exclude
exclude: [],
// preprocess matching files before serving them to the browser // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor
preprocessors: {
"src/**/*.ts": ["karma-typescript", "babel"],
"test/**/*.ts": ["karma-typescript", "babel"]
},
babelPreprocessor: {
options: babel
},
karmaTypescriptConfig: {
// Inline `tsconfig.json` so that the right TS libs are loaded
...typeScriptConfig,
// Coverage is a thing that karma-typescript forces on you and only
// creates problems. This is the simplest way of disabling it that I
// could find.
coverageOptions: {
exclude: /.*/
}
},
mime: {
// Default mimetype for .ts files is video/mp2t but we need
// text/javascript for modules to work.
"text/javascript": ["ts"]
},
plugins: [
// Load all modules whose name starts with "karma" (usually the default).
"karma-*",
// We dont have file extensions on our imports as they are primarily
// consumed by webpack. With Karma, however, this turns into a real HTTP
// request for a non-existent file. This inline plugin is a middleware
// that appends `.ts` to the request URL.
{
"middleware:redirect_to_ts": [
"value",
(req, res, next) => {
if (req.url.startsWith("/base/src")) {
req.url += '.ts';
}
next();
}
]
}
],
// Run our middleware before all other middlewares.
beforeMiddleware: ["redirect_to_ts"],
// test results reporter to use
// possible values: 'dots', 'progress'
// available reporters: https://npmjs.org/browse/keyword/karma-reporter
reporters: ["progress"],
// web server port
port: 9876,
// enable / disable colors in the output (reporters and logs)
colors: true,
// level of logging
// possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
logLevel: config.LOG_INFO,
// enable / disable watching file and executing tests whenever any file changes
autoWatch: false,
// start these browsers
// available browser launchers: https://npmjs.org/browse/keyword/karma-launcher
browsers: ["ChromeHeadless"],
// Continuous Integration mode
// if true, Karma captures browsers, runs the tests and exits
singleRun: true,
// These custom files allow us to use ES6 modules in our tests.
// Remove these 2 lines (and files) once https://github.com/karma-runner/karma/pull/2834 lands.
customContextFile: "test/context.html",
customDebugFile: "test/debug.html"
};
config.set(options);
};

9730
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,9 +4,12 @@
"version": "0.0.0",
"license": "apache-2.0",
"scripts": {
"start": "webpack serve --hot",
"build:mozjpeg_enc": "cd codecs/mozjpeg_enc && npm run build",
"build:codecs": "npm run build:mozjpeg_enc",
"start": "webpack serve --host 0.0.0.0 --hot",
"build": "webpack -p",
"lint": "eslint src"
"lint": "eslint src",
"test": "npm run build && mocha -R spec && karma start"
},
"eslintConfig": {
"extends": [
@@ -29,7 +32,11 @@
"build/*"
],
"devDependencies": {
"@types/chai": "^4.1.3",
"@types/karma": "^1.7.3",
"@types/mocha": "^5.2.0",
"@types/node": "^9.4.7",
"@types/webassembly-js-api": "0.0.1",
"babel-loader": "^7.1.4",
"babel-plugin-jsx-pragmatic": "^1.0.2",
"babel-plugin-syntax-dynamic-import": "^6.18.0",
@@ -41,12 +48,10 @@
"babel-plugin-transform-react-remove-prop-types": "^0.4.13",
"babel-preset-env": "^1.6.1",
"babel-register": "^6.26.0",
"chalk": "^2.3.2",
"chai": "^4.1.2",
"clean-webpack-plugin": "^0.1.19",
"copy-webpack-plugin": "^4.5.1",
"css": "^2.2.1",
"css-loader": "^0.28.11",
"ejs-loader": "^0.3.1",
"eslint": "^4.18.2",
"eslint-config-standard": "^11.0.0",
"eslint-config-standard-jsx": "^5.0.0",
@@ -55,19 +60,26 @@
"eslint-plugin-promise": "^3.7.0",
"eslint-plugin-react": "^7.7.0",
"eslint-plugin-standard": "^3.0.1",
"extract-text-webpack-plugin": "^4.0.0-beta.0",
"fork-ts-checker-notifier-webpack-plugin": "^0.4.0",
"fork-ts-checker-webpack-plugin": "^0.4.1",
"exports-loader": "^0.7.0",
"express": "^4.16.3",
"file-loader": "^1.1.11",
"html-webpack-plugin": "^3.0.6",
"if-env": "^1.0.4",
"jsdom": "^11.6.2",
"karma": "^2.0.2",
"karma-babel-preprocessor": "^7.0.0",
"karma-chai": "^0.1.0",
"karma-chrome-launcher": "^2.2.0",
"karma-mocha": "^1.3.0",
"karma-typescript": "^3.0.12",
"loader-utils": "^1.1.0",
"mini-css-extract-plugin": "^0.3.0",
"mocha": "^5.2.0",
"node-sass": "^4.7.2",
"nwmatcher": "^1.4.4",
"parse5": "^4.0.0",
"preact-render-to-string": "^3.7.0",
"preload-webpack-plugin": "github:GoogleChromeLabs/preload-webpack-plugin",
"optimize-css-assets-webpack-plugin": "^4.0.0",
"prettier": "^1.12.1",
"progress-bar-webpack-plugin": "^1.11.0",
"puppeteer": "^1.3.0",
"raw-loader": "^0.5.1",
"sass-loader": "^6.0.7",
"script-ext-html-webpack-plugin": "^2.0.1",
"source-map-loader": "^0.2.3",
@@ -77,14 +89,12 @@
"tslint-config-semistandard": "^7.0.0",
"tslint-react": "^3.5.1",
"typescript": "^2.7.2",
"typescript-loader": "^1.1.3",
"typings-for-css-modules-loader": "^1.7.0",
"webpack": "^4.3.0",
"webpack-bundle-analyzer": "^2.11.1",
"webpack-cli": "^2.0.13",
"webpack-dev-server": "^3.1.1",
"webpack-plugin-replace": "^1.1.1",
"workbox-webpack-plugin": "^3.0.1"
"webpack-plugin-replace": "^1.1.1"
},
"dependencies": {
"classnames": "^2.2.5",

View File

@@ -1,41 +1,18 @@
import { h, Component } from 'preact';
import { When, bind } from '../../lib/util';
import Fab from '../fab';
import Header from '../header';
// import Drawer from 'async!../drawer';
const Drawer = require('async!../drawer').default;
import Home from '../home';
import { bind, bitmapToImageData } from '../../lib/util';
import * as style from './style.scss';
import Output from '../output';
type Props = {
url?: string
};
import {MozJpegEncoder} from '../../lib/codec-wrappers/mozjpeg-enc';
export type FileObj = {
id: number,
data?: string,
uri?: string,
error?: Error | DOMError | String,
file: File,
loading: boolean
};
type Props = {};
type State = {
showDrawer: boolean,
showFab: boolean,
files: FileObj[]
img?: ImageBitmap
};
let idCounter = 0;
export default class App extends Component<Props, State> {
state: State = {
showDrawer: false,
showFab: true,
files: []
};
enableDrawer = false;
state: State = {};
constructor() {
super();
@@ -49,88 +26,32 @@ export default class App extends Component<Props, State> {
}
@bind
openDrawer() {
this.setState({ showDrawer: true });
}
@bind
closeDrawer() {
this.setState({ showDrawer: false });
}
@bind
toggleDrawer() {
this.setState({ showDrawer: !this.state.showDrawer });
async onFileChange(event: Event) {
const fileInput = event.target as HTMLInputElement;
if (!fileInput.files || !fileInput.files[0]) return;
// TODO: handle decode error
const bitmap = await createImageBitmap(fileInput.files[0]);
const data = await bitmapToImageData(bitmap);
const encoder = new MozJpegEncoder();
const compressedData = await encoder.encode(data);
const blob = new Blob([compressedData], {type: 'image/jpeg'});
const compressedImage = await createImageBitmap(blob);
this.setState({ img: compressedImage });
}
@bind
openFab() {
this.setState({ showFab: true });
}
@bind
closeFab() {
this.setState({ showFab: false });
}
@bind
toggleFab() {
this.setState({ showFab: !this.state.showFab });
}
@bind
loadFile(file: File) {
let fileObj: FileObj = {
id: ++idCounter,
file,
error: undefined,
loading: true,
data: undefined
};
this.setState({
files: [fileObj]
});
Promise.all([
new Response(file).text(),
new Response(file).blob()
])
.then(([data, blob]) => ({
data,
uri: URL.createObjectURL(blob)
}))
.catch(error => ({ error }))
.then(state => {
let files = this.state.files.slice();
files[files.indexOf(fileObj)] = Object.assign({}, fileObj, {
loading: false,
...state
});
this.setState({ files });
});
}
render({ url }: Props, { showDrawer, showFab, files }: State) {
if (showDrawer) this.enableDrawer = true;
if (showFab) showFab = files.length > 0;
render({ }: Props, { img }: State) {
return (
<div id="app" class={style.app}>
<Fab showing={showFab} />
<Header class={style.header} onToggleDrawer={this.toggleDrawer} loadFile={this.loadFile} />
{/* Avoid loading & rendering the drawer until the first time it is shown. */}
<When value={showDrawer}>
<Drawer showing={showDrawer} openDrawer={this.openDrawer} closeDrawer={this.closeDrawer} />
</When>
{/*
Note: this is normally where a <Router> with auto code-splitting goes.
Since we don't seem to need one (yet?), it's omitted.
*/}
<div class={style.content}>
<Home files={files} />
</div>
{img ?
<Output img={img} />
:
<div>
<h1>Select an image</h1>
<input type="file" onChange={this.onFileChange} />
</div>
}
</div>
);
}
}

View File

@@ -1,25 +1,3 @@
@import '~style/helpers.scss';
.app {
position: absolute;
display: flex;
flex-direction: column;
top: 0;
left: 0;
width: 100%;
bottom: 0;
overflow: hidden;
z-index: 1;
.header {
flex: 0 0 auto;
position: relative;
}
.content {
flex: 1 1 auto;
contain: size layout style;
overflow: auto;
-webkit-overflow-scrolling: touch;
}
.app h1 {
color: green;
}

View File

@@ -1,3 +0,0 @@
export const app: string;
export const header: string;
export const content: string;

View File

@@ -1,63 +0,0 @@
import { h, Component } from 'preact';
import MdlDrawer from 'preact-material-components-drawer';
import 'preact-material-components/Drawer/style.css';
import List from 'preact-material-components/List';
// import 'preact-material-components/List/style.css';
import { Text } from 'preact-i18n';
import * as style from './style.scss';
import { bind } from '../../lib/util';
type Props = {
showing: boolean,
openDrawer(): void,
closeDrawer(): void
};
type State = {
rendered: boolean
};
export default class Drawer extends Component<Props, State> {
state: State = {
rendered: false
};
@bind
setRendered() {
this.setState({ rendered: true });
}
render({ showing, openDrawer, closeDrawer }: Props, { rendered }: State) {
if (showing && !rendered) {
setTimeout(this.setRendered, 20);
showing = false;
}
return (
<MdlDrawer open={showing} onOpen={openDrawer} onClose={closeDrawer}>
<MdlDrawer.Header class="mdc-theme--primary-bg">
<img class={style.logo} alt="logo" src="/assets/icon.png" />
</MdlDrawer.Header>
<MdlDrawer.Content>
<List>
<List.LinkItem href="/">
<List.ItemIcon>verified_user</List.ItemIcon>
<Text id="SIGN_IN">Sign In</Text>
</List.LinkItem>
<List.LinkItem href="/register">
<List.ItemIcon>account_circle</List.ItemIcon>
<Text id="REGISTER">Register</Text>
</List.LinkItem>
</List>
</MdlDrawer.Content>
<div class={style.bottom}>
<List.LinkItem href="/preferences">
<List.ItemIcon>settings</List.ItemIcon>
<Text id="PREFERENCES">Preferences</Text>
</List.LinkItem>
</div>
</MdlDrawer>
);
}
}

View File

@@ -1,29 +0,0 @@
@import '~style/helpers.scss';
:global {
// @import '~preact-material-components/Drawer/style.css';
@import '~preact-material-components/List/mdc-list.scss';
}
.drawer {
:global(.mdc-list-item__start-detail) {
margin-right: 16px;
}
}
.logo {
width: 50%;
}
.category img {
opacity: .6;
}
.bottom {
position: absolute;
bottom: 0;
bottom: constant(safe-area-inset-bottom);
bottom: env(safe-area-inset-bottom);
left: 0;
width: 100%;
}

View File

@@ -1,14 +0,0 @@
export const mdcListItemSecondaryText: string;
export const mdcListItemGraphic: string;
export const mdcListItemMeta: string;
export const mdcListItem: string;
export const mdcListDivider: string;
export const mdcListGroup: string;
export const mdcListGroupSubheader: string;
export const drawer: string;
export const logo: string;
export const category: string;
export const bottom: string;
export const mdcRippleFgRadiusIn: string;
export const mdcRippleFgOpacityIn: string;
export const mdcRippleFgOpacityOut: string;

View File

@@ -1,47 +0,0 @@
import { h, Component } from 'preact';
import { bind } from '../../lib/util';
import Icon from 'preact-material-components/Icon';
import 'preact-material-components/Icon/style.css';
import Fab from 'preact-material-components/Fab';
import RadialProgress from 'material-radial-progress';
import * as style from './style.scss';
type Props = {
showing: boolean
};
type State = {
loading: boolean
};
export default class AppFab extends Component<Props, State> {
state: State = {
loading: false
};
@bind
setLoading(loading: boolean) {
this.setState({ loading });
}
@bind
handleClick() {
console.log('TODO: Save the file to disk.');
this.setState({ loading: true });
setTimeout(() => {
this.setState({ loading: false });
}, 1000);
}
render({ showing }: Props, { loading }: State) {
return (
<Fab ripple secondary exited={showing === false} class={style.fab} onClick={this.handleClick}>
{ loading ? (
<RadialProgress primary class={style.progress} />
) : (
<Icon>file_download</Icon>
) }
</Fab>
);
}
}

View File

@@ -1,18 +0,0 @@
@import '~style/helpers.scss';
:global {
@import '~preact-material-components/Fab/mdc-fab.scss';
}
.fab {
position: fixed;
right: 14px;
bottom: 14px;
z-index: 4;
.progress {
width: 24px;
height: 24px;
color: white;
--mdc-theme-primary: #fff;
}
}

View File

@@ -1,5 +0,0 @@
export const fab: string;
export const progress: string;
export const mdcRippleFgRadiusIn: string;
export const mdcRippleFgOpacityIn: string;
export const mdcRippleFgOpacityOut: string;

View File

@@ -1,53 +0,0 @@
import { h, Component } from 'preact';
import Toolbar from 'preact-material-components/Toolbar';
import cx from 'classnames';
import * as style from './style.scss';
import { bind } from '../../lib/util';
type Props = {
'class'?: string,
showHeader?: boolean,
onToggleDrawer?(): void,
showFab?(): void,
loadFile(f: File): void
};
type State = {};
export default class Header extends Component<Props, State> {
input?: HTMLInputElement;
@bind
setInputRef(c?: Element) {
this.input = c as HTMLInputElement;
}
@bind
upload() {
this.input!.click();
}
@bind
handleFiles() {
let files = this.input!.files;
if (files && files.length) {
this.props.loadFile(files[0]);
}
}
render({ class: c, onToggleDrawer, showHeader = true, showFab }: Props) {
return (
<Toolbar fixed class={cx(c, style.toolbar, 'inert', !showHeader && style.minimal)}>
<Toolbar.Row>
<Toolbar.Title class={style.title}>
<Toolbar.Icon title="Upload" ripple onClick={this.upload}>file_upload</Toolbar.Icon>
</Toolbar.Title>
<Toolbar.Section align-end>
<Toolbar.Icon ripple onClick={onToggleDrawer}>menu</Toolbar.Icon>
</Toolbar.Section>
</Toolbar.Row>
<input class={style.fileInput} ref={this.setInputRef} type="file" onChange={this.handleFiles} />
</Toolbar>
);
}
}

View File

@@ -1,52 +0,0 @@
@import '~style/helpers.scss';
:global {
@import '~preact-material-components/Toolbar/mdc-toolbar.scss';
}
.toolbar {
// height: $toolbar-height;
&.minimal {
display: none;
// height: $toolbar-height / 2;
}
// > * {
// min-height: 0;
// }
}
.fileInput {
position: absolute;
left: 0;
top: -999px;
}
.fab {
position: fixed;
display: block;
right: 14px;
bottom: 14px;
// z-index: 999;
// transform: translateZ(0);
}
.logo {
height: 1em;
}
.menu {
position: absolute;
top: $toolbar-height;
right: 5px;
.menuItem {
margin-right: 16px;
}
}
.title {
padding: 3px 0 0;
font-weight: 300;
font-size: 140%;
}

View File

@@ -1,8 +0,0 @@
export const toolbar: string;
export const minimal: string;
export const fileInput: string;
export const fab: string;
export const logo: string;
export const menu: string;
export const menuItem: string;
export const title: string;

View File

@@ -1,36 +0,0 @@
import { h, Component } from 'preact';
// import Button from 'preact-material-components/Button';
// import Switch from 'preact-material-components/Switch';
// import 'preact-material-components/Switch/style.css';
import * as style from './style.scss';
import { FileObj } from '../app';
type Props = {
files: FileObj[]
};
type State = {
active: boolean
};
export default class Home extends Component<Props, State> {
state: State = {
active: false
};
componentDidMount() {
setTimeout(() => {
this.setState({ active: true });
});
}
render({ files }: Props, { active }: State) {
return (
<div class={style.home + ' ' + (active ? style.active : '')}>
{ files && files[0] && (
<img src={files[0].uri} style="width:100%;" />
) }
</div>
);
}
}

View File

@@ -1,20 +0,0 @@
@import '~style/helpers.scss';
// :global {
// @import '~preact-material-components/Button/mdc-button.scss';
// // @import '~preact-material-components/Switch/mdc-switch.scss';
// }
.home {
padding: 20px;
opacity: 0;
}
.active {
animation: fadeIn 2s forwards ease 1;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}

View File

@@ -1,3 +0,0 @@
export const home: string;
export const active: string;
export const fadeIn: string;

View File

@@ -0,0 +1,298 @@
import './styles.css';
import { PointerTracker, Pointer } from '../../../../lib/PointerTracker';
interface Point {
clientX: number;
clientY: number;
}
interface ApplyChangeOpts {
panX?: number;
panY?: number;
scaleDiff?: number;
originX?: number;
originY?: number;
}
interface SetTransformOpts {
scale?: number;
x?: number;
y?: number;
/**
* Fire a 'change' event if values are different to current values
*/
allowChangeEvent?: boolean;
}
function getDistance (a: Point, b?: Point): number {
if (!b) return 0;
return Math.sqrt((b.clientX - a.clientX) ** 2 + (b.clientY - a.clientY) ** 2);
}
function getMidpoint (a: Point, b?: Point): Point {
if (!b) return a;
return {
clientX: (a.clientX + b.clientX) / 2,
clientY: (a.clientY + b.clientY) / 2
};
}
// I'd rather use DOMMatrix/DOMPoint here, but the browser support isn't good enough.
// Given that, better to use something everything supports.
let cachedSvg: SVGSVGElement;
function getSVG (): SVGSVGElement {
return cachedSvg || (cachedSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'));
}
function createMatrix (): SVGMatrix {
return getSVG().createSVGMatrix();
}
function createPoint (): SVGPoint {
return getSVG().createSVGPoint();
}
export default class PinchZoom extends HTMLElement {
// The element that we'll transform.
// Ideally this would be shadow DOM, but we don't have the browser
// support yet.
private _positioningEl?: Element;
// Current transform.
private _transform: SVGMatrix = createMatrix();
constructor () {
super();
// Watch for children changes.
// Note this won't fire for initial contents,
// so _stageElChange is also called in connectedCallback.
new MutationObserver(() => this._stageElChange())
.observe(this, { childList: true });
// Watch for pointers
const pointerTracker: PointerTracker = new PointerTracker(this, {
start: (pointer, event) => {
// We only want to track 2 pointers at most
if (pointerTracker.currentPointers.length === 2 || !this._positioningEl) return false;
event.preventDefault();
return true;
},
move: previousPointers => {
this._onPointerMove(previousPointers, pointerTracker.currentPointers);
}
});
this.addEventListener('wheel', event => this._onWheel(event));
}
connectedCallback () {
this._stageElChange();
}
get x () {
return this._transform.e;
}
get y () {
return this._transform.f;
}
get scale () {
return this._transform.a;
}
/**
* Update the stage with a given scale/x/y.
*/
setTransform (opts: SetTransformOpts = {}) {
const {
scale = this.scale,
allowChangeEvent = false
} = opts;
let {
x = this.x,
y = this.y
} = opts;
// If we don't have an element to position, just set the value as given.
// We'll check bounds later.
if (!this._positioningEl) {
this._updateTransform(scale, x, y, allowChangeEvent);
return;
}
// Get current layout
const thisBounds = this.getBoundingClientRect();
const positioningElBounds = this._positioningEl.getBoundingClientRect();
// Not displayed. May be disconnected or display:none.
// Just take the values, and we'll check bounds later.
if (!thisBounds.width || !thisBounds.height) {
this._updateTransform(scale, x, y, allowChangeEvent);
return;
}
// Create points for _positioningEl.
let topLeft = createPoint();
topLeft.x = positioningElBounds.left - thisBounds.left;
topLeft.y = positioningElBounds.top - thisBounds.top;
let bottomRight = createPoint();
bottomRight.x = positioningElBounds.width + topLeft.x;
bottomRight.y = positioningElBounds.height + topLeft.y;
// Calculate the intended position of _positioningEl.
let matrix = createMatrix()
.translate(x, y)
.scale(scale)
// Undo current transform
.multiply(this._transform.inverse());
topLeft = topLeft.matrixTransform(matrix);
bottomRight = bottomRight.matrixTransform(matrix);
// Ensure _positioningEl can't move beyond out-of-bounds.
// Correct for x
if (topLeft.x > thisBounds.width) {
x += thisBounds.width - topLeft.x;
} else if (bottomRight.x < 0) {
x += -bottomRight.x;
}
// Correct for y
if (topLeft.y > thisBounds.height) {
y += thisBounds.height - topLeft.y;
} else if (bottomRight.y < 0) {
y += -bottomRight.y;
}
this._updateTransform(scale, x, y, allowChangeEvent);
}
/**
* Update transform values without checking bounds. This is only called in setTransform.
*/
_updateTransform (scale: number, x: number, y: number, allowChangeEvent: boolean) {
// Return if there's no change
if (
scale === this.scale &&
x === this.x &&
y === this.y
) return;
this._transform.e = x;
this._transform.f = y;
this._transform.d = this._transform.a = scale;
this.style.setProperty('--x', this.x + 'px');
this.style.setProperty('--y', this.y + 'px');
this.style.setProperty('--scale', this.scale + '');
if (allowChangeEvent) {
const event = new Event('change', { bubbles: true });
this.dispatchEvent(event);
}
}
/**
* Called when the direct children of this element change.
* Until we have have shadow dom support across the board, we
* require a single element to be the child of <pinch-zoom>, and
* that's the element we pan/scale.
*/
private _stageElChange () {
this._positioningEl = undefined;
if (this.children.length === 0) {
console.warn('There should be at least one child in <pinch-zoom>.');
return;
}
this._positioningEl = this.children[0];
if (this.children.length > 1) {
console.warn('<pinch-zoom> must not have more than one child.');
}
// Do a bounds check
this.setTransform();
}
private _onWheel (event: WheelEvent) {
event.preventDefault();
const thisRect = this.getBoundingClientRect();
let { deltaY } = event;
const { ctrlKey, deltaMode } = event;
if (deltaMode === 1) { // 1 is "lines", 0 is "pixels"
// Firefox uses "lines" for some types of mouse
deltaY *= 15;
}
// ctrlKey is true when pinch-zooming on a trackpad.
const divisor = ctrlKey ? 100 : 300;
const scaleDiff = 1 - deltaY / divisor;
this._applyChange({
scaleDiff,
originX: event.clientX - thisRect.left,
originY: event.clientY - thisRect.top
});
}
private _onPointerMove (previousPointers: Pointer[], currentPointers: Pointer[]) {
// Combine next points with previous points
const thisRect = this.getBoundingClientRect();
// For calculating panning movement
const prevMidpoint = getMidpoint(previousPointers[0], previousPointers[1]);
const newMidpoint = getMidpoint(currentPointers[0], currentPointers[1]);
// Midpoint within the element
const originX = prevMidpoint.clientX - thisRect.left;
const originY = prevMidpoint.clientY - thisRect.top;
// Calculate the desired change in scale
const prevDistance = getDistance(previousPointers[0], previousPointers[1]);
const newDistance = getDistance(currentPointers[0], currentPointers[1]);
const scaleDiff = prevDistance ? newDistance / prevDistance : 1;
this._applyChange({
originX, originY, scaleDiff,
panX: newMidpoint.clientX - prevMidpoint.clientX,
panY: newMidpoint.clientY - prevMidpoint.clientY
});
}
/** Transform the view & fire a change event */
private _applyChange (opts: ApplyChangeOpts = {}) {
const {
panX = 0, panY = 0,
originX = 0, originY = 0,
scaleDiff = 1
} = opts;
const matrix = createMatrix()
// Translate according to panning.
.translate(panX, panY)
// Scale about the origin.
.translate(originX, originY)
.scale(scaleDiff)
.translate(-originX, -originY)
// Apply current transform.
.multiply(this._transform);
// Convert the transform into basic translate & scale.
this.setTransform({
scale: matrix.a,
x: matrix.e,
y: matrix.f,
allowChangeEvent: true
});
}
}
customElements.define('pinch-zoom', PinchZoom);

View File

@@ -0,0 +1,16 @@
declare interface CSSStyleDeclaration {
willChange: string | null;
}
// TypeScript, you make me sad.
// https://github.com/Microsoft/TypeScript/issues/18756
interface Window {
PointerEvent: typeof PointerEvent;
Touch: typeof Touch;
}
declare namespace JSX {
interface IntrinsicElements {
'pinch-zoom': HTMLAttributes;
}
}

View File

@@ -0,0 +1,14 @@
pinch-zoom {
display: block;
overflow: hidden;
touch-action: none;
--scale: 1;
--x: 0;
--y: 0;
}
pinch-zoom > * {
transform: translate(var(--x), var(--y)) scale(var(--scale));
transform-origin: 0 0;
will-change: transform;
}

View File

@@ -0,0 +1,111 @@
import * as styles from './styles.css';
import { PointerTracker, Pointer } from '../../../../lib/PointerTracker';
const legacyClipCompat = 'legacy-clip-compat';
/**
* A split view that the user can adjust. The first child becomes
* the left-hand side, and the second child becomes the right-hand side.
*/
export default class TwoUp extends HTMLElement {
static get observedAttributes () { return [legacyClipCompat]; }
private readonly _handle = document.createElement('div');
/**
* The position of the split in pixels.
*/
private _position = 0;
/**
* The value of _position when the pointer went down.
*/
private _positionOnPointerStart = 0;
/**
* Has connectedCallback been called yet?
*/
private _everConnected = false;
constructor () {
super();
this._handle.className = styles.twoUpHandle;
// Watch for children changes.
// Note this won't fire for initial contents,
// so _childrenChange is also called in connectedCallback.
new MutationObserver(() => this._childrenChange())
.observe(this, { childList: true });
// Watch for pointers on the handle.
const pointerTracker: PointerTracker = new PointerTracker(this._handle, {
start: (_, event) => {
// We only want to track 1 pointer.
if (pointerTracker.currentPointers.length === 1) return false;
event.preventDefault();
this._positionOnPointerStart = this._position;
return true;
},
move: () => {
this._pointerChange(
pointerTracker.startPointers[0],
pointerTracker.currentPointers[0]
);
}
});
}
connectedCallback () {
this._childrenChange();
if (!this._everConnected) {
// Set the initial position of the handle.
requestAnimationFrame(() => {
const bounds = this.getBoundingClientRect();
this._position = bounds.width / 2;
this._setPosition();
});
this._everConnected = true;
}
}
/**
* If true, this element works in browsers that don't support clip-path (Edge).
* However, this means you'll have to set the height of this element manually.
*/
get noClipPathCompat () {
return this.hasAttribute(legacyClipCompat);
}
set noClipPathCompat (val: boolean) {
if (val) {
this.setAttribute(legacyClipCompat, '');
} else {
this.removeAttribute(legacyClipCompat);
}
}
/**
* Called when element's child list changes
*/
private _childrenChange () {
// Ensure the handle is the last child.
// The CSS depends on this.
if (this.lastElementChild !== this._handle) {
this.appendChild(this._handle);
}
}
/**
* Called when a pointer moves.
*/
private _pointerChange (startPoint: Pointer, currentPoint: Pointer) {
const bounds = this.getBoundingClientRect();
this._position = this._positionOnPointerStart + (currentPoint.clientX - startPoint.clientX);
// Clamp position to element bounds.
this._position = Math.max(0, Math.min(this._position, bounds.width));
this._setPosition();
}
private _setPosition () {
this.style.setProperty('--split-point', `${this._position}px`);
}
}
customElements.define('two-up', TwoUp);

View File

@@ -0,0 +1,16 @@
declare interface CSSStyleDeclaration {
willChange: string | null;
}
// TypeScript, you make me sad.
// https://github.com/Microsoft/TypeScript/issues/18756
interface Window {
PointerEvent: typeof PointerEvent;
Touch: typeof Touch;
}
declare namespace JSX {
interface IntrinsicElements {
"two-up": HTMLAttributes
}
}

View File

@@ -0,0 +1,61 @@
two-up {
display: grid;
position: relative;
--split-point: 0;
}
two-up > * {
/* 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(.twoUpHandle) {
position: absolute;
}
.twoUpHandle {
touch-action: none;
position: relative;
width: 10px;
background: red;
transform: translateX(var(--split-point)) translateX(-50%);
will-change: transform;
}
.twoUpHandle::after {
content: '';
display: block;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 80px;
height: 40px;
background: red;
border-radius: 20px;
}
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(.twoUpHandle) {
-webkit-clip-path: inset(0 0 0 var(--split-point));
clip-path: inset(0 0 0 var(--split-point));
}
/*
Even in legacy-clip-compat, prefer clip-path if it's supported.
It performs way better in Safari.
*/
@supports not ((clip-path: inset(0 0 0 var(--split-point))) or (-webkit-clip-path: inset(0 0 0 var(--split-point)))) {
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(.twoUpHandle) {
clip: rect(auto auto auto var(--split-point));
}
}

View File

@@ -0,0 +1,104 @@
import { h, Component } from 'preact';
import PinchZoom from './custom-els/PinchZoom';
import './custom-els/PinchZoom';
import './custom-els/TwoUp';
import * as style from './style.scss';
import { bind } from '../../lib/util';
import { twoUpHandle } from './custom-els/TwoUp/styles.css';
type Props = {
img: ImageBitmap
};
type State = {};
export default class App extends Component<Props, State> {
state: State = {};
canvasLeft?: HTMLCanvasElement;
canvasRight?: HTMLCanvasElement;
pinchZoomLeft?: PinchZoom;
pinchZoomRight?: PinchZoom;
retargetedEvents = new WeakSet<Event>();
updateCanvases(img: ImageBitmap) {
for (const [i, canvas] of [this.canvasLeft, this.canvasRight].entries()) {
if (!canvas) throw Error('Missing canvas');
const ctx = canvas.getContext('2d');
if (!ctx) throw Error('Expected 2d canvas context');
if (i === 1) {
// This is temporary, to show the images are different
ctx.filter = 'hue-rotate(180deg)';
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
}
}
componentDidMount() {
this.updateCanvases(this.props.img);
}
componentDidUpdate({ img }: Props) {
if (img !== this.props.img) this.updateCanvases(this.props.img);
}
@bind
onPinchZoomLeftChange(event: Event) {
if (!this.pinchZoomRight || !this.pinchZoomLeft) throw Error('Missing pinch-zoom element');
this.pinchZoomRight.setTransform({
scale: this.pinchZoomLeft.scale,
x: this.pinchZoomLeft.x,
y: this.pinchZoomLeft.y
});
}
/**
* We're using two pinch zoom elements, but we want them to stay in sync. When one moves, we
* update the position of the other. However, this is tricky when it comes to multi-touch, when
* one finger is on one pinch-zoom, and the other finger is on the other. To overcome this, we
* redirect all relevant pointer/touch/mouse events to the first pinch zoom element.
*
* @param event Event to redirect
*/
@bind
onRetargetableEvent(event: Event) {
const targetEl = event.target as HTMLElement;
if (!this.pinchZoomLeft) throw Error('Missing pinch-zoom element');
// If the event is on the handle of the two-up, let it through.
if (targetEl.closest('.' + twoUpHandle)) return;
// If we've already retargeted this event, let it through.
if (this.retargetedEvents.has(event)) return;
// Stop the event in its tracks.
event.stopImmediatePropagation();
event.preventDefault();
// Clone the event & dispatch
// Some TypeScript trickery needed due to https://github.com/Microsoft/TypeScript/issues/3841
const clonedEvent = new (event.constructor as typeof Event)(event.type, event);
this.retargetedEvents.add(clonedEvent);
this.pinchZoomLeft.dispatchEvent(clonedEvent);
}
render({ img }: Props, { }: State) {
return (
<div>
<two-up
// Event redirecting. See onRetargetableEvent.
onTouchStartCapture={this.onRetargetableEvent}
onTouchEndCapture={this.onRetargetableEvent}
onTouchMoveCapture={this.onRetargetableEvent}
onPointerDownCapture={this.onRetargetableEvent}
onMouseDownCapture={this.onRetargetableEvent}
onWheelCapture={this.onRetargetableEvent}
>
<pinch-zoom onChange={this.onPinchZoomLeftChange} ref={p => this.pinchZoomLeft = p as PinchZoom}>
<canvas class={style.outputCanvas} ref={c => this.canvasLeft = c as HTMLCanvasElement} width={img.width} height={img.height} />
</pinch-zoom>
<pinch-zoom ref={p => this.pinchZoomRight = p as PinchZoom}>
<canvas class={style.outputCanvas} ref={c => this.canvasRight = c as HTMLCanvasElement} width={img.width} height={img.height} />
</pinch-zoom>
</two-up>
<p>And that's all the app does so far!</p>
</div>
);
}
}

View File

@@ -0,0 +1,3 @@
.outputCanvas {
image-rendering: pixelated;
}

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8">
<title>Squoosh</title>
<meta name="description" content="Compress and compare images with different codecs, right in your browser">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
@@ -10,14 +11,6 @@
<link rel="manifest" href="/manifest.json">
</head>
<body>
<div id="app" prerender></div>
<script>
(function(style){
style.rel='stylesheet'
style.href='https://fonts.googleapis.com/icon?family=Material+Icons'
document.head.appendChild(style)
})(document.createElement('link'));
</script>
<!-- <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" /> -->
<div id="app"></div>
</body>
</html>
</html>

View File

@@ -4,7 +4,7 @@ import './style';
import App from './components/app';
// Find the outermost Element in our server-rendered HTML structure.
let root = document.querySelector('[prerender]') || undefined;
let root = document.querySelector('#app') || undefined;
// "attach" the client-side rendering to it, updating the DOM in-place instead of replacing:
root = render(<App />, document.body, root);
@@ -20,13 +20,4 @@ if (process.env.NODE_ENV === 'development') {
root = render(<App />, document.body, root);
});
});
} else if ('serviceWorker' in navigator && location.protocol === 'https:') {
addEventListener('load', () => {
navigator.serviceWorker.register(__webpack_public_path__ + 'sw.js');
});
}
/** @todo SSR */
// if (typeof module==='object') {
// module.exports = app;
// }

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
export interface Encoder {
encode(data: ImageData): Promise<ArrayBuffer>;
}
export interface Decoder {
decode(data: ArrayBuffer): Promise<ImageBitmap>;
}

View File

@@ -0,0 +1,77 @@
import {Encoder} from './codec';
import mozjpeg_enc from '../../../codecs/mozjpeg_enc/mozjpeg_enc';
// Using require() so TypeScript doesnt complain about this not being a module.
const wasmBinaryUrl = require('../../../codecs/mozjpeg_enc/mozjpeg_enc.wasm');
// API exposed by wasm module. Details in the codecs README.
interface ModuleAPI {
version(): number;
create_buffer(width: number, height: number): number;
destroy_buffer(pointer: number): void;
encode(buffer: number, width: number, height: number, quality: number): void;
free_result(): void;
get_result_pointer(): number;
get_result_size(): number;
}
export class MozJpegEncoder implements Encoder {
private emscriptenModule: Promise<EmscriptenWasm.Module>;
private api: Promise<ModuleAPI>;
constructor() {
this.emscriptenModule = new Promise(resolve => {
const m = mozjpeg_enc({
// Just to be safe, dont automatically invoke any wasm functions
noInitialRun: false,
locateFile(url: string): string {
// Redirect the request for the wasm binary to whatever webpack gave us.
if(url.endsWith('.wasm')) {
return wasmBinaryUrl;
}
return url;
},
onRuntimeInitialized() {
// An Emscripten is a then-able that, for some reason, `then()`s itself,
// causing an infite loop when you wrap it in a real promise. Deleten the `then`
// prop solves this for now.
// See: https://github.com/kripken/emscripten/blob/incoming/src/postamble.js#L129
// TODO(surma@): File a bug with Emscripten on this.
delete (m as any).then;
resolve(m);
}
});
});
this.api = (async () => {
// Not sure why, but TypeScript complains that I am using `emscriptenModule` before its getting assigned, which is clearly not true :shrug: Using `any`
const m = await (this as any).emscriptenModule;
return {
version: m.cwrap('version', 'number', []),
create_buffer: m.cwrap('create_buffer', 'number', ['number', 'number']),
destroy_buffer: m.cwrap('destroy_buffer', '', ['number']),
encode: m.cwrap('encode', '', ['number', 'number', 'number', 'number']),
free_result: m.cwrap('free_result', '', []),
get_result_pointer: m.cwrap('get_result_pointer', 'number', []),
get_result_size: m.cwrap('get_result_size', 'number', []),
};
})();
}
async encode(data: ImageData): Promise<ArrayBuffer> {
const m = await this.emscriptenModule;
const api = await this.api;
const p = api.create_buffer(data.width, data.height);
m.HEAP8.set(data.data, p);
api.encode(p, data.width, data.height, 2);
const resultPointer = api.get_result_pointer();
const resultSize = api.get_result_size();
const resultView = new Uint8Array(m.HEAP8.buffer, resultPointer, resultSize);
const result = new Uint8Array(resultView);
api.free_result();
api.destroy_buffer(p);
// wasm cant run on SharedArrayBuffers, so we hard-cast to ArrayBuffer.
return result.buffer as ArrayBuffer;
}
}

View File

@@ -1,26 +1,3 @@
import { Component, ComponentProps } from 'preact';
type WhenProps = ComponentProps<When> & {
value: boolean,
children?: (JSX.Element | (() => JSX.Element))[]
};
type WhenState = {
ready: boolean
};
export class When extends Component<WhenProps, WhenState> {
state: WhenState = {
ready: !!this.props.value
};
render({ value, children = [] }: WhenProps, { ready }: WhenState) {
let child = children[0];
if (value && !ready) this.setState({ ready: true });
return ready ? (typeof child === 'function' ? child() : child) : null;
}
}
/**
* A decorator that binds values to their class instance.
* @example
@@ -47,3 +24,22 @@ export function bind(target: any, propertyKey: string, descriptor: PropertyDescr
}
};
}
/**
* Turns a given `ImageBitmap` into `ImageData`.
*/
export async function bitmapToImageData(bitmap: ImageBitmap): Promise<ImageData> {
// Make canvas same size as image
// TODO: Move this off-thread if possible with `OffscreenCanvas` or iFrames?
const canvas = document.createElement('canvas');
canvas.width = bitmap.width;
canvas.height = bitmap.height;
// Draw image onto canvas
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error("Could not create canvas context");
}
ctx.drawImage(bitmap, 0, 0);
return ctx.getImageData(0, 0, bitmap.width, bitmap.height);
}

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

@@ -0,0 +1,22 @@
// PRs to fix this:
// https://github.com/developit/preact/pull/1101
// https://github.com/developit/preact/pull/1102
declare namespace JSX {
type PointerEventHandler = EventHandler<PointerEvent>;
interface DOMAttributes {
onTouchStartCapture?: TouchEventHandler;
onTouchEndCapture?: TouchEventHandler;
onTouchMoveCapture?: TouchEventHandler;
onPointerDownCapture?: PointerEventHandler;
onMouseDownCapture?: MouseEventHandler;
onWheelCapture?: WheelEventHandler;
}
}
interface CanvasRenderingContext2D {
filter: string;
}

View File

@@ -1,10 +0,0 @@
$toolbar-height: 56px;
$mdc-theme-primary: #263238;
$mdc-theme-primary-light: #4f5b62;
$mdc-theme-primary-dark: #000a12;
$mdc-theme-secondary: #d81b60;
$mdc-theme-secondary-light: #ff5c8d;
$mdc-theme-secondary-dark: #a00037;
$mdc-theme-secondary-dark: #a00037;
$mdc-theme-background: #fff;

View File

@@ -1,7 +1,4 @@
// @import './material-icons.scss';
// @import 'material-components-web/material-components-web';
@import './reset.scss';
// @import url('https://fonts.googleapis.com/icon?family=Material+Icons');
html, body {
height: 100%;
@@ -11,17 +8,3 @@ html, body {
overflow: hidden;
overscroll-behavior: none;
}
html {
background: #FAFAFA;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", Helvetica, Arial, sans-serif;
font-weight: 400;
color: #444;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.mdc-theme--dark {
background-color: #333;
color: #fff;
}

View File

@@ -1,28 +0,0 @@
@font-face {
font-family: 'Material Icons';
font-style: normal;
font-weight: 400;
src: local('Material Icons'),
local('MaterialIcons-Regular'),
url(https://example.com/MaterialIcons-Regular.woff2) format('woff2'),
url(https://example.com/MaterialIcons-Regular.woff) format('woff'),
url(https://example.com/MaterialIcons-Regular.ttf) format('truetype');
}
.material-icons {
font-family: 'Material Icons';
font-weight: normal;
font-style: normal;
font-size: 24px;
display: inline-block;
line-height: 1;
text-transform: none;
letter-spacing: normal;
word-wrap: normal;
white-space: nowrap;
direction: ltr;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
-moz-osx-font-smoothing: grayscale;
font-feature-settings: 'liga';
}

41
test/context.html Normal file
View File

@@ -0,0 +1,41 @@
<!DOCTYPE html>
<!--
This file is taken from https://github.com/karma-runner/karma/pull/2834. It allows us to use ES6 module imports in our test suite files.
-->
<!--
This is the execution context.
Loaded within the iframe.
Reloaded before every execution run.
-->
<html>
<head>
<title></title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
</head>
<body>
<!-- The scripts need to be in the body DOM element, as some test running frameworks need the body
to have already been created so they can insert their magic into it. For example, if loaded
before body, Angular Scenario test framework fails to find the body and crashes and burns in
an epic manner. -->
<script src="context.js"></script>
<script type="text/javascript">
// Configure our Karma and set up bindings
%CLIENT_CONFIG%
window.__karma__.setupContext(window);
// All served files with the latest timestamps
%MAPPINGS%
</script>
<!-- Dynamically replaced with <script> tags -->
%SCRIPTS%
<!-- Since %SCRIPTS% might include modules, the `loaded()` call needs to be in a module too.
This ensures all the tests will have been declared before karma tries to run them. -->
<script type="module">
window.__karma__.loaded();
</script>
<script nomodule>
window.__karma__.loaded();
</script>
</body>
</html>

43
test/debug.html Normal file
View File

@@ -0,0 +1,43 @@
<!doctype html>
<!--
This file is taken from https://github.com/karma-runner/karma/pull/2834. It allows us to use ES6 module imports in our test suite files.
-->
<!--
This file is almost the same as context.html - loads all source files,
but its purpose is to be loaded in the main frame (not within an iframe),
just for immediate execution, without reporting to Karma server.
-->
<html>
<head>
%X_UA_COMPATIBLE%
<title>Karma DEBUG RUNNER</title>
<link href="favicon.ico" rel="icon" type="image/x-icon" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
</head>
<body>
<!-- The scripts need to be at the end of body, so that some test running frameworks
(Angular Scenario, for example) need the body to be loaded so that it can insert its magic
into it. If it is before body, then it fails to find the body and crashes and burns in an epic
manner. -->
<script src="context.js"></script>
<script src="debug.js"></script>
<script type="text/javascript">
// Configure our Karma
%CLIENT_CONFIG%
// All served files with the latest timestamps
%MAPPINGS%
</script>
<!-- Dynamically replaced with <script> tags -->
%SCRIPTS%
<!-- Since %SCRIPTS% might include modules, the `loaded()` call needs to be in a module too.
This ensures all the tests will have been declared before karma tries to run them. -->
<script type="module">
window.__karma__.loaded();
</script>
<script nomodule>
window.__karma__.loaded();
</script>
</body>
</html>

66
test/e2etest.js Normal file
View File

@@ -0,0 +1,66 @@
const express = require("express");
const app = express();
const http = require("http");
const puppeteer = require("puppeteer");
const { fingerDown } = require("./finger.js");
const { expect } = require("chai");
async function staticWebServer(path) {
// Start a static web server
const app = express();
app.use(express.static(path));
// Port 0 means let the OS select a port
const server = http.createServer(app).listen(0, "localhost");
await new Promise(resolve => server.on("listening", resolve));
// Read back the bound address
const address = server.address();
return { server, address };
}
describe("some e2e test", function() {
before(async function() {
// Start webserver
const { address, server } = await staticWebServer(".");
this.address = `http://${address.address}:${address.port}`;
this.server = server;
// Start browser
this.browser = await puppeteer.launch();
this.page = await this.browser.newPage();
await this.page.goto(`${this.address}/test/sample.html`, {
waitUntil: "networkidle2"
});
});
it("can tap", async function() {
const btn = await this.page.$("button");
await btn.tap();
const result = await this.page.evaluate(_ => {
return window.lol;
});
expect(result).to.equal(true);
});
it("can tap manually", async function() {
const btn = await this.page.$("button");
const box = await btn.boundingBox();
const finger = fingerDown(
this.page,
box.x + box.width / 2,
box.y + box.height / 2
);
finger.up();
const result = await this.page.evaluate(_ => {
return window.lol;
});
expect(result).to.equal(true);
});
it("does some taps", async function() {});
after(async function() {
this.server.close();
await this.browser.close();
});
});

61
test/finger.js Normal file
View File

@@ -0,0 +1,61 @@
let touchPoints = [];
let idCnt = 0;
class Finger {
constructor(point, page) {
this._point = point;
this._page = page;
}
move(x, y) {
if (!this._point) return;
Object.assign(this._point, {
x: Math.floor(x),
y: Math.floor(y)
});
this._page.touchscreen._client.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints,
modifiers: page._keyboard._modifiers
});
}
up() {
if (!this._point) return;
const idx = touchPoints.indexOf(this._point);
touchPoints = touchPoints.splice(idx, 1);
this._point = null;
if (touchPoints.length === 0) {
this._page.touchscreen._client.send("Input.dispatchTouchEvent", {
type: "touchEnd",
modifiers: this._page._keyboard._modifiers
});
} else {
this._page.touchscreen._client.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints,
modifiers: this._page._keyboard._modifiers
});
}
}
}
function fingerDown(page, x, y) {
const id = idCnt++;
const point = {
x: Math.round(x),
y: Math.round(y),
id
};
touchPoints.push(point);
page.touchscreen._client.send("Input.dispatchTouchEvent", {
type: "touchStart",
touchPoints,
modifiers: page._keyboard._modifiers
});
return new Finger(point, page);
}
module.exports = {
fingerDown
};

9
test/lib/util.ts Normal file
View File

@@ -0,0 +1,9 @@
import {bitmapToImageData} from "../../src/lib/util";
const expect = chai.expect;
describe("util.bitmapToImageData", function () {
it("is a function", function () {
expect(bitmapToImageData).to.be.a('function');
});
});

6
test/sample.html Normal file
View File

@@ -0,0 +1,6 @@
<!doctype html>
<button>Test me</button>
<script>
document.querySelector('button').onclick = _ => window.lol = true;
</script>

View File

@@ -1,34 +1,34 @@
const fs = require('fs');
const path = require('path');
const webpack = require('webpack');
const CleanWebpackPlugin = require('clean-webpack-plugin');
const CleanPlugin = require('clean-webpack-plugin');
const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
const ProgressBarPlugin = require('progress-bar-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const PreloadWebpackPlugin = require('preload-webpack-plugin');
const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const HtmlPlugin = require('html-webpack-plugin');
const ScriptExtHtmlPlugin = require('script-ext-html-webpack-plugin');
const ReplacePlugin = require('webpack-plugin-replace');
const CopyPlugin = require('copy-webpack-plugin');
const WorkboxPlugin = require('workbox-webpack-plugin');
const CrittersPlugin = require('./config/critters-webpack-plugin');
const WatchTimestampsPlugin = require('./config/watch-timestamps-plugin');
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
function readJson(filename) {
function readJson (filename) {
return JSON.parse(fs.readFileSync(filename));
}
module.exports = function(_, env) {
module.exports = function (_, env) {
const isProd = env.mode === 'production';
const nodeModules = path.join(__dirname, 'node_modules');
const componentStyleDirs = [
path.join(__dirname, 'src/components'),
path.join(__dirname, 'src/routes')
path.join(__dirname, 'src/components')
];
return {
mode: isProd ? 'production' : 'development',
entry: './src/index',
devtool: isProd ? 'source-map' : 'inline-source-map',
stats: 'minimal',
output: {
filename: isProd ? '[name].[chunkhash:5].js' : '[name].js',
chunkFilename: '[name].chunk.[chunkhash:5].js',
@@ -49,23 +49,6 @@ module.exports = function(_, env) {
},
module: {
rules: [
{
test: /\.tsx?$/,
exclude: nodeModules,
// Ensure typescript is compiled prior to Babel running:
enforce: 'pre',
use: [
// pluck the sourcemap back out so Babel creates a composed one:
'source-map-loader',
'ts-loader'
]
},
{
test: /\.(ts|js)x?$/,
loader: 'babel-loader',
// Don't respect any Babel RC files found on the filesystem:
options: Object.assign(readJson('.babelrc'), { babelrc: false })
},
{
test: /\.(scss|sass)$/,
loader: 'sass-loader',
@@ -78,7 +61,7 @@ module.exports = function(_, env) {
},
{
test: /\.(scss|sass|css)$/,
// Only enable CSS Modules within `src/{components,routes}/*`
// Only enable CSS Modules within `src/components/*`
include: componentStyleDirs,
use: [
// In production, CSS is extracted to files on disk. In development, it's inlined into JS:
@@ -101,7 +84,7 @@ module.exports = function(_, env) {
},
{
test: /\.(scss|sass|css)$/,
// Process non-modular CSS everywhere *except* `src/{components,routes}/*`
// Process non-modular CSS everywhere *except* `src/components/*`
exclude: componentStyleDirs,
use: [
isProd ? MiniCssExtractPlugin.loader : 'style-loader',
@@ -113,10 +96,34 @@ module.exports = function(_, env) {
}
}
]
},
{
test: /\.tsx?$/,
exclude: nodeModules,
loader: 'ts-loader'
},
{
test: /\.jsx?$/,
loader: 'babel-loader',
// Don't respect any Babel RC files found on the filesystem:
options: Object.assign(readJson('.babelrc'), { babelrc: false })
},
{
// All the codec files define a global with the same name as their file name. `exports-loader` attaches those to `module.exports`.
test: /\/codecs\/.*\.js$/,
loader: 'exports-loader',
},
{
test: /\/codecs\/.*\.wasm$/,
// This is needed to make webpack NOT process wasm files.
// See https://github.com/webpack/webpack/issues/6725
type: 'javascript/auto',
loader: 'file-loader',
}
]
],
},
plugins: [
new webpack.IgnorePlugin(/(fs)/, /\/codecs\//),
// Pretty progressbar showing build progress:
new ProgressBarPlugin({
format: '\u001b[90m\u001b[44mBuild\u001b[49m\u001b[39m [:bar] \u001b[32m\u001b[1m:percent\u001b[22m\u001b[39m (:elapseds) \u001b[2m:msg\u001b[22m\r',
@@ -126,11 +133,12 @@ module.exports = function(_, env) {
}),
// Remove old files before outputting a production build:
isProd && new CleanWebpackPlugin([
isProd && new CleanPlugin([
'assets',
'**/*.{css,js,json,html}'
'**/*.{css,js,json,html,map}'
], {
root: path.join(__dirname, 'build'),
verbose: false,
beforeEmit: true
}),
@@ -144,9 +152,17 @@ module.exports = function(_, env) {
// See: https://github.com/webpack-contrib/mini-css-extract-plugin
// See also: https://twitter.com/wsokra/status/970253245733113856
isProd && new MiniCssExtractPlugin({
filename: '[name].[contenthash:5].css',
chunkFilename: '[name].chunk.[contenthash:5].css'
}),
new OptimizeCssAssetsPlugin({
cssProcessorOptions: {
zindex: false,
discardComments: { removeAll: true }
}
}),
// These plugins fix infinite loop in typings-for-css-modules-loader.
// See: https://github.com/Jimdo/typings-for-css-modules-loader/issues/35
new webpack.WatchIgnorePlugin([
@@ -157,15 +173,14 @@ module.exports = function(_, env) {
]),
// For now we're not doing SSR.
new HtmlWebpackPlugin({
new HtmlPlugin({
filename: path.join(__dirname, 'build/index.html'),
template: '!!ejs-loader!src/index.html',
// template: '!!'+path.join(__dirname, 'config/prerender-loader')+'!src/index.html',
template: 'src/index.html',
minify: isProd && {
collapseWhitespace: true,
removeScriptTypeAttributes: true,
removeRedundantAttributes: true,
removeStyleLinkTypeAttributes: true,
removeRedundantAttributes: true,
removeComments: true
},
manifest: readJson('./src/manifest.json'),
@@ -173,14 +188,8 @@ module.exports = function(_, env) {
compile: true
}),
// Inject <link rel="preload"> for resources
isProd && new PreloadWebpackPlugin(),
isProd && new CrittersPlugin({
// convert critical'd <link rel="stylesheet"> to <link rel="preload" as="style">:
async: true,
// copy original <link rel="stylesheet"> to the end of <body>:
preload: true
new ScriptExtHtmlPlugin({
defaultAttribute: 'async'
}),
// Inline constants during build, so they can be folded by UglifyJS.
@@ -212,19 +221,31 @@ module.exports = function(_, env) {
analyzerMode: 'static',
defaultSizes: 'gzip',
openAnalyzer: false
}),
// Generate a ServiceWorker using Workbox.
isProd && new WorkboxPlugin.GenerateSW({
swDest: 'sw.js',
clientsClaim: true,
skipWaiting: true,
// allow for offline client-side routing:
navigateFallback: '/',
navigateFallbackBlacklist: [/\.[a-z0-9]+$/i]
})
].filter(Boolean), // Filter out any falsey plugin array entries.
optimization: {
minimizer: [
new UglifyJsPlugin({
sourceMap: isProd,
extractComments: {
file: 'build/licenses.txt'
},
uglifyOptions: {
compress: {
inline: 1
},
mangle: {
safari10: true
},
output: {
safari10: true
}
}
})
]
},
// Turn off various NodeJS environment polyfills Webpack adds to bundles.
// They're supposed to be added only when used, but the heuristic is loose
// (eg: existence of a variable called setImmedaite in any scope)
@@ -249,8 +270,6 @@ module.exports = function(_, env) {
compress: true,
// Request paths not ending in a file extension serve index.html:
historyApiFallback: true,
// Don't output server address info to console on startup:
noInfo: true,
// Suppress forwarding of Webpack logs to the browser console:
clientLogLevel: 'none',
// Supress the extensive stats normally printed after a dev build (since sizes are mostly useless):