Merge branch 'master' into firebase

This commit is contained in:
Jason Miller
2018-05-15 11:59:20 -04:00
committed by GitHub
36 changed files with 936 additions and 2505 deletions

View File

@@ -1,33 +1,13 @@
{ {
"presets": [ "plugins": [
[ "transform-class-properties",
"env", "transform-react-constant-elements",
{ "transform-react-remove-prop-types",
"loose": true, [
"uglify": true, "transform-react-jsx",
"modules": false, {
"targets": { "pragma": "h"
"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"
}
]
]
} }

2
.gitignore vendored
View File

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

View File

@@ -1,409 +0,0 @@
const path = require('path');
const parse5 = require('parse5');
const nwmatcher = require('nwmatcher');
const css = require('css');
const prettyBytes = require('pretty-bytes');
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.fonts] If `true`, keeps critical `@font-face` rules and preloads them. If `false`, removes the rules and does not preload the fonts
* @param {Boolean} [options.preloadFonts=false] Preloads critical fonts (even those removed by `{fonts:false}`)
* @param {Boolean} [options.removeFonts=false] Remove all fonts (even critical ones)
* @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) {
// 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) => {
this.process(compiler, compilation, htmlPluginData)
.then(result => { callback(null, result); })
.catch(callback);
});
});
}
readFile (filename, encoding) {
return new Promise((resolve, reject) => {
this.fs.readFile(filename, encoding, (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
}
async process (compiler, compilation, htmlPluginData) {
const outputPath = compiler.options.output.path;
// Parse the generated HTML in a DOM we can mutate
const document = parse5.parse(htmlPluginData.html, PARSE5_OPTS);
makeDomInteractive(document);
// `external:false` skips processing of external sheets
if (this.options.external !== false) {
const externalSheets = document.querySelectorAll('link[rel="stylesheet"]');
await Promise.all(externalSheets.map(
link => this.embedLinkedStylesheet(link, compilation, outputPath)
));
}
// go through all the style tags in the document and reduce them to only critical CSS
const styles = document.querySelectorAll('style');
await Promise.all(styles.map(
style => this.processStyle(style, document)
));
// serialize the document back to HTML and we're done
const html = parse5.serialize(document, PARSE5_OPTS);
return { html };
}
/** Inline the target stylesheet referred to by a <link rel="stylesheet"> (assuming it passes `options.filter`) */
async 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(/^\.\//, '')];
// CSS loader is only injected for the first sheet, then this becomes an empty string
let cssLoaderPreamble = `function $loadcss(u,l){(l=document.createElement('link')).rel='stylesheet';l.href=u;document.head.appendChild(l)}`;
const media = typeof this.options.media === 'string' ? this.options.media : 'all';
// { preload:'js', media:true }
// { preload:'js', media:'print' }
if (this.options.media) {
cssLoaderPreamble = cssLoaderPreamble.replace('l.href', "l.media='only x';l.onload=function(){l.media='" + media + "'};l.href");
}
// Attempt to read from assets, falling back to a disk read
const sheet = asset ? asset.source() : await this.readFile(filename, 'utf8');
// 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 === 'js') {
const script = document.createElement('script');
script.appendChild(document.createTextNode(`${cssLoaderPreamble}$loadcss(${JSON.stringify(href)})`));
link.parentNode.insertBefore(script, link.nextSibling);
cssLoaderPreamble = '';
} else if (this.options.preload) {
const bodyLink = document.createElement('link');
bodyLink.setAttribute('rel', 'stylesheet');
bodyLink.setAttribute('href', href);
document.body.appendChild(bodyLink);
} else if (this.options.media) {
// @see https://github.com/filamentgroup/loadCSS/blob/af1106cfe0bf70147e22185afa7ead96c01dec48/src/loadCSS.js#L26
link.setAttribute('rel', 'stylesheet');
link.removeAttribute('as');
link.setAttribute('media', 'only x');
link.setAttribute('onload', "this.media='" + media + "'");
} 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. */
async processStyle (style) {
const options = this.options;
const document = style.ownerDocument;
const head = document.querySelector('head');
// 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;
const ast = css.parse(sheet);
// a string to search for font names (very loose)
let criticalFonts = '';
// 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 (rule.declarations) {
for (let i = 0; i < rule.declarations.length; i++) {
const decl = rule.declarations[i];
if (decl.property.match(/\bfont\b/i)) {
criticalFonts += ' ' + decl.value;
}
}
}
}
// keep font rules, they're handled in the second pass:
if (rule.type === 'font-face') return;
// If there are no remaining rules, remove the whole rule:
return !rule.rules || rule.rules.length !== 0;
});
const preloadedFonts = [];
visit(ast, rule => {
// only process @font-face rules in the second pass
if (rule.type !== 'font-face') return;
let family, src;
for (let i = 0; i < rule.declarations.length; i++) {
const decl = rule.declarations[i];
if (decl.property === 'src') {
// @todo parse this properly and generate multiple preloads with type="font/woff2" etc
src = (decl.value.match(/url\s*\(\s*(['"]?)(.+?)\1\s*\)/) || [])[2];
} else if (decl.property === 'font-family') {
family = decl.value;
}
}
if (src && (options.fonts === true || options.preloadFonts) && preloadedFonts.indexOf(src) === -1) {
preloadedFonts.push(src);
const preload = document.createElement('link');
preload.setAttribute('rel', 'preload');
preload.setAttribute('as', 'font');
if (src.match(/:\/\//)) {
preload.setAttribute('crossorigin', 'anonymous');
}
preload.setAttribute('href', src.trim());
head.appendChild(preload);
}
// if we're missing info or the font is unused, remove the rule:
if (!family || !src || criticalFonts.indexOf(family) === -1 || !options.fonts || options.removeFonts) return false;
});
sheet = css.stringify(ast, { compress: this.options.compress !== false });
// 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 = 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))
);
}
const reflectedProperty = attributeName => ({
get () {
return this.getAttribute(attributeName);
},
set (value) {
this.setAttribute(attributeName, value);
}
});
/** Methods and descriptors to mix into Element.prototype */
const ElementExtensions = {
nodeName: {
get () {
return this.tagName.toUpperCase();
}
},
id: reflectedProperty('id'),
className: reflectedProperty('class'),
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,214 +0,0 @@
const jsdom = require('jsdom');
const os = require('os');
const util = require('util');
const path = require('path');
const loaderUtils = require('loader-utils');
const LibraryTemplatePlugin = require('webpack/lib/LibraryTemplatePlugin');
const NodeTemplatePlugin = require('webpack/lib/node/NodeTemplatePlugin');
const NodeTargetPlugin = require('webpack/lib/node/NodeTargetPlugin');
const SingleEntryPlugin = require('webpack/lib/SingleEntryPlugin');
const DefinePlugin = require('webpack').DefinePlugin;
const MemoryFs = require('memory-fs');
const FILENAME = 'ssr-bundle.js';
const PRERENDER_REG = /\{\{prerender(?::\s*([^}]+)\s*)?\}\}/;
module.exports = function PrerenderLoader (content) {
const options = loaderUtils.getOptions(this) || {};
const outputFilter = options.as === 'string' || options.string ? stringToModule : String;
if (options.disabled === true) {
return outputFilter(content);
}
// When applied to HTML, attempts to inject into a specified {{prerender}} field.
// @note: this is only used when the entry module exports a String or function
// that resolves to a String, otherwise the whole document is serialized.
let inject = false;
if (!this.request.match(/.(js|ts)x?$/i)) {
const matches = content.match(PRERENDER_REG);
if (matches) {
inject = true;
options.entry = matches[1];
}
options.templateContent = content;
}
const callback = this.async();
prerender(this, options, inject)
.then(output => {
callback(null, outputFilter(output));
})
.catch(err => {
console.error(err);
callback(err);
});
};
async function prerender (loaderContext, options, inject) {
const parentCompilation = loaderContext._compilation;
const parentCompiler = rootCompiler(parentCompilation.compiler);
const request = loaderContext.request;
const context = parentCompiler.options.context || process.cwd();
const entry = './' + ((options.entry && [].concat(options.entry).pop().trim()) || path.relative(context, parentCompiler.options.entry));
if (!inject && options.template) {
const loadModule = util.promisify(loaderContext.loadModule);
const source = await loadModule('!!raw-loader!' + path.resolve(context, options.template));
options.templateContent = source;
}
const outputOptions = {
// fix for plugins not using outputfilesystem
path: os.tmpdir(),
filename: FILENAME
};
// Only copy over mini-extract-text-plugin (excluding it breaks extraction entirely)
const plugins = (parentCompiler.options.plugins || []).filter(c => /MiniCssExtractPlugin/i.test(c.constructor.name));
// Compile to an in-memory filesystem since we just want the resulting bundled code as a string
const compiler = parentCompilation.createChildCompiler('prerender', outputOptions, plugins);
compiler.outputFileSystem = new MemoryFs();
// Define PRERENDER to be true within the SSR bundle
new DefinePlugin({
PRERENDER: 'true'
}).apply(compiler);
// ... then define PRERENDER to be false within the client bundle
new DefinePlugin({
PRERENDER: 'false'
}).apply(parentCompiler);
// Compile to CommonJS to be executed by Node
new NodeTemplatePlugin(outputOptions).apply(compiler);
new NodeTargetPlugin().apply(compiler);
new LibraryTemplatePlugin('PRERENDER_RESULT', 'var').apply(compiler);
// Kick off compilation at our entry module (either the parent compiler's entry or a custom one defined via `{{prerender:entry.js}}`)
new SingleEntryPlugin(context, entry, undefined).apply(compiler);
// Set up cache inheritance for the child compiler
const subCache = 'subcache ' + request;
function addChildCache (compilation, data) {
if (compilation.cache) {
if (!compilation.cache[subCache]) compilation.cache[subCache] = {};
compilation.cache = compilation.cache[subCache];
}
}
if (compiler.hooks) {
compiler.hooks.compilation.tap('prerender-loader', addChildCache);
} else {
compiler.plugin('compilation', addChildCache);
}
const compilation = await runChildCompiler(compiler);
let result = '';
let dom, injectParent;
if (compilation.assets[compilation.options.output.filename]) {
// Get the compiled main bundle
const output = compilation.assets[compilation.options.output.filename].source();
const tpl = options.templateContent || '<!DOCTYPE html><html><head></head><body></body></html>';
dom = new jsdom.JSDOM(tpl.replace(PRERENDER_REG, '<div id="PRERENDER_INJECT"></div>'), {
includeNodeLocations: false,
runScripts: 'outside-only'
});
const { window } = dom;
// Find the placeholder node for injection & remove it
const injectPlaceholder = window.document.getElementById('PRERENDER_INJECT');
if (injectPlaceholder) {
injectParent = injectPlaceholder.parentNode;
injectPlaceholder.remove();
}
// These are missing from JSDOM
window.requestAnimationFrame = setTimeout;
window.cancelAnimationFrame = clearTimeout;
// Invoke the SSR bundle within the JSDOM document and grab the exported/returned result
result = window.eval(output + '\nPRERENDER_RESULT') || result;
if (window.PRERENDER_RESULT != null) {
result = window.PRERENDER_RESULT;
}
}
// Deal with ES Module exports (just use the best guess):
if (result && result.__esModule === true) {
result = getBestModuleExport(result);
}
if (typeof result === 'function') {
// @todo any arguments worth passing here?
result = result();
}
// The entry can export or return a Promise in order to perform fully async prerendering:
if (result && result.then) {
result = await result;
}
// Returning or resolving to `null` / `undefined` defaults to serializing the whole document.
// Note: this pypasses `inject` because the document is already derived from the template.
if (result == null && dom) {
result = dom.serialize();
} else if (inject) {
// @todo determine if this is really necessary for the string return case
if (injectParent) {
injectParent.insertAdjacentHTML('beforeend', result || '');
} else {
// Otherwise inject the prerendered HTML into the template
result = options.templateContent.replace(PRERENDER_REG, result || '');
}
}
return result;
}
// Promisified version of compiler.runAsChild() with error hoisting and isolated output/assets
function runChildCompiler (compiler) {
return new Promise((resolve, reject) => {
// runAsChild() merges assets into the parent compilation, we don't want that.
compiler.compile((err, compilation) => {
compiler.parentCompilation.children.push(compilation);
if (err) return reject(err);
if (compilation.errors && compilation.errors.length) {
const errorDetails = compilation.errors.map(error => error.details).join('\n');
return reject(Error('Child compilation failed:\n' + errorDetails));
}
resolve(compilation);
});
});
}
// Crawl up the compiler tree and return the outermost compiler instance
function rootCompiler (compiler) {
while (compiler.parentCompilation && compiler.parentCompilation.compiler) {
compiler = compiler.parentCompilation.compiler;
}
return compiler;
}
// Find the best possible export for an ES Module. Returns `undefined` for no exports.
function getBestModuleExport (exports) {
if (exports.default) {
return exports.default;
}
for (const prop in exports) {
if (prop !== '__esModule') {
return exports[prop];
}
}
}
// Wrap a String up into an ES Module that exports it
const stringToModule = str => 'export default ' + JSON.stringify(str);

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;
};

1
global.d.ts vendored
View File

@@ -1,5 +1,4 @@
declare const __webpack_public_path__: string; declare const __webpack_public_path__: string;
declare const PRERENDER: boolean;
declare interface NodeModule { declare interface NodeModule {
hot: any; hot: any;

1418
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -41,12 +41,9 @@
"babel-plugin-transform-react-remove-prop-types": "^0.4.13", "babel-plugin-transform-react-remove-prop-types": "^0.4.13",
"babel-preset-env": "^1.6.1", "babel-preset-env": "^1.6.1",
"babel-register": "^6.26.0", "babel-register": "^6.26.0",
"chalk": "^2.3.2",
"clean-webpack-plugin": "^0.1.19", "clean-webpack-plugin": "^0.1.19",
"copy-webpack-plugin": "^4.5.1", "copy-webpack-plugin": "^4.5.1",
"css": "^2.2.1",
"css-loader": "^0.28.11", "css-loader": "^0.28.11",
"ejs-loader": "^0.3.1",
"eslint": "^4.18.2", "eslint": "^4.18.2",
"eslint-config-standard": "^11.0.0", "eslint-config-standard": "^11.0.0",
"eslint-config-standard-jsx": "^5.0.0", "eslint-config-standard-jsx": "^5.0.0",
@@ -55,22 +52,12 @@
"eslint-plugin-promise": "^3.7.0", "eslint-plugin-promise": "^3.7.0",
"eslint-plugin-react": "^7.7.0", "eslint-plugin-react": "^7.7.0",
"eslint-plugin-standard": "^3.0.1", "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",
"html-webpack-plugin": "^3.0.6", "html-webpack-plugin": "^3.0.6",
"if-env": "^1.0.4", "if-env": "^1.0.4",
"jsdom": "^11.5.1",
"loader-utils": "^1.1.0", "loader-utils": "^1.1.0",
"memory-fs": "^0.4.1",
"mini-css-extract-plugin": "^0.3.0", "mini-css-extract-plugin": "^0.3.0",
"node-sass": "^4.7.2", "node-sass": "^4.7.2",
"nwmatcher": "^1.4.4",
"optimize-css-assets-webpack-plugin": "^4.0.0", "optimize-css-assets-webpack-plugin": "^4.0.0",
"parse5": "^4.0.0",
"preact-render-to-string": "^3.7.0",
"preload-webpack-plugin": "github:GoogleChromeLabs/preload-webpack-plugin",
"pretty-bytes": "^4.0.2",
"progress-bar-webpack-plugin": "^1.11.0", "progress-bar-webpack-plugin": "^1.11.0",
"raw-loader": "^0.5.1", "raw-loader": "^0.5.1",
"sass-loader": "^6.0.7", "sass-loader": "^6.0.7",
@@ -82,14 +69,12 @@
"tslint-config-semistandard": "^7.0.0", "tslint-config-semistandard": "^7.0.0",
"tslint-react": "^3.5.1", "tslint-react": "^3.5.1",
"typescript": "^2.7.2", "typescript": "^2.7.2",
"typescript-loader": "^1.1.3",
"typings-for-css-modules-loader": "^1.7.0", "typings-for-css-modules-loader": "^1.7.0",
"webpack": "^4.3.0", "webpack": "^4.3.0",
"webpack-bundle-analyzer": "^2.11.1", "webpack-bundle-analyzer": "^2.11.1",
"webpack-cli": "^2.0.13", "webpack-cli": "^2.0.13",
"webpack-dev-server": "^3.1.1", "webpack-dev-server": "^3.1.1",
"webpack-plugin-replace": "^1.1.1", "webpack-plugin-replace": "^1.1.1"
"workbox-webpack-plugin": "^3.0.1"
}, },
"dependencies": { "dependencies": {
"classnames": "^2.2.5", "classnames": "^2.2.5",

View File

@@ -1,41 +1,16 @@
import { h, Component } from 'preact'; import { h, Component } from 'preact';
import { When, bind } from '../../lib/util'; import { 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 * as style from './style.scss'; import * as style from './style.scss';
import Output from '../output';
type Props = { type Props = {};
url?: string
};
export type FileObj = {
id: number,
data?: string,
uri?: string,
error?: Error | DOMError | String,
file: File,
loading: boolean
};
type State = { type State = {
showDrawer: boolean, img?: ImageBitmap
showFab: boolean,
files: FileObj[]
}; };
let idCounter = 0;
export default class App extends Component<Props, State> { export default class App extends Component<Props, State> {
state: State = { state: State = {};
showDrawer: false,
showFab: true,
files: []
};
enableDrawer = false;
constructor() { constructor() {
super(); super();
@@ -49,87 +24,25 @@ export default class App extends Component<Props, State> {
} }
@bind @bind
openDrawer() { async onFileChange(event: Event) {
this.setState({ showDrawer: true }); const fileInput = event.target as HTMLInputElement;
} if (!fileInput.files || !fileInput.files[0]) return;
@bind // TODO: handle decode error
closeDrawer() { const img = await createImageBitmap(fileInput.files[0]);
this.setState({ showDrawer: false }); this.setState({ img });
}
@bind
toggleDrawer() {
this.setState({ showDrawer: !this.state.showDrawer });
} }
@bind render({ }: Props, { img }: State) {
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;
return ( return (
<div id="app" class={style.app}> <div id="app" class={style.app}>
<Fab showing={showFab} /> {img ?
<Output img={img} />
<Header class={style.header} onToggleDrawer={this.toggleDrawer} loadFile={this.loadFile} /> :
<div>
{/* Avoid loading & rendering the drawer until the first time it is shown. */} <h1>Select an image</h1>
<When value={showDrawer}> <input type="file" onChange={this.onFileChange} />
<Drawer showing={showDrawer} openDrawer={this.openDrawer} closeDrawer={this.closeDrawer} /> </div>
</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>
</div> </div>
); );
} }

View File

@@ -1,25 +1,3 @@
@import '~style/helpers.scss'; .app h1 {
color: green;
.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;
}
} }

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} id="uploadIcon">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} aria-labelledby="uploadIcon" />
</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,41 +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}>
{ files && files[0] && (
<img src={files[0].uri} class={style.image} />
) || (
<div class={style.content}>
<h1>Squoosh</h1>
<p>Test home content</p>
</div>
) }
</div>
);
}
}

View File

@@ -1,21 +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;
}
.image {
width: 100%;
}
.content {
max-width: 600px;
margin: 50px auto 0;
font-size: 120%;
text-align: center;
}

View File

@@ -1,3 +0,0 @@
export const home: string;
export const image: string;
export const content: 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": any
}
}

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,45 @@
import { h, Component } from 'preact';
import './custom-els/PinchZoom';
import * as style from './style.scss';
type Props = {
img: ImageBitmap
};
type State = {};
export default class App extends Component<Props, State> {
state: State = {};
canvas?: HTMLCanvasElement;
constructor() {
super();
}
updateCanvas(img: ImageBitmap) {
if (!this.canvas) return;
const ctx = this.canvas.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
ctx.drawImage(img, 0, 0);
}
componentDidMount() {
this.updateCanvas(this.props.img);
}
componentDidUpdate({ img }: Props) {
if (img !== this.props.img) this.updateCanvas(this.props.img);
}
render({ img }: Props, { }: State) {
return (
<div>
<pinch-zoom>
<canvas class={style.outputCanvas} ref={c => this.canvas = c as HTMLCanvasElement} width={img.width} height={img.height} />
</pinch-zoom>
<p>And that's all the app does so far!</p>
</div>
);
}
}

