Compare commits

..

7 Commits

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

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

* Don't prerender analytics

* Version bump
2018-11-10 08:20:13 -08:00
Jake Archibald
6870b135b7 I'm calling this 1.0 2018-11-09 16:01:24 -08:00
12 changed files with 475 additions and 35 deletions

View File

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

8
package-lock.json generated
View File

@@ -1,6 +1,6 @@
{
"name": "squoosh",
"version": "0.1.0",
"version": "1.0.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@@ -9481,12 +9481,6 @@
"integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=",
"dev": true
},
"pinch-zoom-element": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/pinch-zoom-element/-/pinch-zoom-element-1.0.0.tgz",
"integrity": "sha512-n5yMOhV/zlOsHcTERBZIbGkhfwATAl9l5JLc5nPAhwAgY8f+PGDikh4TWgvPfGooFyQctLtD+3SNlIE3fEM/eQ==",
"dev": true
},
"pinkie": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz",

View File

@@ -1,7 +1,7 @@
{
"private": true,
"name": "squoosh",
"version": "0.1.0",
"version": "1.0.2",
"license": "apache-2.0",
"scripts": {
"start": "webpack serve --host 0.0.0.0 --hot",
@@ -47,12 +47,11 @@
"if-env": "^1.0.4",
"linkstate": "^1.1.1",
"loader-utils": "^1.1.0",
"mini-css-extract-plugin": "^0.4.4",
"pointer-tracker": "^2.0.3",
"minimatch": "^3.0.4",
"mini-css-extract-plugin": "^0.4.4",
"node-sass": "^4.9.4",
"optimize-css-assets-webpack-plugin": "^4.0.3",
"pinch-zoom-element": "^1.0.0",
"pointer-tracker": "^2.0.3",
"preact": "^8.3.1",
"prerender-loader": "^1.2.0",
"pretty-bytes": "^5.1.0",

View File

@@ -0,0 +1,374 @@
import PointerTracker, { Pointer } from 'pointer-tracker';
import './styles.css';
interface Point {
clientX: number;
clientY: number;
}
interface ChangeOptions {
/**
* Fire a 'change' event if values are different to current values
*/
allowChangeEvent?: boolean;
}
interface ApplyChangeOpts extends ChangeOptions {
panX?: number;
panY?: number;
scaleDiff?: number;
originX?: number;
originY?: number;
}
interface SetTransformOpts extends ChangeOptions {
scale?: number;
x?: number;
y?: number;
}
type ScaleRelativeToValues = 'container' | 'content';
export interface ScaleToOpts extends ChangeOptions {
/** Transform origin. Can be a number, or string percent, eg "50%" */
originX?: number | string;
/** Transform origin. Can be a number, or string percent, eg "50%" */
originY?: number | string;
/** Should the transform origin be relative to the container, or content? */
relativeTo?: ScaleRelativeToValues;
}
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,
};
}
function getAbsoluteValue(value: string | number, max: number): number {
if (typeof value === 'number') return value;
if (value.trimRight().endsWith('%')) {
return max * parseFloat(value) / 100;
}
return parseFloat(value);
}
// 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();
}
const MIN_SCALE = 0.01;
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;
}
/**
* Change the scale, adjusting x/y by a given transform origin.
*/
scaleTo(scale: number, opts: ScaleToOpts = {}) {
let {
originX = 0,
originY = 0,
} = opts;
const {
relativeTo = 'content',
allowChangeEvent = false,
} = opts;
const relativeToEl = (relativeTo === 'content' ? this._positioningEl : this);
// No content element? Fall back to just setting scale
if (!relativeToEl || !this._positioningEl) {
this.setTransform({ scale, allowChangeEvent });
return;
}
const rect = relativeToEl.getBoundingClientRect();
originX = getAbsoluteValue(originX, rect.width);
originY = getAbsoluteValue(originY, rect.height);
if (relativeTo === 'content') {
originX += this.x;
originY += this.y;
} else {
const currentRect = this._positioningEl.getBoundingClientRect();
originX -= currentRect.left;
originY -= currentRect.top;
}
this._applyChange({
allowChangeEvent,
originX,
originY,
scaleDiff: scale / this.scale,
});
}
/**
* 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.
const 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.
*/
private _updateTransform(scale: number, x: number, y: number, allowChangeEvent: boolean) {
// Avoid scaling to zero
if (scale < MIN_SCALE) return;
// 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) 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({ allowChangeEvent: true });
}
private _onWheel(event: WheelEvent) {
if (!this._positioningEl) return;
event.preventDefault();
const currentRect = this._positioningEl.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 - currentRect.left,
originY: event.clientY - currentRect.top,
allowChangeEvent: true,
});
}
private _onPointerMove(previousPointers: Pointer[], currentPointers: Pointer[]) {
if (!this._positioningEl) return;
// Combine next points with previous points
const currentRect = this._positioningEl.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 - currentRect.left;
const originY = prevMidpoint.clientY - currentRect.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,
allowChangeEvent: true,
});
}
/** Transform the view & fire a change event */
private _applyChange(opts: ApplyChangeOpts = {}) {
const {
panX = 0, panY = 0,
originX = 0, originY = 0,
scaleDiff = 1,
allowChangeEvent = false,
} = opts;
const matrix = createMatrix()
// Translate according to panning.
.translate(panX, panY)
// Scale about the origin.
.translate(originX, originY)
// Apply current translate
.translate(this.x, this.y)
.scale(scaleDiff)
.translate(-originX, -originY)
// Apply current scale.
.scale(this.scale);
// Convert the transform into basic translate & scale.
this.setTransform({
allowChangeEvent,
scale: matrix.a,
x: matrix.e,
y: matrix.f,
});
}
}
customElements.define('pinch-zoom', PinchZoom);

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { h, Component } from 'preact';
import PinchZoom, { ScaleToOpts } from 'pinch-zoom-element/lib';
import 'pinch-zoom-element/lib';
import PinchZoom, { ScaleToOpts } from './custom-els/PinchZoom';
import './custom-els/PinchZoom';
import './custom-els/TwoUp';
import * as style from './style.scss';
import { bind, linkRef } from '../../lib/initial-util';
@@ -48,6 +48,15 @@ export default class Output extends Component<Props, State> {
const leftDraw = this.leftDrawable();
const rightDraw = this.rightDrawable();
// Reset the pinch zoom, which may have an position set from the previous view, after pressing
// the back button.
this.pinchZoomLeft!.setTransform({
allowChangeEvent: true,
x: 0,
y: 0,
scale: 1,
});
if (this.canvasLeft && leftDraw) {
drawDataToCanvas(this.canvasLeft, leftDraw);
}

View File

@@ -1,5 +0,0 @@
declare namespace JSX {
interface IntrinsicElements {
'pinch-zoom': HTMLAttributes;
}
}

View File

@@ -129,6 +129,11 @@ export default class Intro extends Component<Props, State> {
<ul class={style.relatedLinks}>
<li><a href="https://github.com/GoogleChromeLabs/squoosh/">View the code</a></li>
<li><a href="https://github.com/GoogleChromeLabs/squoosh/issues">Report a bug</a></li>
<li>
<a href="https://github.com/GoogleChromeLabs/squoosh/blob/master/README.md#privacy">
Privacy
</a>
</li>
</ul>
</div>
);

View File

@@ -60,11 +60,16 @@ export default class Results extends Component<Props, State> {
@bind
onDownload() {
// GA cant do floats. So we round to ints. We're deliberately rounding to nearest kilobyte to
// avoid cases where exact image sizes leak something interesting about the user.
const before = Math.round(this.props.source!.file.size / 1024);
const after = Math.round(this.props.imageFile!.size / 1024);
const change = Math.round(after / before * 1000);
ga('send', 'event', 'compression', 'download', {
// GA cant do floats. So we round to ints.
metric1: Math.floor(this.props.source!.file.size),
metric2: Math.floor(this.props.imageFile!.size),
metric3: Math.floor(this.props.imageFile!.size / this.props.source!.file.size * 1000),
metric1: before,
metric2: after,
metric3: change,
});
}

View File

@@ -13,11 +13,13 @@ if (!('customElements' in self)) {
init();
}
window.ga = window.ga || ((...args) => (ga.q = ga.q || []).push(args));
ga('create', 'UA-128752250-1', 'auto');
ga('set', 'transport', 'beacon');
ga('send', 'pageview');
// Load the GA script
const s = document.createElement('script');
s.src = 'https://www.google-analytics.com/analytics.js';
document.head!.appendChild(s);
if (typeof PRERENDER === 'undefined') {
window.ga = window.ga || ((...args) => (ga.q = ga.q || []).push(args));
ga('create', 'UA-128752250-1', 'auto');
ga('set', 'transport', 'beacon');
ga('send', 'pageview');
// Load the GA script
const s = document.createElement('script');
s.src = 'https://www.google-analytics.com/analytics.js';
document.head!.appendChild(s);
}

View File

@@ -141,8 +141,8 @@ module.exports = function (_, env) {
},
{
test: /\.tsx?$/,
loader: 'ts-loader',
options: { allowTsInNodeModules: true }
exclude: nodeModules,
loader: 'ts-loader'
},
{
// All the codec files define a global with the same name as their file name. `exports-loader` attaches those to `module.exports`.
@@ -226,7 +226,7 @@ module.exports = function (_, env) {
// For now we're not doing SSR.
new HtmlPlugin({
filename: path.join(__dirname, 'build/index.html'),
template: '!!prerender-loader?string!src/index.html',
template: isProd ? '!!prerender-loader?string!src/index.html' : 'src/index.html',
minify: isProd && {
collapseWhitespace: true,
removeScriptTypeAttributes: true,