Compare commits

...

23 Commits

Author SHA1 Message Date
Jason Miller
34be93b0f0 add missing chai dep 2018-07-01 15:02:42 +00:00
Jason Miller
e95ea80c4f merge master 2018-07-01 15:00:53 +00:00
Jason Miller
44412f6217 remove prettier 2018-07-01 14:57:52 +00:00
Jason Miller
08362a4b2d Remove unused deps 2018-06-30 17:32:57 +00:00
Surma
cc3ed168d8 Merge pull request #70 from GoogleChromeLabs/lint-fix
Fixing issues raised by the linter. Fixes #68
2018-06-30 00:25:02 +01:00
Paul Kinlan
3b9b1e9f2e Fixing issues raised by the linter. Fixes #68
+ just cleans up issues, and disables one test that can't be fixed.
+ biggest change is encoders not using multiple imports now.
2018-06-29 21:12:17 +00:00
Jason Miller
10de559a0c Try karmatic! +test reorg 2018-06-29 20:12:40 +00:00
Paul Kinlan
7c220b1a92 Adding in Drag and Drop support to fix #45 (#56)
* Merging file drop

* Fixing double drop
2018-06-29 16:37:48 +01:00
Jason Miller
3035a68b90 Options UI (#39)
* Initial work to add Options config

* Use a single encoder instance and retry up to 10 times on failure.

* Switch both sides to allow encoding from the source image, add options configuration for each.

* Styling for options (and a few tweaks for the app)

* Dep updates.

* Remove commented out code.

* Fix Encoder typing

* Fix lint issues

* Apparently I didnt have tslint autofix enabled on the chromebook

* Attempt to fix layout/panning issues

* Fix missing custom element import!

* Fix variable naming, remove dynamic encoder names, remove retry, allow encoders to return ImageData.

* Refactor state management to use an Array of objects and immutable updates instead of relying on explicit update notifications.

* Add Identity encoder, which is a passthrough encoder that handles the "original" view.

* Drop comlink-loader into the project and add ".worker" to the jpeg encoder filename so it runs in a worker (🦄)

* lint fixes.

* cleanup

* smaller PR feedback fixes

* rename "jpeg" codec to "MozJpeg"

* Formatting fixes for Options

* Colocate codecs and their options UIs in src/codecs, and standardize the namings

* Handle canvas errors

* Throw if quality is undefined, add default quality

* add note about temp styles

* add note about temp styles [2]

* Renaming updateOption

* Clarify option input bindings

* Move updateCanvas() to util and rename to drawBitmapToCanvas

* use generics to pass through encoder options

* Remove unused dependencies

* fix options type

* const

* Use `Array.prototype.some()` for image loading check

* Display encoding errors in the UI.

* I fought typescript and I think I won

* This doesn't need to be optional

* Quality isn't optional

* Simplifying comlink casting

* Splitting counters into loading and displaying

* Still loading if the loading counter isn't equal.
2018-06-29 16:29:18 +01:00
Jason Miller
e9dad3d884 Use Puppeteer's chrome install 2018-06-29 01:43:13 +00:00
Surma
65847c0ed7 Merge pull request #62 from GoogleChromeLabs/linting
Switch to tslint and run it as commit hook
2018-06-26 15:16:42 +01:00
Surma
5303afe9ad Fix code lint complaints 2018-06-26 15:11:07 +01:00
Surma
579b8a494a Use better exclude option 2018-06-26 15:10:54 +01:00
Surma
56faf619d0 Allow leading underscores on variable names 2018-06-26 14:44:31 +01:00
Surma
85e3a12c84 Add lint fix script 2018-06-26 14:43:33 +01:00
Surma
cab8d3f13c Allow leading underscores in private methods 2018-06-26 14:40:28 +01:00
Surma
5c651a1716 Switch to tslint and run it as commit hook 2018-06-26 11:19:44 +01:00
Surma
ba0ad81646 Merge pull request #52 from GoogleChromeLabs/codec-fixes
Codec fixes
2018-06-14 13:39:22 +01:00
Surma
695bbed12b Update webp to v1.0.0 2018-06-14 13:32:05 +01:00
Surma
6a6d478f77 Commit webp decoder binaries 2018-06-14 13:29:11 +01:00
Surma
d75a3aca9b Merge pull request #50 from GoogleChromeLabs/webp-dec
Decoder for webp
2018-06-14 13:25:49 +01:00
Surma
91945da5ae Add documentation 2018-06-13 23:44:46 +01:00
Surma
00e73daabd Decoder for webp 2018-06-13 23:40:24 +01:00
46 changed files with 5369 additions and 4754 deletions

BIN
codecs/example.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

View File

@@ -27,6 +27,7 @@
get_result_pointer: Module.cwrap('get_result_pointer', 'number', []),
get_result_size: Module.cwrap('get_result_size', 'number', []),
};
console.log('Version:', api.version().toString(16));
const image = await loadImage('../example.png');
const p = api.create_buffer(image.width, image.height);
Module.HEAP8.set(image.data, p);

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

@@ -0,0 +1,42 @@
# WebP decoder
- Source: <https://github.com/webmproject/libwebp>
- Version: v0.6.1
## 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 size)`
Allocates an buffer for the file data.
### `void destroy_buffer(uint8_t* p)`
Frees a buffer created with `create_buffer`.
### `void decode(uint8_t* img_in, int size)`
Decodes the given webp file into raw RGBA. The result is implicitly stored and can be accessed using the `get_result_*()` functions.
### `void free_result()`
Frees the result created by `decode()`.
### `int get_result_pointer()`
Returns the pointer to the start of the buffer holding the encoded data. Length is width x height x 4 bytes.
### `int get_result_width()`
Returns the width of the image.
### `int get_result_height()`
Returns the height of the image.