View File

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

View File

@@ -20,15 +20,4 @@ if (process.env.NODE_ENV === 'development') {
root = render(<App />, document.body, root); root = render(<App />, document.body, root);
}); });
}); });
} else if ('serviceWorker' in navigator) {
addEventListener('load', () => {
setTimeout(() => {
navigator.serviceWorker.register(__webpack_public_path__ + 'sw.js');
}, 1000);
});
} }
/** @todo Async SSR if we need it */
// export default async () => {
// // render here, then resolve to a string of HTML (or null to serialize the document)
// }

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

@@ -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. * A decorator that binds values to their class instance.
* @example * @example

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-components-web/material-components-web';
@import './material-icons.scss';
@import './reset.scss'; @import './reset.scss';
// @import url('https://fonts.googleapis.com/icon?family=Material+Icons');
html, body { html, body {
height: 100%; height: 100%;
@@ -11,17 +8,3 @@ html, body {
overflow: hidden; overflow: hidden;
overscroll-behavior: none; 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,24 +0,0 @@
@font-face {
font-family: 'Material Icons';
font-style: normal;
font-weight: 400;
font-display: swap;
// @todo woff fallback!
src: url(https://fonts.gstatic.com/s/materialicons/v36/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.woff2) format('woff2');
}
.material-icons {
font-family: 'Material Icons';
font-weight: normal;
font-style: normal;
font-size: 24px;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased;
}

View File

@@ -2,16 +2,14 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const webpack = require('webpack'); const webpack = require('webpack');
const CleanPlugin = require('clean-webpack-plugin'); const CleanPlugin = require('clean-webpack-plugin');
const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
const ProgressBarPlugin = require('progress-bar-webpack-plugin'); const ProgressBarPlugin = require('progress-bar-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin'); const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const HtmlPlugin = require('html-webpack-plugin'); const HtmlPlugin = require('html-webpack-plugin');
const ScriptExtHtmlPlugin = require('script-ext-html-webpack-plugin'); const ScriptExtHtmlPlugin = require('script-ext-html-webpack-plugin');
const PreloadPlugin = require('preload-webpack-plugin');
const ReplacePlugin = require('webpack-plugin-replace'); const ReplacePlugin = require('webpack-plugin-replace');
const CopyPlugin = require('copy-webpack-plugin'); 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 WatchTimestampsPlugin = require('./config/watch-timestamps-plugin');
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
@@ -23,8 +21,7 @@ module.exports = function (_, env) {
const isProd = env.mode === 'production'; const isProd = env.mode === 'production';
const nodeModules = path.join(__dirname, 'node_modules'); const nodeModules = path.join(__dirname, 'node_modules');
const componentStyleDirs = [ const componentStyleDirs = [
path.join(__dirname, 'src/components'), path.join(__dirname, 'src/components')
path.join(__dirname, 'src/routes')
]; ];
return { return {
@@ -52,23 +49,6 @@ module.exports = function (_, env) {
}, },
module: { module: {
rules: [ 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)$/, test: /\.(scss|sass)$/,
loader: 'sass-loader', loader: 'sass-loader',
@@ -81,7 +61,7 @@ module.exports = function (_, env) {
}, },
{ {
test: /\.(scss|sass|css)$/, test: /\.(scss|sass|css)$/,
// Only enable CSS Modules within `src/{components,routes}/*` // Only enable CSS Modules within `src/components/*`
include: componentStyleDirs, include: componentStyleDirs,
use: [ use: [
// In production, CSS is extracted to files on disk. In development, it's inlined into JS: // In production, CSS is extracted to files on disk. In development, it's inlined into JS:
@@ -104,7 +84,7 @@ module.exports = function (_, env) {
}, },
{ {
test: /\.(scss|sass|css)$/, test: /\.(scss|sass|css)$/,
// Process non-modular CSS everywhere *except* `src/{components,routes}/*` // Process non-modular CSS everywhere *except* `src/components/*`
exclude: componentStyleDirs, exclude: componentStyleDirs,
use: [ use: [
isProd ? MiniCssExtractPlugin.loader : 'style-loader', isProd ? MiniCssExtractPlugin.loader : 'style-loader',
@@ -116,6 +96,17 @@ 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 })
} }
] ]
}, },
@@ -171,7 +162,7 @@ module.exports = function (_, env) {
// For now we're not doing SSR. // For now we're not doing SSR.
new HtmlPlugin({ new HtmlPlugin({
filename: path.join(__dirname, 'build/index.html'), filename: path.join(__dirname, 'build/index.html'),
template: '!' + path.join(__dirname, 'config/prerender-loader') + '?string' + (isProd ? '' : '&disabled') + '!src/index.html', template: 'src/index.html',
minify: isProd && { minify: isProd && {
collapseWhitespace: true, collapseWhitespace: true,
removeScriptTypeAttributes: true, removeScriptTypeAttributes: true,
@@ -188,24 +179,6 @@ module.exports = function (_, env) {
defaultAttribute: 'async' defaultAttribute: 'async'
}), }),
// Inject <link rel="preload"> for resources
isProd && new PreloadPlugin({
include: 'initial'
}),
isProd && new CrittersPlugin({
// Don't inline fonts into critical CSS, but do preload them:
preloadFonts: true,
// convert critical'd <link rel="stylesheet"> to <link rel="preload" as="style">:
async: true,
// Use media hack to load async (<link media="only x" onload="this.media='all'">):
media: true
// // use a $loadcss async CSS loading shim (DOM insertion to head)
// preload: 'js'
// // copy original <link rel="stylesheet"> to the end of <body>:
// preload: true
}),
// Inline constants during build, so they can be folded by UglifyJS. // Inline constants during build, so they can be folded by UglifyJS.
new webpack.DefinePlugin({ new webpack.DefinePlugin({
// We set node.process=false later in this config. // We set node.process=false later in this config.
@@ -235,26 +208,31 @@ module.exports = function (_, env) {
analyzerMode: 'static', analyzerMode: 'static',
defaultSizes: 'gzip', defaultSizes: 'gzip',
openAnalyzer: false openAnalyzer: false
}),
// Generate a ServiceWorker using Workbox.
isProd && new WorkboxPlugin.GenerateSW({
swDest: 'sw.js',
clientsClaim: true,
skipWaiting: true,
importWorkboxFrom: 'local',
exclude: [
'report.html',
'manifest.json',
/(report\.html|manifest\.json|\.precache-manifest\..*\.json)$/,
/\.(?:map|pem|DS_Store)$/
],
// allow for offline client-side routing:
navigateFallback: '/',
navigateFallbackBlacklist: [/\.[a-z0-9]+$/i]
}) })
].filter(Boolean), // Filter out any falsey plugin array entries. ].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. // Turn off various NodeJS environment polyfills Webpack adds to bundles.
// They're supposed to be added only when used, but the heuristic is loose // They're supposed to be added only when used, but the heuristic is loose
// (eg: existence of a variable called setImmedaite in any scope) // (eg: existence of a variable called setImmedaite in any scope)
@@ -279,8 +257,6 @@ module.exports = function (_, env) {
compress: true, compress: true,
// Request paths not ending in a file extension serve index.html: // Request paths not ending in a file extension serve index.html:
historyApiFallback: true, historyApiFallback: true,
// Don't output server address info to console on startup:
noInfo: true,
// Suppress forwarding of Webpack logs to the browser console: // Suppress forwarding of Webpack logs to the browser console:
clientLogLevel: 'none', clientLogLevel: 'none',
// Supress the extensive stats normally printed after a dev build (since sizes are mostly useless): // Supress the extensive stats normally printed after a dev build (since sizes are mostly useless):