mirror of
https://github.com/GoogleChromeLabs/squoosh.git
synced 2025-11-13 01:07:18 +00:00
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:
committed by
Jake Archibald
parent
54ad30a7ed
commit
32f6f8b941
4
global.d.ts
vendored
4
global.d.ts
vendored
@@ -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
1640
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
87
src/components/FileSize.tsx
Normal file
87
src/components/FileSize.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
33
src/lib/icons.tsx
Normal 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>
|
||||
);
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user