View File

@@ -0,0 +1,45 @@
<!doctype html>
<script src='webp_dec.js'></script>
<script>
const Module = webp_dec();
async function loadFile(src) {
const resp = await fetch(src);
return await resp.arrayBuffer();
}
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']),
decode: Module.cwrap('decode', '', ['number', 'number']),
free_result: Module.cwrap('free_result', '', ['number']),
get_result_pointer: Module.cwrap('get_result_pointer', 'number', []),
get_result_width: Module.cwrap('get_result_width', 'number', []),
get_result_height: Module.cwrap('get_result_height', 'number', []),
};
console.log('Version:', api.version().toString(16));
const image = await loadFile('../example.webp');
const p = api.create_buffer(image.byteLength);
Module.HEAP8.set(new Uint8Array(image), p);
api.decode(p, image.byteLength);
const resultPointer = api.get_result_pointer();
if(resultPointer === 0) {
throw new Error("Could not decode image");
}
const resultWidth = api.get_result_width();
const resultHeight = api.get_result_height();
const resultView = new Uint8Array(Module.HEAP8.buffer, resultPointer, resultWidth * resultHeight * 4);
const result = new Uint8ClampedArray(resultView);
const imageData = new ImageData(result, resultWidth, resultHeight);
api.free_result(resultPointer);
api.destroy_buffer(p);
const canvas = document.createElement('canvas');
canvas.width = resultWidth;
canvas.height = resultHeight;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
ctx.putImageData(imageData, 0, 0);
};
</script>

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
{
"name": "webp_dec",
"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_dec\"' -I node_modules/libwebp -o ./webp_dec.js webp_dec.c node_modules/libwebp/src/{dec,dsp,demux,enc,mux,utils}/*.c"
},
"napa": {
"libwebp": "webmproject/libwebp#v1.0.0"
},
"devDependencies": {
"napa": "^3.0.0"
}
}

View File

@@ -0,0 +1,51 @@
#include "emscripten.h"
#include "src/webp/decode.h"
#include "src/webp/demux.h"
#include <stdlib.h>
EMSCRIPTEN_KEEPALIVE
int version() {
return WebPGetDecoderVersion();
}
EMSCRIPTEN_KEEPALIVE
uint8_t* create_buffer(int size) {
return malloc(size);
}
EMSCRIPTEN_KEEPALIVE
void destroy_buffer(uint8_t* p) {
free(p);
}
int result[3];
EMSCRIPTEN_KEEPALIVE
void decode(uint8_t* img_in, int size) {
int width, height;
uint8_t* img_out = WebPDecodeRGBA(img_in, size, &width, &height);
result[0] = (int)img_out;
result[1] = width;
result[2] = height;
}
EMSCRIPTEN_KEEPALIVE
void free_result() {
WebPFree(result[0]);
}
EMSCRIPTEN_KEEPALIVE
int get_result_pointer() {
return result[0];
}
EMSCRIPTEN_KEEPALIVE
int get_result_width() {
return result[1];
}
EMSCRIPTEN_KEEPALIVE
int get_result_height() {
return result[2];
}

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@@ -27,6 +27,7 @@
get_result_pointer: Module.cwrap('get_result_pointer', 'number', []),
get_result_size: Module.cwrap('get_result_size', 'number', []),
};
console.log('Version:', api.version().toString(16));
const image = await loadImage('../example.png');
const p = api.create_buffer(image.width, image.height);
Module.HEAP8.set(image.data, p);
@@ -38,7 +39,7 @@
api.free_result(resultPointer);
api.destroy_buffer(p);
const blob = new Blob([result], {type: 'image/jpeg'});
const blob = new Blob([result], {type: 'image/webp'});
const blobURL = URL.createObjectURL(blob);
const img = document.createElement('img');
img.src = blobURL;

View File

