Options UI (#135)

* Move gzipped size calculations into a worker and wrap it up in a `<GzipSize />` component that will also handle showing % of original size once that info is plumbed

* A couple tweaks for the app welcome (drop files) screen. We don't have mocks for this one, but this is at least a minor improvement.

* Prettier "pop" effect and styling for the drop zone/indicator.

* Styling for the quantization toggle to make it look like a disclosure triangle/button.

* Add controls bar (zoom in/out/to, background toggle). @todo: extract into its own component.

* When clicking/tapping the image area, give it focus.

* Utilities used by this PR

* Add a `two-up-handle` attribute to the handle for easier styling (classname gets mangled so it doesn't make for a good public API)

* Add a dummy comment to test netlify deploy

* Remove commented-out code.

* Fix styling of vertical split (which as it turns out is slightly different in the mocks anyway)

* Use a composited overlay for the dark background instead of animating background-color

* Move grayscale styling into `<two-up>` by default, then set colors via custom properties

* Remove commented-out svg fill

* Remove dummy comment

* Change `<GzipSize>` to be `<FileSize>`, add `compress` option that lets us show gzipped sizes later if we need. Defaults to `false`, and the gzip worker is only lazily instantiated the first time a compressed size calculation is requested.

* Dependency updates

* Remove color animations from dnd overlay

* Don't use a cyclical import for EncodedImage, instead just specify the types of the properties we Options actually uses.

* Pass source image through to FileSize component so it can compute delta

* Stylize size display with colors based on delta amount/direction

* Remove box-shadow animation.

* Simplify font stack

* Remove commented out code

* Remove gzip compression from size component

* Remove memoization bits

* Use specific flattend props instead of passing large context objects around.

* Remove unused packages.

* Remove unreachable String case in FileSize, and omit redundant File type

* Simplify calculateSize()

* Fix types for FileSize!

* Remove FileSize title

* Make delta variable consistent.

* Skip passing compareTo value for original image

* Remove manual focus

* Fix whitespace

* remove unused keyframes

* remove pointless flex-wrap property

* Remove unused resetZoom() method

* Remove pointless flex properties

* Use `on` prefix for event handling

* Remove pointless justify-self property

* Use an inline SVG for TwoUp's handle icon so it can be colored from outside the component..

* Move orientation state up from `<Output>` into `<App>` and share it with `<Options>`.

* Make the options panels responsive :)

* Show a plus sign for size increases `(+8%)`

* Use inline SVG for the zoom +/- icons, collect SVG icons into one file now that I've verified they get tree-shaken properly.

* Fix top/bottom options panels being reversed

* remove commented out code

* lockfile

* Revert quanitzation toggle styles so it's just a checkbox.

* Remove minimum delta for compare size

* Rename data prop to file.

* scale int -> float

* remove tabIndex

* Remove old icon files

* Add width to options panels

* Add vertical scrolling when options are taller than 80% of the screen height.
This commit is contained in:
Jason Miller
2018-09-05 03:21:54 -04:00
committed by Jake Archibald
parent 54ad30a7ed
commit 32f6f8b941
15 changed files with 766 additions and 1757 deletions

4
global.d.ts vendored
View File

@@ -13,10 +13,6 @@ declare namespace JSX {
interface IntrinsicElements { }
}
declare module 'preact-i18n';
declare module 'preact-material-components-drawer';
declare module 'material-radial-progress';
declare module 'classnames' {
export default function classnames(...args: any[]): string;
}

1640
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,7 @@
},
"devDependencies": {
"@types/node": "^9.6.23",
"@types/pretty-bytes": "^5.1.0",
"@types/webassembly-js-api": "0.0.1",
"babel-loader": "^7.1.5",
"babel-plugin-jsx-pragmatic": "^1.0.2",
@@ -62,15 +63,10 @@
"webpack-plugin-replace": "^1.1.1"
},
"dependencies": {
"@types/filesize": "^3.6.0",
"classnames": "^2.2.6",
"comlink": "^3.0.3",
"comlink-loader": "^1.0.0",
"filesize": "^3.6.1",
"material-components-web": "^0.32.0",
"preact": "^8.3.1",
"preact-i18n": "^1.2.2",
"preact-material-components": "^1.4.7",
"preact-router": "^2.6.1"
"pretty-bytes": "^5.1.0"
}
}