@@ -5,7 +5,7 @@
"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"
"libwebp": "webmproject/libwebp#v1.0.0"
},
"devDependencies": {
"napa": "^3.0.0"

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

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

7614
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,29 +8,17 @@
"build:codecs": "npm run build:mozjpeg_enc",
"start": "webpack serve --host 0.0.0.0 --hot",
"build": "webpack -p",
"lint": "eslint src",
"test": "npm run build && mocha -R spec && karma start"
"lint": "tslint -c tslint.json -t verbose 'src/**/*.{ts,js}'",
"lintfix": "tslint -c tslint.json -t verbose --fix 'src/**/*.{ts,js}'",
"test": "npm run lint && npm run build && npm run test:e2e && npm run test:unit",
"test:e2e": "mocha -R spec test/e2e",
"test:unit": "karmatic"
},
"eslintConfig": {
"extends": [
"standard",
"standard-jsx"
],
"rules": {
"indent": [
2,
2
],
"semi": [
2,
"always"
],
"prefer-const": 1
"husky": {
"hooks": {
"pre-commit": "npm run lint"
}
},
"eslintIgnore": [
"build/*"
],
"devDependencies": {
"@types/chai": "^4.1.3",
"@types/karma": "^1.7.3",
@@ -52,31 +40,18 @@
"clean-webpack-plugin": "^0.1.19",
"copy-webpack-plugin": "^4.5.1",
"css-loader": "^0.28.11",
"eslint": "^4.18.2",
"eslint-config-standard": "^11.0.0",
"eslint-config-standard-jsx": "^5.0.0",
"eslint-plugin-import": "^2.10.0",
"eslint-plugin-node": "^6.0.1",
"eslint-plugin-promise": "^3.7.0",
"eslint-plugin-react": "^7.7.0",
"eslint-plugin-standard": "^3.0.1",
"exports-loader": "^0.7.0",
"express": "^4.16.3",
"file-loader": "^1.1.11",
"html-webpack-plugin": "^3.0.6",
"husky": "^1.0.0-rc.9",
"if-env": "^1.0.4",
"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",
"karmatic": "^1.1.7",
"loader-utils": "^1.1.0",
"mini-css-extract-plugin": "^0.3.0",
"mocha": "^5.2.0",
"node-sass": "^4.7.2",
"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",
@@ -85,7 +60,8 @@
"source-map-loader": "^0.2.3",
"style-loader": "^0.20.3",
"ts-loader": "^4.0.1",
"tslint": "^5.9.1",
"tslint": "^5.10.0",
"tslint-config-airbnb": "^5.9.2",
"tslint-config-semistandard": "^7.0.0",
"tslint-react": "^3.5.1",
"typescript": "^2.7.2",
@@ -98,12 +74,12 @@
},
"dependencies": {
"classnames": "^2.2.5",
"comlink": "^3.0.3",
"comlink-loader": "^1.0.0",
"material-components-web": "^0.32.0",
"material-radial-progress": "git+https://gist.github.com/02134901c77c5309924bfcf8b4435ebe.git",
"preact": "^8.2.7",
"preact-i18n": "^1.2.0",
"preact-material-components": "^1.3.7",
"preact-material-components-drawer": "git+https://gist.github.com/a78fceed440b98e62582e4440b86bfab.git",
"preact-router": "^2.6.0"
}
}

13
src/codecs/encoders.ts Normal file
View File

@@ -0,0 +1,13 @@
import * as mozJPEG from './mozjpeg/encoder';
import * as identity from './identity/encoder';
export type EncoderState = identity.EncoderState | mozJPEG.EncoderState;
export type EncoderOptions = identity.EncodeOptions | mozJPEG.EncodeOptions;
export type EncoderType = keyof typeof encoderMap;
export const encoderMap = {
[identity.type]: identity,
[mozJPEG.type]: mozJPEG,
};
export const encoders = Array.from(Object.values(encoderMap));

View File

@@ -0,0 +1,6 @@
export interface EncodeOptions {}
export interface EncoderState { type: typeof type; options: EncodeOptions; }
export const type = 'identity';
export const label = 'Original image';
export const defaultOptions: EncodeOptions = {};

View File

@@ -1,7 +1,6 @@
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.
import { EncodeOptions } from './encoder';
const wasmBinaryUrl = require('../../../codecs/mozjpeg_enc/mozjpeg_enc.wasm');
// API exposed by wasm module. Details in the codecs README.
@@ -15,17 +14,18 @@ interface ModuleAPI {
get_result_size(): number;
}
export class MozJpegEncoder implements Encoder {
export default class MozJpegEncoder {
private emscriptenModule: Promise<EmscriptenWasm.Module>;
private api: Promise<ModuleAPI>;
constructor() {
this.emscriptenModule = new Promise(resolve => {
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')) {
if (url.endsWith('.wasm')) {
return wasmBinaryUrl;
}
return url;
@@ -38,12 +38,14 @@ export class MozJpegEncoder implements Encoder {
// 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`
// 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', []),
@@ -57,13 +59,13 @@ export class MozJpegEncoder implements Encoder {
})();
}
async encode(data: ImageData): Promise<ArrayBuffer> {
async encode(data: ImageData, options: EncodeOptions): 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);
api.encode(p, data.width, data.height, options.quality);
const resultPointer = api.get_result_pointer();
const resultSize = api.get_result_size();
const resultView = new Uint8Array(m.HEAP8.buffer, resultPointer, resultSize);

View File

@@ -0,0 +1,16 @@
import EncoderWorker from './EncoderWorker';
export interface EncodeOptions { quality: number; }
export interface EncoderState { type: typeof type; options: EncodeOptions; }
export const type = 'mozjpeg';
export const label = 'MozJPEG';
export const mimeType = 'image/jpeg';
export const extension = 'jpg';
export const defaultOptions: EncodeOptions = { quality: 7 };
export async function encode(data: ImageData, options: EncodeOptions) {
// We need to await this because it's been comlinked.
const encoder = await new EncoderWorker();
return encoder.encode(data, options);
}

View File

@@ -0,0 +1,35 @@
import { h, Component } from 'preact';
import { EncodeOptions } from './encoder';
import { bind } from '../../lib/util';
type Props = {
options: EncodeOptions,
onChange(newOptions: EncodeOptions): void
};
export default class MozJpegCodecOptions extends Component<Props, {}> {
@bind
onChange(event: Event) {
const el = event.currentTarget as HTMLInputElement;
this.props.onChange({ quality: Number(el.value) });
}
render({ options }: Props) {
return (
<div>
<label>
Quality:
<input
name="quality"
type="range"
min="1"
max="100"
step="1"
value={'' + options.quality}
onChange={this.onChange}
/>
</label>
</div>
);
}
}

View File

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

View File

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

View File

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

View File

@@ -2,56 +2,217 @@ import { h, Component } from 'preact';
import { bind, bitmapToImageData } from '../../lib/util';
import * as style from './style.scss';
import Output from '../output';
import Options from '../options';
import { FileDropEvent } from './custom-els/FileDrop';
import './custom-els/FileDrop';
import {MozJpegEncoder} from '../../lib/codec-wrappers/mozjpeg-enc';
import * as mozJPEG from '../../codecs/mozjpeg/encoder';
import * as identity from '../../codecs/identity/encoder';
import { EncoderState, EncoderType, EncoderOptions, encoderMap } from '../../codecs/encoders';
type Props = {};
interface SourceImage {
file: File;
bmp: ImageBitmap;
data: ImageData;
}
type State = {
img?: ImageBitmap
};
interface EncodedImage {
encoderState: EncoderState;
bmp?: ImageBitmap;
loading: boolean;
/** Counter of the latest bmp currently encoding */
loadingCounter: number;
/** Counter of the latest bmp encoded */
loadedCounter: number;
}
interface Props {}
interface State {
source?: SourceImage;
images: [EncodedImage, EncodedImage];
loading: boolean;
error?: string;
}
export default class App extends Component<Props, State> {
state: State = {};
state: State = {
loading: false,
images: [
{
encoderState: { type: identity.type, options: identity.defaultOptions },
loadingCounter: 0,
loadedCounter: 0,
loading: false
},
{
encoderState: { type: mozJPEG.type, options: mozJPEG.defaultOptions },
loadingCounter: 0,
loadedCounter: 0,
loading: false
}
]
};
constructor() {
super();
// In development, persist application state across hot reloads:
if (process.env.NODE_ENV === 'development') {
this.setState(window.STATE);
this.componentDidUpdate = () => {
const oldCDU = this.componentDidUpdate;
this.componentDidUpdate = (props, state) => {
if (oldCDU) oldCDU.call(this, props, state);
window.STATE = this.state;
};
}
}
@bind
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 });
onEncoderChange(index: 0 | 1, type: EncoderType, options?: EncoderOptions): void {
const images = this.state.images.slice() as [EncodedImage, EncodedImage];
const image = images[index];
// Some type cheating here.
// encoderMap[type].defaultOptions is always safe.
// options should always be correct for the type, but TypeScript isn't smart enough.
const encoderState: EncoderState = {
type,
options: options ? options : encoderMap[type].defaultOptions
} as EncoderState;
images[index] = {
...image,
encoderState,
};
this.setState({ images });
}
render({ }: Props, { img }: State) {
return (
<div id="app" class={style.app}>
{img ?
<Output img={img} />
:
<div>
<h1>Select an image</h1>
<input type="file" onChange={this.onFileChange} />
</div>
onOptionsChange(index: 0 | 1, options: EncoderOptions): void {
this.onEncoderChange(index, this.state.images[index].encoderState.type, options);
}
componentDidUpdate(prevProps: Props, prevState: State): void {
const { source, images } = this.state;
for (const [i, image] of images.entries()) {
if (source !== prevState.source || image !== prevState.images[i]) {
this.updateImage(i);
}
}
}
@bind
async onFileChange(event: Event): Promise<void> {
const fileInput = event.target as HTMLInputElement;
const file = fileInput.files && fileInput.files[0];
if (!file) return;
await this.updateFile(file);
}
@bind
async onFileDrop(event: FileDropEvent) {
const { file } = event;
if (!file) return;
await this.updateFile(file);
}
async updateFile(file: File) {
this.setState({ loading: true });
try {
const bmp = await createImageBitmap(file);
// compute the corresponding ImageData once since it only changes when the file changes:
const data = await bitmapToImageData(bmp);
this.setState({
source: { data, bmp, file },
error: undefined,
loading: false,
});
} catch (err) {
this.setState({ error: 'IMAGE_INVALID', loading: false });
}
}
async updateImage(index: number): Promise<void> {
const { source, images } = this.state;
if (!source) return;
let image = images[index];
// Each time we trigger an async encode, the ID changes.
image.loadingCounter = image.loadingCounter + 1;
const loadingCounter = image.loadingCounter;
image.loading = true;
this.setState({ });
const result = await this.updateCompressedImage(source, image.encoderState);
image = this.state.images[index];
// If a later encode has landed before this one, return.
if (loadingCounter < image.loadedCounter) return;
image.bmp = result;
image.loading = image.loadingCounter !== loadingCounter;
image.loadedCounter = loadingCounter;
this.setState({ });
}
async updateCompressedImage(source: SourceImage, encodeData: EncoderState): Promise<ImageBitmap> {
// Special case for identity
if (encodeData.type === identity.type) return source.bmp;
try {
const compressedData = await (() => {
switch (encodeData.type) {
case mozJPEG.type: return mozJPEG.encode(source.data, encodeData.options);
default: throw Error(`Unexpected encoder name`);
}
</div>
})();
const blob = new Blob([compressedData], {
type: encoderMap[encodeData.type].mimeType
});
const bitmap = await createImageBitmap(blob);
this.setState({ error: '' });
return bitmap;
} catch (err) {
this.setState({ error: `Encoding error (type=${encodeData.type}): ${err}` });
throw err;
}
}
render({ }: Props, { loading, error, images, source }: State) {
const [leftImageBmp, rightImageBmp] = images.map(i => i.bmp);
loading = loading || images.some(image => image.loading);
return (
<file-drop accept="image/*" onfiledrop={this.onFileDrop}>
<div id="app" class={style.app}>
{(leftImageBmp && rightImageBmp) ? (
<Output leftImg={leftImageBmp} rightImg={rightImageBmp} />
) : (
<div class={style.welcome}>
<h1>Select an image</h1>
<input type="file" onChange={this.onFileChange} />
</div>
)}
{images.map((image, index) => (
<span class={index ? style.rightLabel : style.leftLabel}>
{encoderMap[image.encoderState.type].label}
</span>
))}
{images.map((image, index) => (
<Options
class={index ? style.rightOptions : style.leftOptions}
encoderState={image.encoderState}
onTypeChange={this.onEncoderChange.bind(this, index)}
onOptionsChange={this.onOptionsChange.bind(this, index)}
/>
))}
{loading && <span style={{ position: 'fixed', top: 0, left: 0 }}>Loading...</span>}
{error && <span style={{ position: 'fixed', top: 0, left: 0 }}>Error: {error}</span>}
</div>
</file-drop>
);
}
}

View File

@@ -1,3 +1,83 @@
.app h1 {
color: green;
/*
Note: These styles are temporary. They will be replaced before going live.
*/
.app {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: hidden;
contain: strict;
.leftLabel,
.rightLabel {
position: fixed;
bottom: 0;
padding: 5px 10px;
background: rgba(0,0,0,0.5);
color: #fff;
}
.leftLabel { left: 0; }
.rightLabel { right: 0; }
.leftOptions,
.rightOptions {
position: fixed;
bottom: 40px;
}
.leftOptions { left: 10px; }
.rightOptions { right: 10px; }
}
.welcome {
position: absolute;
display: inline-block;
left: 50%;
top: 50%;
padding: 20px;
transform: translate(-50%, -50%);
h1 {
font-weight: inherit;
font-size: 150%;
text-align: center;
}
input {
display: inline-block;
width: 16em;
padding: 5px;
margin: 0 auto;
-webkit-appearance: none;
border: 1px solid #b68c86;
background: #f0d3cf;
box-shadow: inset 0 0 1px #fff;
border-radius: 3px;
cursor: pointer;
}
}
:global {
file-drop {
overflow: hidden;
touch-action: none;
height:100%;
width:100%;
&.drop-valid {
transition: opacity 200ms ease-in-out, background-color 200ms;
opacity: 0.5;
background-color:green;
}
&.drop-invalid {
transition: opacity 200ms ease-in-out, background-color 200ms;
opacity: 0.5;
background-color:red;
}
}
}

View File

@@ -0,0 +1,63 @@
import { h, Component } from 'preact';
import * as style from './style.scss';
import { bind } from '../../lib/util';
import MozJpegEncoderOptions from '../../codecs/mozjpeg/options';
import { type as mozJPEGType } from '../../codecs/mozjpeg/encoder';
import { type as identityType } from '../../codecs/identity/encoder';
import { EncoderState, EncoderType, EncoderOptions, encoders } from '../../codecs/encoders';
const encoderOptionsComponentMap = {
[mozJPEGType]: MozJpegEncoderOptions,
[identityType]: undefined
};
interface Props {
class?: string;
encoderState: EncoderState;
onTypeChange(newType: EncoderType): void;
onOptionsChange(newOptions: EncoderOptions): void;
}
interface State {}
export default class Options extends Component<Props, State> {
typeSelect?: HTMLSelectElement;
@bind
onTypeChange(event: Event) {
const el = event.currentTarget as HTMLSelectElement;
// The select element only has values matching encoder types,
// so 'as' is safe here.
const type = el.value as EncoderType;
this.props.onTypeChange(type);
}
render({ class: className, encoderState, onOptionsChange }: Props) {
const EncoderOptionComponent = encoderOptionsComponentMap[encoderState.type];
return (
<div class={`${style.options}${className ? (' ' + className) : ''}`}>
<label>
Mode:
<select value={encoderState.type} onChange={this.onTypeChange}>
{encoders.map(encoder => (
<option value={encoder.type}>{encoder.label}</option>
))}
</select>
</label>
{EncoderOptionComponent &&
<EncoderOptionComponent
options={
// Casting options, as encoderOptionsComponentMap[encodeData.type] ensures the correct type,
// but typescript isn't smart enough.
encoderState.options as typeof EncoderOptionComponent['prototype']['props']['options']
}
onChange={onOptionsChange}
/>
}
</div>
);
}
}

View File

@@ -0,0 +1,38 @@
/*
Note: These styles are temporary. They will be replaced before going live.
*/
.options {
width: 180px;
padding: 10px;
background: rgba(50,50,50,0.8);
border: 1px solid #222;
box-shadow: inset 0 0 1px #fff, 0 0 1px #fff;
border-radius: 3px;
color: #eee;
overflow: auto;
z-index: 1;
transition: opacity 300ms ease;
&:not(:hover) {
opacity: .6;
}
label {
display: block;
padding: 5px;
font-weight: bold;
select {
margin-left: 5px;
}
input {
vertical-align: middle;
}
}
pre {
font-size: 10px;
}
}

View File

@@ -24,17 +24,17 @@ interface SetTransformOpts {
allowChangeEvent?: boolean;
}
function getDistance (a: Point, b?: Point): number {
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 {
function getMidpoint(a: Point, b?: Point): Point {
if (!b) return a;
return {
clientX: (a.clientX + b.clientX) / 2,
clientY: (a.clientY + b.clientY) / 2
clientY: (a.clientY + b.clientY) / 2,
};
}
@@ -42,15 +42,15 @@ function getMidpoint (a: Point, b?: Point): Point {
// Given that, better to use something everything supports.
let cachedSvg: SVGSVGElement;
function getSVG (): SVGSVGElement {
function getSVG(): SVGSVGElement {
return cachedSvg || (cachedSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'));
}
function createMatrix (): SVGMatrix {
function createMatrix(): SVGMatrix {
return getSVG().createSVGMatrix();
}
function createPoint (): SVGPoint {
function createPoint(): SVGPoint {
return getSVG().createSVGPoint();
}
@@ -62,7 +62,7 @@ export default class PinchZoom extends HTMLElement {
// Current transform.
private _transform: SVGMatrix = createMatrix();
constructor () {
constructor() {
super();
// Watch for children changes.
@@ -79,42 +79,42 @@ export default class PinchZoom extends HTMLElement {
event.preventDefault();
return true;
},
move: previousPointers => {
move: (previousPointers) => {
this._onPointerMove(previousPointers, pointerTracker.currentPointers);
}
},
});
this.addEventListener('wheel', event => this._onWheel(event));
}
connectedCallback () {
connectedCallback() {
this._stageElChange();
}
get x () {
get x() {
return this._transform.e;
}
get y () {
get y() {
return this._transform.f;
}
get scale () {
get scale() {
return this._transform.a;
}
/**
* Update the stage with a given scale/x/y.
*/
setTransform (opts: SetTransformOpts = {}) {
setTransform(opts: SetTransformOpts = {}) {
const {
scale = this.scale,
allowChangeEvent = false
allowChangeEvent = false,
} = opts;
let {
x = this.x,
y = this.y
y = this.y,
} = opts;
// If we don't have an element to position, just set the value as given.
@@ -144,7 +144,7 @@ export default class PinchZoom extends HTMLElement {
bottomRight.y = positioningElBounds.height + topLeft.y;
// Calculate the intended position of _positioningEl.
let matrix = createMatrix()
const matrix = createMatrix()
.translate(x, y)
.scale(scale)
// Undo current transform
@@ -174,7 +174,7 @@ export default class PinchZoom extends HTMLElement {
/**
* Update transform values without checking bounds. This is only called in setTransform.
*/
_updateTransform (scale: number, x: number, y: number, allowChangeEvent: boolean) {
_updateTransform(scale: number, x: number, y: number, allowChangeEvent: boolean) {
// Return if there's no change
if (
scale === this.scale &&
@@ -202,7 +202,7 @@ export default class PinchZoom extends HTMLElement {
* require a single element to be the child of <pinch-zoom>, and
* that's the element we pan/scale.
*/
private _stageElChange () {
private _stageElChange() {
this._positioningEl = undefined;
if (this.children.length === 0) {
@@ -220,7 +220,7 @@ export default class PinchZoom extends HTMLElement {
this.setTransform();
}
private _onWheel (event: WheelEvent) {
private _onWheel(event: WheelEvent) {
event.preventDefault();
const thisRect = this.getBoundingClientRect();
@@ -239,11 +239,11 @@ export default class PinchZoom extends HTMLElement {
this._applyChange({
scaleDiff,
originX: event.clientX - thisRect.left,
originY: event.clientY - thisRect.top
originY: event.clientY - thisRect.top,
});
}
private _onPointerMove (previousPointers: Pointer[], currentPointers: Pointer[]) {
private _onPointerMove(previousPointers: Pointer[], currentPointers: Pointer[]) {
// Combine next points with previous points
const thisRect = this.getBoundingClientRect();
@@ -263,16 +263,16 @@ export default class PinchZoom extends HTMLElement {
this._applyChange({
originX, originY, scaleDiff,
panX: newMidpoint.clientX - prevMidpoint.clientX,
panY: newMidpoint.clientY - prevMidpoint.clientY
panY: newMidpoint.clientY - prevMidpoint.clientY,
});
}
/** Transform the view & fire a change event */
private _applyChange (opts: ApplyChangeOpts = {}) {
private _applyChange(opts: ApplyChangeOpts = {}) {
const {
panX = 0, panY = 0,
originX = 0, originY = 0,
scaleDiff = 1
scaleDiff = 1,
} = opts;
const matrix = createMatrix()
@@ -290,7 +290,7 @@ export default class PinchZoom extends HTMLElement {
scale: matrix.a,
x: matrix.e,
y: matrix.f,
allowChangeEvent: true
allowChangeEvent: true,
});
}
}

View File

@@ -46,9 +46,9 @@ export default class TwoUp extends HTMLElement {
move: () => {
this._pointerChange(
pointerTracker.startPointers[0],
pointerTracker.currentPointers[0]
pointerTracker.currentPointers[0],
);
}
},
});
}

View File

@@ -11,6 +11,6 @@ interface Window {
declare namespace JSX {
interface IntrinsicElements {
"two-up": HTMLAttributes
'two-up': HTMLAttributes;
}
}

View File

@@ -3,16 +3,17 @@ 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 { bind, drawBitmapToCanvas } from '../../lib/util';
import { twoUpHandle } from './custom-els/TwoUp/styles.css';
type Props = {
img: ImageBitmap
leftImg: ImageBitmap,
rightImg: ImageBitmap
};
type State = {};
export default class App extends Component<Props, State> {
export default class Output extends Component<Props, State> {
state: State = {};
canvasLeft?: HTMLCanvasElement;
canvasRight?: HTMLCanvasElement;
@@ -20,26 +21,22 @@ export default class App extends Component<Props, State> {
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() {
if (this.canvasLeft) {
drawBitmapToCanvas(this.canvasLeft, this.props.leftImg);
}
if (this.canvasRight) {
drawBitmapToCanvas(this.canvasRight, this.props.rightImg);
}
}
componentDidMount() {
this.updateCanvases(this.props.img);
}
componentDidUpdate({ img }: Props) {
if (img !== this.props.img) this.updateCanvases(this.props.img);
componentDidUpdate(prevProps: Props) {
if (prevProps.leftImg !== this.props.leftImg && this.canvasLeft) {
drawBitmapToCanvas(this.canvasLeft, this.props.leftImg);
}
if (prevProps.rightImg !== this.props.rightImg && this.canvasRight) {
drawBitmapToCanvas(this.canvasRight, this.props.rightImg);
}
}
@bind
@@ -78,9 +75,9 @@ export default class App extends Component<Props, State> {
this.pinchZoomLeft.dispatchEvent(clonedEvent);
}
render({ img }: Props, { }: State) {
render({ leftImg, rightImg }: Props, { }: State) {
return (
<div>
<div class={style.output}>
<two-up
// Event redirecting. See onRetargetableEvent.
onTouchStartCapture={this.onRetargetableEvent}
@@ -91,13 +88,12 @@ export default class App extends Component<Props, State> {
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} />
<canvas class={style.outputCanvas} ref={c => this.canvasLeft = c as HTMLCanvasElement} width={leftImg.width} height={leftImg.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} />
<canvas class={style.outputCanvas} ref={c => this.canvasRight = c as HTMLCanvasElement} width={rightImg.width} height={rightImg.height} />
</pinch-zoom>
</two-up>
<p>And that's all the app does so far!</p>
</div>
);
}

View File

@@ -1,3 +1,29 @@
/*
Note: These styles are temporary. They will be replaced before going live.
*/
%fill {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: hidden;
contain: strict;
}
.output {
@extend %fill;
> two-up {
@extend %fill;
> pinch-zoom {
@extend %fill;
}
}
}
.outputCanvas {
image-rendering: pixelated;
}

View File

@@ -32,9 +32,10 @@ const isPointerEvent = (event: any): event is 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);
export type InputEvent = TouchEvent | PointerEvent | MouseEvent;
type StartCallback = ((pointer: Pointer, event: InputEvent) => boolean);
type MoveCallback = ((previousPointers: Pointer[], event: InputEvent) => void);
type EndCallback = ((pointer: Pointer, event: InputEvent) => void);
interface PointerTrackerCallbacks {
/**
@@ -95,7 +96,7 @@ export class PointerTracker {
const {
start = () => true,
move = noop,
end = noop
end = noop,
} = callbacks;
this._startCallback = start;
@@ -120,7 +121,7 @@ export class PointerTracker {
* @param event Related event
* @returns Whether the pointer is being tracked.
*/
private _triggerPointerStart (pointer: Pointer, event: PointerEvent | MouseEvent | TouchEvent): boolean {
private _triggerPointerStart (pointer: Pointer, event: InputEvent): boolean {
if (!this._startCallback(pointer, event)) return false;
this.currentPointers.push(pointer);
this.startPointers.push(pointer);
@@ -193,7 +194,7 @@ export class PointerTracker {
* @param event Related event
*/
@bind
private _triggerPointerEnd (pointer: Pointer, event: PointerEvent | MouseEvent | TouchEvent): boolean {
private _triggerPointerEnd (pointer: Pointer, event: InputEvent): boolean {
const index = this.currentPointers.findIndex(p => p.id === pointer.id);
// Not a pointer we're interested in?
if (index === -1) return false;

View File

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

View File

@@ -16,12 +16,12 @@ export function bind(target: any, propertyKey: string, descriptor: PropertyDescr
// define an instance property pointing to the bound function.
// This effectively "caches" the bound prototype method as an instance property.
get() {
let bound = descriptor.value.bind(this);
const bound = descriptor.value.bind(this);
Object.defineProperty(this, propertyKey, {
value: bound
value: bound,
});
return bound;
}
},
};
}
@@ -37,9 +37,16 @@ export async function bitmapToImageData(bitmap: ImageBitmap): Promise<ImageData>
// Draw image onto canvas
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error("Could not create canvas context");
throw new Error('Could not create canvas context');
}
ctx.drawImage(bitmap, 0, 0);
return ctx.getImageData(0, 0, bitmap.width, bitmap.height);
}
/** Replace the contents of a canvas with the given bitmap */
export function drawBitmapToCanvas(canvas: HTMLCanvasElement, img: ImageBitmap) {
const ctx = canvas.getContext('2d');
if (!ctx) throw Error('Canvas not initialized');
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
}

View File

@@ -1,3 +1,7 @@
/*
Note: These styles are temporary. They will be replaced before going live.
*/
@import './reset.scss';
html, body {
@@ -5,6 +9,8 @@ html, body {
width: 100%;
padding: 0;
margin: 0;
font: 14px/1.3 Roboto,'Helvetica Neue',arial,helvetica,sans-serif;
overflow: hidden;
overscroll-behavior: none;
contain: strict;
}

View File

@@ -1,3 +1,7 @@
/*
Note: These styles are temporary. They will be replaced before going live.
*/
button, a, img, input, select, textarea {
-webkit-tap-highlight-color: rgba(0,0,0,0);
}

View File

@@ -1,41 +0,0 @@
<!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>

View File

@@ -1,43 +0,0 @@
<!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>

View File

@@ -1,40 +1,42 @@
const express = require("express");
const app = express();
const http = require("http");
const puppeteer = require("puppeteer");
const { fingerDown } = require("./finger.js");
const { expect } = require("chai");
/* eslint-env mocha */
async function staticWebServer(path) {
const path = require('path');
const express = require('express');
const http = require('http');
const puppeteer = require('puppeteer');
const { fingerDown } = require('../lib/finger');
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));
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() {
describe('some e2e test', function () {
before(async function () {
// Start webserver
const { address, server } = await staticWebServer(".");
const { address, server } = await staticWebServer(path.resolve(__dirname, '../fixtures'));
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"
await this.page.goto(`${this.address}/sample.html`, {
waitUntil: 'networkidle2'
});
});
it("can tap", async function() {
const btn = await this.page.$("button");
it('can tap', async function () {
const btn = await this.page.$('button');
await btn.tap();
const result = await this.page.evaluate(_ => {
return window.lol;
@@ -42,8 +44,8 @@ describe("some e2e test", function() {
expect(result).to.equal(true);
});
it("can tap manually", async function() {
const btn = await this.page.$("button");
it('can tap manually', async function () {
const btn = await this.page.$('button');
const box = await btn.boundingBox();
const finger = fingerDown(
this.page,
@@ -57,9 +59,9 @@ describe("some e2e test", function() {
expect(result).to.equal(true);
});
it("does some taps", async function() {});
it('does some taps', async function () {});
after(async function() {
after(async function () {
this.server.close();
await this.browser.close();
});

View File

@@ -2,37 +2,37 @@ let touchPoints = [];
let idCnt = 0;
class Finger {
constructor(point, page) {
constructor (point, page) {
this._point = point;
this._page = page;
}
move(x, y) {
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",
this._page.touchscreen._client.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints,
modifiers: page._keyboard._modifiers
modifiers: this._page._keyboard._modifiers
});
}
up() {
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",
this._page.touchscreen._client.send('Input.dispatchTouchEvent', {
type: 'touchEnd',
modifiers: this._page._keyboard._modifiers
});
} else {
this._page.touchscreen._client.send("Input.dispatchTouchEvent", {
type: "touchMove",
this._page.touchscreen._client.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints,
modifiers: this._page._keyboard._modifiers
});
@@ -40,7 +40,7 @@ class Finger {
}
}
function fingerDown(page, x, y) {
function fingerDown (page, x, y) {
const id = idCnt++;
const point = {
x: Math.round(x),
@@ -48,8 +48,8 @@ function fingerDown(page, x, y) {
id
};
touchPoints.push(point);
page.touchscreen._client.send("Input.dispatchTouchEvent", {
type: "touchStart",
page.touchscreen._client.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints,
modifiers: page._keyboard._modifiers
});

27
test/unit/index.test.js Normal file
View File

@@ -0,0 +1,27 @@
/* eslint-env jest */
import { h, Component, render } from 'preact';
import App from '../../src/components/app';
describe('<App />', () => {
let scratch;
beforeEach(() => {
scratch = document.createElement('div');
document.body.appendChild(scratch);
});
afterEach(() => {
render(<span />, scratch, scratch.firstChild);
scratch.remove();
});
it('should render', () => {
let app;
render(<App ref={c => { app = c; }} />, scratch);
expect(app instanceof Component).toBe(true);
expect(scratch.innerHTML).toBe(
`<div id="app" class="app__1wROX"><div><h1>Select an image</h1><input type="file"></div></div>`
);
});
});

View File

@@ -1,16 +1,19 @@
{
"extends": [
"tslint-config-semistandard",
"tslint-config-airbnb",
"tslint-react"
],
"rules": {
"quotemark": [true, "single", "jsx-double", "avoid-escape"],
"no-use-before-declare": false,
"no-floating-promises": false,
"space-before-function-paren": [true, false],
"jsx-boolean-value": [true, "never"],
"jsx-no-multiline-js": false,
"jsx-no-bind": true,
"jsx-no-lambda": true
"jsx-no-lambda": true,
"function-name": false,
"variable-name": [true, "check-format", "allow-leading-underscore"]
},
"linterOptions": {
"exclude": [
"build"
]
}
}
}

View File

@@ -33,7 +33,8 @@ module.exports = function (_, env) {
filename: isProd ? '[name].[chunkhash:5].js' : '[name].js',
chunkFilename: '[name].chunk.[chunkhash:5].js',
path: path.join(__dirname, 'build'),
publicPath: '/'
publicPath: '/',
globalObject: 'self'
},
resolve: {
extensions: ['.ts', '.tsx', '.js', '.jsx', '.scss', '.css'],
@@ -97,6 +98,10 @@ module.exports = function (_, env) {
}
]
},
{
test: /\.worker.[tj]sx?$/,
loader: 'comlink-loader'
},
{
test: /\.tsx?$/,
exclude: nodeModules,
@@ -111,16 +116,16 @@ module.exports = function (_, env) {
{
// 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',
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',
loader: 'file-loader'
}
],
]
},
plugins: [
new webpack.IgnorePlugin(/(fs)/, /\/codecs\//),