View File

@@ -1,5 +1,4 @@
import { h, Component } from 'preact';
import { partial } from 'filesize';
import { bind, linkRef, bitmapToImageData } from '../../lib/util';
import * as style from './style.scss';
@@ -38,6 +37,8 @@ import {
import { decodeImage } from '../../codecs/decoders';
import { cleanMerge, cleanSet } from '../../lib/clean-modify';
type Orientation = 'horizontal' | 'vertical';
interface SourceImage {
file: File;
bmp: ImageBitmap;
@@ -65,14 +66,13 @@ interface State {
images: [EncodedImage, EncodedImage];
loading: boolean;
error?: string;
orientation: Orientation;
}
interface UpdateImageOptions {
skipPreprocessing?: boolean;
}
const filesize = partial({});
async function preprocessImage(
source: SourceImage,
preprocessData: PreprocessorState,
@@ -115,6 +115,8 @@ async function compressImage(
}
export default class App extends Component<Props, State> {
widthQuery = window.matchMedia('(min-width: 500px)');
state: State = {
loading: false,
images: [
@@ -133,6 +135,7 @@ export default class App extends Component<Props, State> {
loading: false,
},
],
orientation: this.widthQuery.matches ? 'horizontal' : 'vertical',
};
private snackbar?: SnackBarElement;
@@ -148,6 +151,13 @@ export default class App extends Component<Props, State> {
window.STATE = this.state;
};
}
this.widthQuery.addListener(this.onMobileWidthChange);
}
@bind
onMobileWidthChange() {
this.setState({ orientation: this.widthQuery.matches ? 'horizontal' : 'vertical' });
}
onEncoderTypeChange(index: 0 | 1, newType: EncoderType): void {
@@ -289,33 +299,32 @@ export default class App extends Component<Props, State> {
this.snackbar.showSnackbar({ message: error });
}
render({ }: Props, { loading, images }: State) {
render({ }: Props, { loading, images, source, orientation }: State) {
const [leftImageBmp, rightImageBmp] = images.map(i => i.bmp);
const anyLoading = loading || images.some(image => image.loading);
return (
<file-drop accept="image/*" onfiledrop={this.onFileDrop}>
<div id="app" class={style.app}>
<div id="app" class={`${style.app} ${style[orientation]}`}>
{(leftImageBmp && rightImageBmp) ? (
<Output leftImg={leftImageBmp} rightImg={rightImageBmp} />
<Output
orientation={orientation}
leftImg={leftImageBmp}
rightImg={rightImageBmp}
/>
) : (
<div class={style.welcome}>
<h1>Select an image</h1>
<h1>Drop, paste or 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}
{(image.downloadUrl && image.file) && (
<a href={image.downloadUrl} download={image.file.name}>🔻</a>
)}
{image.file && ` - ${filesize(image.file.size)}`}
</span>
))}
{images.map((image, index) => (
{(leftImageBmp && rightImageBmp) && images.map((image, index) => (
<Options
class={index ? style.rightOptions : style.leftOptions}
orientation={orientation}
imageIndex={index}
imageFile={image.file}
sourceImageFile={source && source.file}
downloadUrl={image.downloadUrl}
preprocessorState={image.preprocessorState}
encoderState={image.encoderState}
onEncoderTypeChange={this.onEncoderTypeChange.bind(this, index)}

View File

@@ -10,36 +10,22 @@ Note: These styles are temporary. They will be replaced before going live.
height: 100%;
overflow: hidden;
contain: strict;
display: flex;
justify-content: flex-end;
.leftLabel,
.rightLabel {
position: fixed;
bottom: 0;
padding: 5px 10px;
background: rgba(0,0,0,0.5);
color: #fff;
&.horizontal {
justify-content: space-between;
align-items: flex-end;
}
.leftLabel { left: 0; }
.rightLabel { right: 0; }
.leftOptions,
.rightOptions {
position: fixed;
bottom: 40px;
&.vertical {
flex-direction: column;
}
.leftOptions { left: 10px; }
.rightOptions { right: 10px; }
}
.welcome {
position: absolute;
display: inline-block;
left: 50%;
top: 50%;
padding: 20px;
transform: translate(-50%, -50%);
margin: auto;
text-align: center;
h1 {
font-weight: inherit;
@@ -50,12 +36,11 @@ Note: These styles are temporary. They will be replaced before going live.
input {
display: inline-block;
width: 16em;
padding: 5px;
padding: 10px;
margin: 0 auto;
-webkit-appearance: none;
border: 1px solid #b68c86;
background: #f0d3cf;
box-shadow: inset 0 0 1px #fff;
border: 1px solid var(--button-fg);
background: rgba(var(--button-fg-color), 0.1);
border-radius: 3px;
cursor: pointer;
}
@@ -68,16 +53,37 @@ Note: These styles are temporary. They will be replaced before going live.
height:100%;
width:100%;
&.drop-valid {
transition: opacity 200ms ease-in-out, background-color 200ms;
opacity: 0.5;
background-color:green;
&:after {
content: '';
position: absolute;
display: block;
left: 10px;
top: 10px;
right: 10px;
bottom: 10px;
border: 2px dashed #fff;
border-radius: 10px;
opacity: 0;
transform: scale(0.95);
transition: opacity 300ms ease, transform 300ms cubic-bezier(.6,2,.6,1), background-color 300ms step-end, border-color 300ms step-end;
pointer-events: none;
}
&.drop-invalid {
transition: opacity 200ms ease-in-out, background-color 200ms;
opacity: 0.5;
background-color:red;
&.drop-valid:after,
&.drop-invalid:after {
opacity: 1;
transform: scale(1);
transition: opacity 300ms ease, transform 300ms cubic-bezier(.6,2,.6,1);
}
&.drop-valid:after {
background-color:rgba(88, 116, 88, 0.2);
border-color: rgba(65, 129, 65, 0.5);
}
&.drop-invalid:after {
background-color:rgba(119, 85, 85, 0.2);
border-color:rgba(129, 63, 63, 0.5);
}
}
}

View File

@@ -0,0 +1,87 @@
import { h, Component } from 'preact';
import * as prettyBytes from 'pretty-bytes';
type FileContents = ArrayBuffer | Blob;
interface Props extends Pick<JSX.HTMLAttributes, Exclude<keyof JSX.HTMLAttributes, 'data'>> {
file?: FileContents;
compareTo?: FileContents;
increaseClass?: string;
decreaseClass?: string;
}
interface State {
size?: number;
sizeFormatted?: string;
compareSize?: number;
compareSizeFormatted?: string;
}
function calculateSize(data: FileContents): number {
return data instanceof ArrayBuffer ? data.byteLength : data.size;
}
export default class FileSize extends Component<Props, State> {
constructor(props: Props) {
super(props);
if (props.file) {
this.computeSize('size', props.file);
}
if (props.compareTo) {
this.computeSize('compareSize', props.compareTo);
}
}
componentWillReceiveProps({ file, compareTo }: Props) {
if (file !== this.props.file) {
this.computeSize('size', file);
}
if (compareTo !== this.props.compareTo) {
this.computeSize('compareSize', compareTo);
}
}
componentDidMount() {
this.applyStyles();
}
componentDidUpdate() {
this.applyStyles();
}
applyStyles() {
const { size, compareSize = 0 } = this.state;
if (size != null && this.base) {
const delta = size && compareSize ? (size - compareSize) / compareSize : 0;
this.base.style.setProperty('--size', '' + size);
this.base.style.setProperty('--size-delta', '' + Math.round(Math.abs(delta * 100)));
}
}
computeSize(prop: keyof State, data?: FileContents) {
const size = data ? calculateSize(data) : 0;
const pretty = prettyBytes(size);
this.setState({
[prop]: size,
[prop + 'Formatted']: pretty,
});
}
render(
{ file, compareTo, increaseClass, decreaseClass, ...props }: Props,
{ size, sizeFormatted = '', compareSize }: State,
) {
const delta = size && compareSize ? (size - compareSize) / compareSize : 0;
return (
<span {...props}>
{sizeFormatted}
{compareTo && (
<span class={delta > 0 ? increaseClass : decreaseClass}>
{delta > 0 && '+'}
{Math.round(delta * 100)}%
</span>
)}
</span>
);
}
}

View File

@@ -30,11 +30,15 @@ import {
encoders,
encodersSupported,
EncoderSupportMap,
encoderMap,
} from '../../codecs/encoders';
import { QuantizeOptions } from '../../codecs/imagequant/quantizer';
import { PreprocessorState } from '../../codecs/preprocessors';
import FileSize from '../FileSize';
import { DownloadIcon } from '../../lib/icons';
const encoderOptionsComponentMap = {
[identity.type]: undefined,
[optiPNG.type]: OptiPNGEncoderOptions,
@@ -51,8 +55,17 @@ const encoderOptionsComponentMap = {
[browserPDF.type]: undefined,
};
const titles = {
horizontal: ['Left Image', 'Right Image'],
vertical: ['Top Image', 'Bottom Image'],
};
interface Props {
class?: string;
orientation: 'horizontal' | 'vertical';
imageIndex: number;
sourceImageFile?: File;
imageFile?: File;
downloadUrl?: string;
encoderState: EncoderState;
preprocessorState: PreprocessorState;
onEncoderTypeChange(newType: EncoderType): void;
@@ -100,37 +113,42 @@ export default class Options extends Component<Props, State> {
}
render(
{ class: className, encoderState, preprocessorState, onEncoderOptionsChange }: Props,
{
sourceImageFile,
imageIndex,
imageFile,
downloadUrl,
orientation,
encoderState,
preprocessorState,
onEncoderOptionsChange,
}: Props,
{ encoderSupportMap }: State,
) {
// tslint:disable variable-name
const EncoderOptionComponent = encoderOptionsComponentMap[encoderState.type];
return (
<div class={`${style.options}${className ? (' ' + className) : ''}`}>
{encoderState.type !== 'identity' && (
<div>
<p>Quantization</p>
<label>
<input
name="quantizer.enable"
type="checkbox"
checked={!!preprocessorState.quantizer.enabled}
onChange={this.onPreprocessorEnabledChange}
/>
Enable
</label>
{preprocessorState.quantizer.enabled &&
<QuantizerOptionsComponent
options={preprocessorState.quantizer}
onChange={this.onQuantizerOptionsChange}
/>
}
<hr/>
</div>
<div class={`${style.options} ${style[orientation]}`}>
<h2 class={style.title}>
{titles[orientation][imageIndex]}
{', '}
{encoderMap[encoderState.type].label}
{(downloadUrl && imageFile) && (
<a
class={style.download}
href={downloadUrl}
download={imageFile.name}
title="Download"
>
<DownloadIcon />
</a>
)}
<label>
Mode:
</h2>
<div class={style.inner}>
<section class={style.picker}>
{encoderSupportMap ?
<select value={encoderState.type} onChange={this.onEncoderTypeChange}>
{encoders.filter(encoder => encoderSupportMap[encoder.type]).map(encoder => (
@@ -140,18 +158,50 @@ export default class Options extends Component<Props, State> {
:
<select><option>Loading</option></select>
}
</section>
{encoderState.type !== 'identity' && (
<div key="quantization" class={style.quantization}>
<label class={style.toggle}>
<input
name="quantizer.enable"
type="checkbox"
checked={!!preprocessorState.quantizer.enabled}
onChange={this.onPreprocessorEnabledChange}
/>
Enable Quantization
</label>
{preprocessorState.quantizer.enabled &&
<QuantizerOptionsComponent
options={preprocessorState.quantizer}
onChange={this.onQuantizerOptionsChange}
/>
}
</div>
)}
{EncoderOptionComponent &&
<EncoderOptionComponent
options={
// Casting options, as encoderOptionsComponentMap[encodeData.type] ensures the correct
// type, but typescript isn't smart enough.
// Casting options, as encoderOptionsComponentMap[encodeData.type] ensures
// the correct type, but typescript isn't smart enough.
encoderState.options as any
}
onChange={onEncoderOptionsChange}
/>
}
</div>
<div class={style.sizeDetails}>
<FileSize
class={style.size}
increaseClass={style.increase}
decreaseClass={style.decrease}
file={imageFile}
compareTo={imageFile === sourceImageFile ? undefined : sourceImageFile}
/>
</div>
</div>
);
}
}

View File

@@ -3,36 +3,165 @@ 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;
box-sizing: border-box;
padding: 0;
background: rgba(40,40,40,0.8);
box-shadow: 0 1px 3px rgba(0,0,0,0.5);
color: #eee;
overflow: auto;
z-index: 1;
transition: opacity 300ms ease;
opacity: 0.9;
transform-origin: 50% 140%;
transition: opacity 300ms linear;
animation: options-open 500ms cubic-bezier(.6,1.6,.6,1) forwards 1;
&:not(:hover) {
opacity: .6;
&.horizontal {
border-radius: 1px 1px 5px 5px;
width: 230px;
> .inner {
max-height: 80vh;
overflow: auto;
-webkit-overflow-scrolling: touch;
-ms-touch-action: pan-y;
touch-action: pan-y;
}
}
&.vertical {
opacity: 1;
margin: 0 5px 10px;
border-radius: 0 0 5px 5px;
}
&:hover, &:focus, &:focus-within {
opacity: 1;
}
@keyframes options-open {
from {
transform: translateY(100px) scale(.8);
}
}
.picker {
margin: 5px 15px;
select {
display: block;
width: 100%;
box-sizing: border-box;
-webkit-appearance: none;
appearance: none;
padding: 10px 30px 10px 10px;
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="25" height="5"><polygon fill="#fff" points="10,0 5,5 0,0"/></svg>') right center no-repeat;
background-color: var(--gray-dark);
opacity: 0.9;
border: none;
font: inherit;
color: white;
&:hover {
opacity: 1;
}
&:focus {
opacity: 1;
outline: none;
box-shadow: 0 0 0 2px var(--button-fg, #ccc);
}
}
}
.title {
display: flex;
align-items: center;
padding: 10px 15px;
margin: 0 0 12px;
background: rgba(0,0,0,0.9);
font: inherit;
.download {
flex: 0;
margin: 0 0 0 auto;
background: rgba(0,0,0,0.7);
border-radius: 50%;
padding: 5px;
width: 16px;
height: 16px;
text-decoration: none;
> svg {
width: 16px;
height: 16px;
fill: #fff;
}
&:hover {
background-color: rgba(255,255,255,0.3);
}
}
}
label {
display: block;
padding: 5px;
font-weight: bold;
select {
margin-left: 5px;
}
margin: 0 10px;
input {
vertical-align: middle;
}
input[type=checkbox],
input[type=radio] {
margin-right: 8px;
}
pre {
font-size: 10px;
range-input {
display: block;
width: 90%;
}
}
.size-details {
padding: 5px 15px;
background: rgba(0,0,0,0.5);
.size {
font-weight: normal;
.increase,
.decrease {
font-style: italic;
filter: #{"grayscale(calc(50% - var(--size-delta, 50) * 0.5%))"};
&:before {
content: ' (';
}
&:after {
content: ')';
}
}
.increase {
color: var(--negative);
}
.decrease {
color: var(--positive);
}
}
}
}
.quantization {
padding: 5px 0;
margin: 5px 0;
box-shadow: inset 0 -.5px 0 rgba(0,0,0,0.25), 0 .5px 0 rgba(255,255,255,0.15);
.toggle {
display: flex;
position: relative;
align-content: center;
font-size: 14px;
}
}

View File

@@ -56,6 +56,10 @@ export default class TwoUp extends HTMLElement {
}
connectedCallback() {
this._handle.innerHTML = `<div class="${styles.scrubber}">${
`<svg viewBox="0 0 20 10" fill="currentColor"><path d="M8 0v10L0 5zM12 0v10l8-5z"/></svg>`
}</div>`;
this._childrenChange();
if (!this._everConnected) {
this._resetPosition();

View File

@@ -2,6 +2,10 @@ two-up {
display: grid;
position: relative;
--split-point: 0;
--accent-color: #777;
--track-color: var(--accent-color);
--thumb-background: #fff;
--thumb-color: var(--accent-color);
}
two-up > * {
@@ -18,33 +22,47 @@ two-up[legacy-clip-compat] > :not(.twoUpHandle) {
touch-action: none;
position: relative;
width: 10px;
background: red;
background: var(--track-color);
transform: translateX(var(--split-point)) translateX(-50%);
box-shadow: inset 4px 0 0 rgba(0,0,0,0.1), 0 1px 4px rgba(0,0,0,0.4);
will-change: transform;
cursor: ew-resize;
}
.twoUpHandle::after {
content: '';
display: block;
.scrubber {
display: flex;
position: absolute;
top: 50%;
left: 50%;
transform-origin: 50% 50%;
transform: translate(-50%, -50%);
width: 80px;
height: 40px;
background: red;
border-radius: 20px;
width: 62px;
height: 56px;
background: var(--thumb-background);
border: 1px solid rgba(0,0,0,0.2);
border-radius: 5px;
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
color: var(--thumb-color);
}
.scrubber svg {
flex: 1;
margin: 0 10px;
}
two-up[orientation='vertical'] .twoUpHandle {
width: auto;
height: 10px;
height: 7px;
transform: translateY(var(--split-point)) translateY(-50%);
box-shadow: inset 0 3px 0 rgba(0,0,0,0.1), 0 1px 4px rgba(0,0,0,0.4);
cursor: ns-resize;
}
two-up[orientation='vertical'] .twoUpHandle::after {
width: 40px;
height: 80px;
two-up[orientation='vertical'] .scrubber {
width: 46px;
height: 40px;
font-size: 18px;
box-shadow: 1px 0 4px rgba(0,0,0,0.1);
transform: translate(-50%, -50%) rotate(-90deg);
}
two-up > :nth-child(1):not(.twoUpHandle) {

View File

@@ -3,34 +3,35 @@ import PinchZoom from './custom-els/PinchZoom';
import './custom-els/PinchZoom';
import './custom-els/TwoUp';
import * as style from './style.scss';
import { bind, drawBitmapToCanvas, linkRef } from '../../lib/util';
import { bind, shallowEqual, drawBitmapToCanvas, linkRef } from '../../lib/util';
import { ToggleIcon, AddIcon, RemoveIcon } from '../../lib/icons';
import { twoUpHandle } from './custom-els/TwoUp/styles.css';
interface Props {
orientation: 'horizontal' | 'vertical';
leftImg: ImageBitmap;
rightImg: ImageBitmap;
}
interface State {
verticalTwoUp: boolean;
scale: number;
editingScale: boolean;
altBackground: boolean;
}
export default class Output extends Component<Props, State> {
widthQuery = window.matchMedia('(min-width: 500px)');
state: State = {
verticalTwoUp: !this.widthQuery.matches,
scale: 1,
editingScale: false,
altBackground: false,
};
canvasLeft?: HTMLCanvasElement;
canvasRight?: HTMLCanvasElement;
pinchZoomLeft?: PinchZoom;
pinchZoomRight?: PinchZoom;
scaleInput?: HTMLInputElement;
retargetedEvents = new WeakSet<Event>();
constructor() {
super();
this.widthQuery.addListener(this.onMobileWidthChange);
}
componentDidMount() {
if (this.canvasLeft) {
drawBitmapToCanvas(this.canvasLeft, this.props.leftImg);
@@ -40,29 +41,76 @@ export default class Output extends Component<Props, State> {
}
}
componentDidUpdate(prevProps: Props) {
componentDidUpdate(prevProps: Props, prevState: State) {
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);
}
const { scale } = this.state;
if (scale !== prevState.scale && this.pinchZoomLeft && this.pinchZoomRight) {
// @TODO it would be nice if PinchZoom exposed a variant of setTransform() that
// preserved translation. It currently only does this for mouse wheel events.
this.pinchZoomLeft.setTransform({ scale });
this.pinchZoomRight.setTransform({ scale });
}
}
shouldComponentUpdate(nextProps: Props, nextState: State) {
return this.props.leftImg !== nextProps.leftImg ||
this.props.rightImg !== nextProps.rightImg ||
this.state.verticalTwoUp !== nextState.verticalTwoUp;
return !shallowEqual(this.props, nextProps) || !shallowEqual(this.state, nextState);
}
@bind
onMobileWidthChange() {
this.setState({ verticalTwoUp: !this.widthQuery.matches });
toggleBackground() {
this.setState({
altBackground: !this.state.altBackground,
});
}
@bind
zoomIn() {
this.setState({
scale: Math.min(this.state.scale * 1.25, 100),
});
}
@bind
zoomOut() {
this.setState({
scale: Math.max(this.state.scale / 1.25, 0.0001),
});
}
@bind
editScale() {
this.setState({ editingScale: true }, () => {
if (this.scaleInput) this.scaleInput.focus();
});
}
@bind
cancelEditScale() {
this.setState({ editingScale: false });
}
@bind
onScaleInputChanged(event: Event) {
const target = event.target as HTMLInputElement;
const percent = parseFloat(target.value);
if (isNaN(percent)) return;
this.setState({
scale: percent / 100,
});
}
@bind
onPinchZoomLeftChange(event: Event) {
if (!this.pinchZoomRight || !this.pinchZoomLeft) throw Error('Missing pinch-zoom element');
this.setState({
scale: this.pinchZoomLeft.scale,
});
this.pinchZoomRight.setTransform({
scale: this.pinchZoomLeft.scale,
x: this.pinchZoomLeft.x,
@@ -97,11 +145,14 @@ export default class Output extends Component<Props, State> {
this.pinchZoomLeft.dispatchEvent(clonedEvent);
}
render({ leftImg, rightImg }: Props, { verticalTwoUp }: State) {
render(
{ orientation, leftImg, rightImg }: Props,
{ scale, editingScale, altBackground }: State,
) {
return (
<div class={style.output}>
<div class={`${style.output} ${altBackground ? style.altBackground : ''}`}>
<two-up
orientation={verticalTwoUp ? 'vertical' : 'horizontal'}
orientation={orientation}
// Event redirecting. See onRetargetableEvent.
onTouchStartCapture={this.onRetargetableEvent}
onTouchEndCapture={this.onRetargetableEvent}
@@ -110,7 +161,10 @@ export default class Output extends Component<Props, State> {
onMouseDownCapture={this.onRetargetableEvent}
onWheelCapture={this.onRetargetableEvent}
>
<pinch-zoom onChange={this.onPinchZoomLeftChange} ref={linkRef(this, 'pinchZoomLeft')}>
<pinch-zoom
onChange={this.onPinchZoomLeftChange}
ref={linkRef(this, 'pinchZoomLeft')}
>
<canvas
class={style.outputCanvas}
ref={linkRef(this, 'canvasLeft')}
@@ -127,6 +181,39 @@ export default class Output extends Component<Props, State> {
/>
</pinch-zoom>
</two-up>
<div class={style.controls}>
<div class={style.group}>
<button class={style.button} onClick={this.zoomOut}>
<RemoveIcon />
</button>
{editingScale ? (
<input
type="number"
step="1"
min="1"
max="1000000"
ref={linkRef(this, 'scaleInput')}
class={style.zoom}
value={Math.round(scale * 100)}
onInput={this.onScaleInputChanged}
onBlur={this.cancelEditScale}
/>
) : (
<span class={style.zoom} tabIndex={0} onFocus={this.editScale}>
<strong>{Math.round(scale * 100)}</strong>
%
</span>
)}
<button class={style.button} onClick={this.zoomIn}>
<AddIcon />
</button>
</div>
<button class={style.button} onClick={this.toggleBackground}>
<ToggleIcon />
Toggle Background
</button>
</div>
</div>
);
}

View File

@@ -14,16 +14,127 @@ Note: These styles are temporary. They will be replaced before going live.
.output {
@extend %fill;
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none"><path fill="rgba(0,0,0,0.05)" d="M0 0h10v10H0zM10 10h10v10H10z"/></svg>') center repeat;
&:before {
content: '';
@extend %fill;
background: #000;
opacity: 0;
transition: opacity 500ms ease;
}
&.altBackground:before {
opacity: .6;
}
> two-up {
@extend %fill;
--accent-color: var(--button-fg);
> pinch-zoom {
@extend %fill;
outline: none;
}
}
}
.controls {
position: absolute;
display: flex;
justify-content: center;
left: 220px;
right: 220px;
bottom: 0;
padding: 9px;
overflow: hidden;
flex-wrap: wrap;
contain: content;
@media (max-width: 680px) {
top: 0;
bottom: auto;
left: 0;
right: 0;
}
> * {
z-index: 2;
}
.group {
display: flex;
}
.button,
.zoom {
display: flex;
align-items: center;
flex: 0;
box-sizing: border-box;
height: 48px;
padding: 0 16px;
margin: 4px;
background-color: #fff;
border: 1px solid rgba(0,0,0,0.2);
border-radius: 5px;
line-height: 1;
font-size: 110%;
white-space: nowrap;
&:focus {
box-shadow: 0 0 0 2px var(--button-fg);
outline: none;
z-index: 1;
}
}
.button {
text-transform: uppercase;
color: var(--button-fg);
cursor: pointer;
text-indent: 6px;
}
.button:hover {
background-color: #eee;
}
.zoom {
flex: 0 0 6em;
color: #625E80;
font: inherit;
cursor: text;
width: 6em;
text-align: center;
justify-content: center;
&:focus {
box-shadow: inset 0 1px 4px rgba(0,0,0,0.2), 0 0 0 2px var(--button-fg);
}
strong {
position: relative;
top: 1px;
margin: 0 3px 0 0;
color: #888;
font-weight: normal;
border-bottom: 1px dashed #999;
}
}
.group > :not(:first-child) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
margin-left: 0;
}
.group > :not(:last-child) {
margin-right: 0;
border-right-width: 0;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
}
.outputCanvas {
image-rendering: pixelated;
}

33
src/lib/icons.tsx Normal file
View File

@@ -0,0 +1,33 @@
import { h } from 'preact';
// tslint:disable:max-line-length variable-name
export interface IconProps extends JSX.HTMLAttributes {}
const Icon = (props: IconProps) => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" {...props} />
);
export const DownloadIcon = (props: IconProps) => (
<Icon {...props}>
<path d="M19.35 10.04A7.49 7.49 0 0 0 12 4C9.11 4 6.6 5.64 5.35 8.04A5.994 5.994 0 0 0 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM17 13l-5 5-5-5h3V9h4v4h3z" />
</Icon>
);
export const ToggleIcon = (props: IconProps) => (
<Icon {...props}>
<path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm2 4v-2H3c0 1.1.89 2 2 2zM3 9h2V7H3v2zm12 12h2v-2h-2v2zm4-18H9c-1.11 0-2 .9-2 2v10c0 1.1.89 2 2 2h10c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 12H9V5h10v10zm-8 6h2v-2h-2v2zm-4 0h2v-2H7v2z" />
</Icon>
);
export const AddIcon = (props: IconProps) => (
<Icon {...props}>
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</Icon>
);
export const RemoveIcon = (props: IconProps) => (
<Icon {...props}>
<path d="M19 13H5v-2h14v2z"/>
</Icon>
);

View File

@@ -25,6 +25,15 @@ export function bind(target: any, propertyKey: string, descriptor: PropertyDescr
};
}
/** Compare two objects, returning a boolean indicating if
* they have the same properties and strictly equal values.
*/
export function shallowEqual(one: any, two: any) {
for (const i in one) if (one[i] !== two[i]) return false;
for (const i in two) if (!(i in one)) return false;
return true;
}
/** Creates a function ref that assigns its value to a given property of an object.
* @example
* // element is stored as `this.foo` when rendered.

View File

@@ -9,8 +9,18 @@ html, body {
width: 100%;
padding: 0;
margin: 0;
font: 14px/1.3 Roboto,'Helvetica Neue',arial,helvetica,sans-serif;
font: 12px/1.3 system-ui, -apple-system, BlinkMacSystemFont, Roboto, Helvetica, sans-serif;
overflow: hidden;
overscroll-behavior: none;
contain: strict;
}
:root {
--gray-dark: rgba(0,0,0,0.8);
--button-fg-color: 95, 180, 228;
--button-fg: rgb(95, 180, 228);
--negative: rgb(207, 113, 127);
--positive: rgb(149, 212, 159);
}