Compare commits

..

7 Commits

Author SHA1 Message Date
Jake Archibald
76188df0d3 Destructuring args. 2018-11-06 13:47:18 +00:00
Jake Archibald
9a58e4d339 Fixing lossless slider for webP. Previously you couldn't select "9" :D 2018-11-06 13:47:18 +00:00
Jake Archibald
f396a5b784 MozJPEG chroma subsampling and quality (#235)
* Adding chroma subsampling for mozjpeg

* Adding separate chroma quality.

* Preact sometimes removes the inline styles, this fixes it.

* Simplifying chroma subsample

* Adding comments
2018-11-06 13:46:52 +00:00
Jake Archibald
e572b853e2 Snackbar defaults & copy undo (#233)
* Fix snackbar defaults. Fixes #205.

* Undo copy settings across.

* Oops

* Fixing stupid minification bug

* Something weird happened with the last commit
2018-11-06 13:44:15 +00:00
Jake Archibald
726c2f195a Fixing graphics glitch. Fixes #166 (#232) 2018-11-06 13:41:08 +00:00
Jake Archibald
4599e51b1e Copy to side ui (#229)
* Copy settings to other side button

* Download button on the outside.

* Whoops
2018-11-06 13:39:03 +00:00
Jake Archibald
d93169cc5a Mobile ui (#227)
* Basic grid setup

* Fixing thumb on two-up

* Adding margin so you can still access the two-up

* Allow multi-panel to keep one open only

* Edge cases for one-open

* Abstracting results so it can be used as a heading.

* Ordering of items in mobile view. Changing scrolling element.

* Adding labels to collapsed view

* Adding height animation to multi-panel

* Fixing animation bugs

* Expand/collapse icon

* Allow two-up and pinch-zoom to work beneath controls

* Range bubble now behaves properly on mobile

* No longer need this.

* Prevent options overflow at larger widths
2018-11-06 13:37:52 +00:00
21 changed files with 342 additions and 181 deletions

View File

@@ -39,5 +39,9 @@ struct MozJpegOptions {
bool trellis_opt_zero; bool trellis_opt_zero;
bool trellis_opt_table; bool trellis_opt_table;
int trellis_loops; int trellis_loops;
bool auto_subsample;
int chroma_subsample;
bool separate_chroma_quality;
int chroma_quality;
}; };
``` ```

View File

@@ -21,7 +21,7 @@
console.log('Version:', module.version().toString(16)); console.log('Version:', module.version().toString(16));
const image = await loadImage('../example.png'); const image = await loadImage('../example.png');
const result = module.encode(image.data, image.width, image.height, { const result = module.encode(image.data, image.width, image.height, {
quality: 40, quality: 75,
baseline: false, baseline: false,
arithmetic: false, arithmetic: false,
progressive: true, progressive: true,
@@ -29,10 +29,14 @@
smoothing: 0, smoothing: 0,
color_space: 3, color_space: 3,
quant_table: 3, quant_table: 3,
trellis_multipass: true, trellis_multipass: false,
trellis_opt_zero: true, trellis_opt_zero: false,
trellis_opt_table: true, trellis_opt_table: false,
trellis_loops: 1, trellis_loops: 1,
auto_subsample: true,
chroma_subsample: 2,
separate_chroma_quality: false,
chroma_quality: 75,
}); });
const blob = new Blob([result], {type: 'image/jpeg'}); const blob = new Blob([result], {type: 'image/jpeg'});

View File

@@ -29,6 +29,10 @@ struct MozJpegOptions {
bool trellis_opt_zero; bool trellis_opt_zero;
bool trellis_opt_table; bool trellis_opt_table;
int trellis_loops; int trellis_loops;
bool auto_subsample;
int chroma_subsample;
bool separate_chroma_quality;
int chroma_quality;
}; };
int version() { int version() {
@@ -119,9 +123,6 @@ val encode(std::string image_in, int image_width, int image_height, MozJpegOptio
*/ */
jpeg_set_defaults(&cinfo); jpeg_set_defaults(&cinfo);
/* Now you can set any non-default parameters you wish to.
* Here we just illustrate the use of quality (quantization table) scaling:
*/
jpeg_set_colorspace(&cinfo, (J_COLOR_SPACE) opts.color_space); jpeg_set_colorspace(&cinfo, (J_COLOR_SPACE) opts.color_space);
if (opts.quant_table != -1) { if (opts.quant_table != -1) {
@@ -142,11 +143,23 @@ val encode(std::string image_in, int image_width, int image_height, MozJpegOptio
jpeg_c_set_bool_param(&cinfo, JBOOLEAN_TRELLIS_Q_OPT, opts.trellis_opt_table); jpeg_c_set_bool_param(&cinfo, JBOOLEAN_TRELLIS_Q_OPT, opts.trellis_opt_table);
jpeg_c_set_int_param(&cinfo, JINT_TRELLIS_NUM_LOOPS, opts.trellis_loops); jpeg_c_set_int_param(&cinfo, JINT_TRELLIS_NUM_LOOPS, opts.trellis_loops);
// A little hacky to build a string for this, but it means we can use set_quality_ratings which
// does some useful heuristic stuff.
std::string quality_str = std::to_string(opts.quality); std::string quality_str = std::to_string(opts.quality);
if (opts.separate_chroma_quality && opts.color_space == JCS_YCbCr) {
quality_str += "," + std::to_string(opts.chroma_quality);
}
char const *pqual = quality_str.c_str(); char const *pqual = quality_str.c_str();
set_quality_ratings(&cinfo, (char*) pqual, opts.baseline); set_quality_ratings(&cinfo, (char*) pqual, opts.baseline);
if (!opts.auto_subsample && opts.color_space == JCS_YCbCr) {
cinfo.comp_info[0].h_samp_factor = opts.chroma_subsample;
cinfo.comp_info[0].v_samp_factor = opts.chroma_subsample;
}
if (!opts.baseline && opts.progressive) { if (!opts.baseline && opts.progressive) {
jpeg_simple_progression(&cinfo); jpeg_simple_progression(&cinfo);
} else { } else {
@@ -209,6 +222,10 @@ EMSCRIPTEN_BINDINGS(my_module) {
.field("trellis_opt_zero", &MozJpegOptions::trellis_opt_zero) .field("trellis_opt_zero", &MozJpegOptions::trellis_opt_zero)
.field("trellis_opt_table", &MozJpegOptions::trellis_opt_table) .field("trellis_opt_table", &MozJpegOptions::trellis_opt_table)
.field("trellis_loops", &MozJpegOptions::trellis_loops) .field("trellis_loops", &MozJpegOptions::trellis_loops)
.field("chroma_subsample", &MozJpegOptions::chroma_subsample)
.field("auto_subsample", &MozJpegOptions::auto_subsample)
.field("separate_chroma_quality", &MozJpegOptions::separate_chroma_quality)
.field("chroma_quality", &MozJpegOptions::chroma_quality)
; ;
function("version", &version); function("version", &version);

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@@ -17,6 +17,10 @@ export interface EncodeOptions {
trellis_opt_zero: boolean; trellis_opt_zero: boolean;
trellis_opt_table: boolean; trellis_opt_table: boolean;
trellis_loops: number; trellis_loops: number;
auto_subsample: boolean;
chroma_subsample: number;
separate_chroma_quality: boolean;
chroma_quality: number;
} }
export interface EncoderState { type: typeof type; options: EncodeOptions; } export interface EncoderState { type: typeof type; options: EncodeOptions; }
@@ -38,4 +42,8 @@ export const defaultOptions: EncodeOptions = {
trellis_opt_zero: false, trellis_opt_zero: false,
trellis_opt_table: false, trellis_opt_table: false,
trellis_loops: 1, trellis_loops: 1,
auto_subsample: true,
chroma_subsample: 2,
separate_chroma_quality: false,
chroma_quality: 75,
}; };

View File

@@ -39,8 +39,13 @@ export default class MozJPEGEncoderOptions extends Component<Props, State> {
trellis_multipass: inputFieldChecked(form.trellis_multipass, options.trellis_multipass), trellis_multipass: inputFieldChecked(form.trellis_multipass, options.trellis_multipass),
trellis_opt_zero: inputFieldChecked(form.trellis_opt_zero, options.trellis_opt_zero), trellis_opt_zero: inputFieldChecked(form.trellis_opt_zero, options.trellis_opt_zero),
trellis_opt_table: inputFieldChecked(form.trellis_opt_table, options.trellis_opt_table), trellis_opt_table: inputFieldChecked(form.trellis_opt_table, options.trellis_opt_table),
auto_subsample: inputFieldChecked(form.auto_subsample, options.auto_subsample),
separate_chroma_quality:
inputFieldChecked(form.separate_chroma_quality, options.separate_chroma_quality),
// .value // .value
quality: inputFieldValueAsNumber(form.quality, options.quality), quality: inputFieldValueAsNumber(form.quality, options.quality),
chroma_quality: inputFieldValueAsNumber(form.chroma_quality, options.chroma_quality),
chroma_subsample: inputFieldValueAsNumber(form.chroma_subsample, options.chroma_subsample),
smoothing: inputFieldValueAsNumber(form.smoothing, options.smoothing), smoothing: inputFieldValueAsNumber(form.smoothing, options.smoothing),
color_space: inputFieldValueAsNumber(form.color_space, options.color_space), color_space: inputFieldValueAsNumber(form.color_space, options.color_space),
quant_table: inputFieldValueAsNumber(form.quant_table, options.quant_table), quant_table: inputFieldValueAsNumber(form.quant_table, options.quant_table),
@@ -75,6 +80,72 @@ export default class MozJPEGEncoderOptions extends Component<Props, State> {
<Expander> <Expander>
{showAdvanced ? {showAdvanced ?
<div> <div>
<label class={style.optionTextFirst}>
Channels:
<Select
name="color_space"
value={options.color_space}
onChange={this.onChange}
>
<option value={MozJpegColorSpace.GRAYSCALE}>Grayscale</option>
<option value={MozJpegColorSpace.RGB}>RGB</option>
<option value={MozJpegColorSpace.YCbCr}>YCbCr</option>
</Select>
</label>
<Expander>
{options.color_space === MozJpegColorSpace.YCbCr ?
<div>
<label class={style.optionInputFirst}>
<Checkbox
name="auto_subsample"
checked={options.auto_subsample}
onChange={this.onChange}
/>
Auto subsample chroma
</label>
<Expander>
{options.auto_subsample ? null :
<div class={style.optionOneCell}>
<Range
name="chroma_subsample"
min="1"
max="4"
value={options.chroma_subsample}
onInput={this.onChange}
>
Subsample chroma by:
</Range>
</div>
}
</Expander>
<label class={style.optionInputFirst}>
<Checkbox
name="separate_chroma_quality"
checked={options.separate_chroma_quality}
onChange={this.onChange}
/>
Separate chroma quality
</label>
<Expander>
{options.separate_chroma_quality ?
<div class={style.optionOneCell}>
<Range
name="chroma_quality"
min="0"
max="100"
value={options.chroma_quality}
onInput={this.onChange}
>
Chroma quality:
</Range>
</div>
: null
}
</Expander>
</div>
: null
}
</Expander>
<label class={style.optionInputFirst}> <label class={style.optionInputFirst}>
<Checkbox <Checkbox
name="baseline" name="baseline"
@@ -119,18 +190,6 @@ export default class MozJPEGEncoderOptions extends Component<Props, State> {
Smoothing: Smoothing:
</Range> </Range>
</div> </div>
<label class={style.optionTextFirst}>
Channels:
<Select
name="color_space"
value={options.color_space}
onChange={this.onChange}
>
<option value={MozJpegColorSpace.GRAYSCALE}>Grayscale</option>
<option value={MozJpegColorSpace.RGB}>RGB</option>
<option value={MozJpegColorSpace.YCbCr}>YCbCr</option>
</Select>
</label>
<label class={style.optionTextFirst}> <label class={style.optionTextFirst}>
Quantization: Quantization:
<Select <Select

View File

@@ -26,8 +26,10 @@ const losslessPresets:[number, number][] = [
]; ];
const losslessPresetDefault = 6; const losslessPresetDefault = 6;
function determineLosslessQuality(quality: number): number { function determineLosslessQuality(quality: number, method: number): number {
const index = losslessPresets.findIndex(item => item[1] === quality); const index = losslessPresets.findIndex(
([presetMethod, presetQuality]) => presetMethod === method && presetQuality === quality,
);
if (index !== -1) return index; if (index !== -1) return index;
// Quality doesn't match one of the presets. // Quality doesn't match one of the presets.
// This can happen when toggling 'lossless'. // This can happen when toggling 'lossless'.
@@ -45,7 +47,7 @@ export default class WebPEncoderOptions extends Component<Props, State> {
const lossless = inputFieldCheckedAsNumber(form.lossless); const lossless = inputFieldCheckedAsNumber(form.lossless);
const { options } = this.props; const { options } = this.props;
const losslessPresetValue = inputFieldValueAsNumber( const losslessPresetValue = inputFieldValueAsNumber(
form.lossless_preset, determineLosslessQuality(options.quality), form.lossless_preset, determineLosslessQuality(options.quality, options.method),
); );
const newOptions: EncodeOptions = { const newOptions: EncodeOptions = {
@@ -97,7 +99,7 @@ export default class WebPEncoderOptions extends Component<Props, State> {
name="lossless_preset" name="lossless_preset"
min="0" min="0"
max="9" max="9"
value={determineLosslessQuality(options.quality)} value={determineLosslessQuality(options.quality, options.method)}
onInput={this.onChange} onInput={this.onChange}
> >
Effort: Effort:

View File

@@ -4,7 +4,7 @@ import { bind, linkRef, Fileish } from '../../lib/initial-util';
import * as style from './style.scss'; import * as style from './style.scss';
import { FileDropEvent } from './custom-els/FileDrop'; import { FileDropEvent } from './custom-els/FileDrop';
import './custom-els/FileDrop'; import './custom-els/FileDrop';
import SnackBarElement from '../../lib/SnackBar'; import SnackBarElement, { SnackOptions } from '../../lib/SnackBar';
import '../../lib/SnackBar'; import '../../lib/SnackBar';
import Intro from '../intro'; import Intro from '../intro';
import '../custom-els/LoadingSpinner'; import '../custom-els/LoadingSpinner';
@@ -39,7 +39,7 @@ export default class App extends Component<Props, State> {
import('../compress').then((module) => { import('../compress').then((module) => {
this.setState({ Compress: module.default }); this.setState({ Compress: module.default });
}).catch(() => { }).catch(() => {
this.showError('Failed to load app'); this.showSnack('Failed to load app');
}); });
// In development, persist application state across hot reloads: // In development, persist application state across hot reloads:
@@ -66,9 +66,9 @@ export default class App extends Component<Props, State> {
} }
@bind @bind
private showError(error: string) { private showSnack(message: string, options: SnackOptions = {}): Promise<string> {
if (!this.snackbar) throw Error('Snackbar missing'); if (!this.snackbar) throw Error('Snackbar missing');
this.snackbar.showSnackbar({ message: error }); return this.snackbar.showSnackbar(message, options);
} }
render({}: Props, { file, Compress }: State) { render({}: Props, { file, Compress }: State) {
@@ -76,9 +76,9 @@ export default class App extends Component<Props, State> {
<div id="app" class={style.app}> <div id="app" class={style.app}>
<file-drop accept="image/*" onfiledrop={this.onFileDrop} class={style.drop}> <file-drop accept="image/*" onfiledrop={this.onFileDrop} class={style.drop}>
{(!file) {(!file)
? <Intro onFile={this.onIntroPickFile} onError={this.showError} /> ? <Intro onFile={this.onIntroPickFile} showSnack={this.showSnack} />
: (Compress) : (Compress)
? <Compress file={file} onError={this.showError} /> ? <Compress file={file} showSnack={this.showSnack} />
: <loading-spinner class={style.appLoader}/> : <loading-spinner class={style.appLoader}/>
} }
<snack-bar ref={linkRef(this, 'snackbar')} /> <snack-bar ref={linkRef(this, 'snackbar')} />

View File

@@ -59,13 +59,11 @@ const encoderOptionsComponentMap = {
interface Props { interface Props {
mobileView: boolean; mobileView: boolean;
source?: SourceImage; source?: SourceImage;
imageIndex: number;
encoderState: EncoderState; encoderState: EncoderState;
preprocessorState: PreprocessorState; preprocessorState: PreprocessorState;
onEncoderTypeChange(newType: EncoderType): void; onEncoderTypeChange(newType: EncoderType): void;
onEncoderOptionsChange(newOptions: EncoderOptions): void; onEncoderOptionsChange(newOptions: EncoderOptions): void;
onPreprocessorOptionsChange(newOptions: PreprocessorState): void; onPreprocessorOptionsChange(newOptions: PreprocessorState): void;
onCopyToOtherClick(): void;
} }
interface State { interface State {
@@ -116,16 +114,9 @@ export default class Options extends Component<Props, State> {
); );
} }
@bind
onCopyToOtherClick(event: Event) {
event.preventDefault();
this.props.onCopyToOtherClick();
}
render( render(
{ {
source, source,
imageIndex,
encoderState, encoderState,
preprocessorState, preprocessorState,
onEncoderOptionsChange, onEncoderOptionsChange,
@@ -205,14 +196,6 @@ export default class Options extends Component<Props, State> {
/> />
: null} : null}
</Expander> </Expander>
<div class={style.optionsCopy}>
<button onClick={this.onCopyToOtherClick} class={style.copyButton}>
{imageIndex === 1 && '← '}
Copy settings across
{imageIndex === 0 && ' →'}
</button>
</div>
</div> </div>
); );
} }

View File

@@ -55,18 +55,3 @@ $horizontalPadding: 15px;
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
} }
.options-copy {
display: grid;
background: rgba(0, 0, 0, 0.9);
padding: 5px;
}
.copy-button {
composes: unbutton from '../../lib/util.scss';
background: #484848;
border-radius: 4px;
color: #fff;
text-align: left;
padding: 5px 10px;
}

View File

@@ -129,4 +129,8 @@
.output-canvas { .output-canvas {
flex-shrink: 0; flex-shrink: 0;
// This fixes a severe painting bug in Chrome.
// We should try to remove this once the issue is fixed.
// https://bugs.chromium.org/p/chromium/issues/detail?id=870222#c10
will-change: auto;
} }

View File

@@ -34,7 +34,8 @@ import Processor from '../../codecs/processor';
import { VectorResizeOptions, BitmapResizeOptions } from '../../codecs/resize/processor-meta'; import { VectorResizeOptions, BitmapResizeOptions } from '../../codecs/resize/processor-meta';
import './custom-els/MultiPanel'; import './custom-els/MultiPanel';
import Results from '../results'; import Results from '../results';
import { ExpandIcon } from '../../lib/icons'; import { ExpandIcon, CopyAcrossIconProps } from '../../lib/icons';
import SnackBarElement from 'src/lib/SnackBar';
export interface SourceImage { export interface SourceImage {
file: File | Fileish; file: File | Fileish;
@@ -58,7 +59,7 @@ interface EncodedImage {
interface Props { interface Props {
file: File | Fileish; file: File | Fileish;
onError: (msg: string) => void; showSnack: SnackBarElement['showSnackbar'];
} }
interface State { interface State {
@@ -136,7 +137,7 @@ async function processSvg(blob: Blob): Promise<HTMLImageElement> {
const parser = new DOMParser(); const parser = new DOMParser();
const text = await blobToText(blob); const text = await blobToText(blob);
const document = parser.parseFromString(text, 'image/svg+xml'); const document = parser.parseFromString(text, 'image/svg+xml');
const svg = document.documentElement; const svg = document.documentElement!;
if (svg.hasAttribute('width') && svg.hasAttribute('height')) { if (svg.hasAttribute('width') && svg.hasAttribute('height')) {
return blobToImg(blob); return blobToImg(blob);
@@ -156,6 +157,9 @@ async function processSvg(blob: Blob): Promise<HTMLImageElement> {
// These are only used in the mobile view // These are only used in the mobile view
const resultTitles = ['Top', 'Bottom']; const resultTitles = ['Top', 'Bottom'];
// These are only used in the desktop view
const buttonPositions =
['download-left', 'download-right'] as ('download-left' | 'download-right')[];
export default class Compress extends Component<Props, State> { export default class Compress extends Component<Props, State> {
widthQuery = window.matchMedia('(max-width: 599px)'); widthQuery = window.matchMedia('(max-width: 599px)');
@@ -247,12 +251,24 @@ export default class Compress extends Component<Props, State> {
} }
} }
private onCopyToOtherClick(index: 0 | 1) { private async onCopyToOtherClick(index: 0 | 1) {
const otherIndex = (index + 1) % 2; const otherIndex = (index + 1) % 2;
const oldSettings = this.state.images[otherIndex];
this.setState({ this.setState({
images: cleanSet(this.state.images, otherIndex, this.state.images[index]), images: cleanSet(this.state.images, otherIndex, this.state.images[index]),
}); });
const result = await this.props.showSnack('Settings copied across', {
timeout: 5000,
actions: ['undo', 'dismiss'],
});
if (result !== 'undo') return;
this.setState({
images: cleanSet(this.state.images, otherIndex, oldSettings),
});
} }
@bind @bind
@@ -315,7 +331,7 @@ export default class Compress extends Component<Props, State> {
console.error(err); console.error(err);
// Another file has been opened before this one processed. // Another file has been opened before this one processed.
if (this.state.loadingCounter !== loadingCounter) return; if (this.state.loadingCounter !== loadingCounter) return;
this.props.onError('Invalid image'); this.props.showSnack('Invalid image');
this.setState({ loading: false }); this.setState({ loading: false });
} }
} }
@@ -374,7 +390,7 @@ export default class Compress extends Component<Props, State> {
} }
} catch (err) { } catch (err) {
if (err.name === 'AbortError') return; if (err.name === 'AbortError') return;
this.props.onError(`Processing error (type=${image.encoderState.type}): ${err}`); this.props.showSnack(`Processing error (type=${image.encoderState.type}): ${err}`);
throw err; throw err;
} }
} }
@@ -405,26 +421,30 @@ export default class Compress extends Component<Props, State> {
<Options <Options
source={source} source={source}
mobileView={mobileView} mobileView={mobileView}
imageIndex={index}
preprocessorState={image.preprocessorState} preprocessorState={image.preprocessorState}
encoderState={image.encoderState} encoderState={image.encoderState}
onEncoderTypeChange={this.onEncoderTypeChange.bind(this, index)} onEncoderTypeChange={this.onEncoderTypeChange.bind(this, index)}
onEncoderOptionsChange={this.onEncoderOptionsChange.bind(this, index)} onEncoderOptionsChange={this.onEncoderOptionsChange.bind(this, index)}
onPreprocessorOptionsChange={this.onPreprocessorOptionsChange.bind(this, index)} onPreprocessorOptionsChange={this.onPreprocessorOptionsChange.bind(this, index)}
onCopyToOtherClick={this.onCopyToOtherClick.bind(this, index)}
/> />
)); ));
const results = images.map((image, i) => ( const copyDirections =
(mobileView ? ['down', 'up'] : ['right', 'left']) as CopyAcrossIconProps['copyDirection'][];
const results = images.map((image, index) => (
<Results <Results
downloadUrl={image.downloadUrl} downloadUrl={image.downloadUrl}
imageFile={image.file} imageFile={image.file}
source={source} source={source}
loading={loading || image.loading} loading={loading || image.loading}
copyDirection={copyDirections[index]}
onCopyToOtherClick={this.onCopyToOtherClick.bind(this, index)}
buttonPosition={mobileView ? 'stack-right' : buttonPositions[index]}
> >
{!mobileView ? null : [ {!mobileView ? null : [
<ExpandIcon class={style.expandIcon} key="expand-icon"/>, <ExpandIcon class={style.expandIcon} key="expand-icon"/>,
`${resultTitles[i]} (${encoderMap[image.encoderState.type].label})`, `${resultTitles[index]} (${encoderMap[image.encoderState.type].label})`,
]} ]}
</Results> </Results>
)); ));

View File

@@ -12,6 +12,7 @@ import artworkIcon from './imgs/demos/artwork-icon.jpg';
import deviceScreenIcon from './imgs/demos/device-screen-icon.jpg'; import deviceScreenIcon from './imgs/demos/device-screen-icon.jpg';
import logoIcon from './imgs/demos/logo-icon.png'; import logoIcon from './imgs/demos/logo-icon.png';
import * as style from './style.scss'; import * as style from './style.scss';
import SnackBarElement from '../../lib/SnackBar';
const demos = [ const demos = [
{ {
@@ -42,7 +43,7 @@ const demos = [
interface Props { interface Props {
onFile: (file: File | Fileish) => void; onFile: (file: File | Fileish) => void;
onError: (error: string) => void; showSnack: SnackBarElement['showSnackbar'];
} }
interface State { interface State {
fetchingDemoIndex?: number; fetchingDemoIndex?: number;
@@ -79,7 +80,7 @@ export default class Intro extends Component<Props, State> {
this.props.onFile(file); this.props.onFile(file);
} catch (err) { } catch (err) {
this.setState({ fetchingDemoIndex: undefined }); this.setState({ fetchingDemoIndex: undefined });
this.props.onError("Couldn't fetch demo image"); this.props.showSnack("Couldn't fetch demo image");
} }
} }

View File

@@ -2,10 +2,10 @@ import { h, Component, ComponentChildren, ComponentChild } from 'preact';
import * as style from './style.scss'; import * as style from './style.scss';
import FileSize from './FileSize'; import FileSize from './FileSize';
import { DownloadIcon } from '../../lib/icons'; import { DownloadIcon, CopyAcrossIcon, CopyAcrossIconProps } from '../../lib/icons';
import '../custom-els/LoadingSpinner'; import '../custom-els/LoadingSpinner';
import { SourceImage } from '../compress'; import { SourceImage } from '../compress';
import { Fileish } from '../../lib/initial-util'; import { Fileish, bind } from '../../lib/initial-util';
interface Props { interface Props {
loading: boolean; loading: boolean;
@@ -13,12 +13,21 @@ interface Props {
imageFile?: Fileish; imageFile?: Fileish;
downloadUrl?: string; downloadUrl?: string;
children: ComponentChildren; children: ComponentChildren;
copyDirection: CopyAcrossIconProps['copyDirection'];
buttonPosition: keyof typeof buttonPositionClass;
onCopyToOtherClick(): void;
} }
interface State { interface State {
showLoadingState: boolean; showLoadingState: boolean;
} }
const buttonPositionClass = {
'stack-right': style.stackRight,
'download-right': style.downloadRight,
'download-left': style.downloadLeft,
};
const loadingReactionDelay = 500; const loadingReactionDelay = 500;
export default class Results extends Component<Props, State> { export default class Results extends Component<Props, State> {
@@ -43,9 +52,19 @@ export default class Results extends Component<Props, State> {
} }
} }
render({ source, imageFile, downloadUrl, children }: Props, { showLoadingState }: State) { @bind
private onCopyToOtherClick(event: Event) {
event.preventDefault();
this.props.onCopyToOtherClick();
}
render(
{ source, imageFile, downloadUrl, children, copyDirection, buttonPosition }: Props,
{ showLoadingState }: State,
) {
return ( return (
<div class={style.results}> <div class={`${style.results} ${buttonPositionClass[buttonPosition]}`}>
<div class={style.resultData}> <div class={style.resultData}>
{(children as ComponentChild[])[0] {(children as ComponentChild[])[0]
? <div class={style.resultTitle}>{children}</div> ? <div class={style.resultTitle}>{children}</div>
@@ -59,6 +78,14 @@ export default class Results extends Component<Props, State> {
} }
</div> </div>
<button
class={style.copyToOther}
title="Copy settings to other side"
onClick={this.onCopyToOtherClick}
>
<CopyAcrossIcon class={style.copyIcon} copyDirection={copyDirection} />
</button>
<div class={style.download}> <div class={style.download}>
{(downloadUrl && imageFile) && ( {(downloadUrl && imageFile) && (
<a <a

View File

@@ -16,9 +16,17 @@
.results { .results {
display: grid; display: grid;
grid-template-columns: 1fr auto; grid-template-columns: [text] 1fr [copy-button] auto [download-button] auto;
background: rgba(0, 0, 0, 0.9); background: rgba(0, 0, 0, 0.9);
font-size: 1rem;
@media (min-width: 400px) {
font-size: 1.2rem;
}
@media (min-width: 600px) {
font-size: 1.4rem; font-size: 1.4rem;
}
&:focus { &:focus {
outline: none; outline: none;
@@ -26,13 +34,29 @@
} }
.result-data { .result-data {
grid-row: 1;
grid-column: text;
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0 15px; padding: 0 10px;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
} }
.download-right {
grid-template-columns: [copy-button] auto [text] 1fr [download-button] auto;
}
.download-left {
grid-template-columns: [download-button] auto [text] 1fr [copy-button] auto;
}
.stack-right {
& .result-data {
padding: 0 15px;
}
}
.result-title { .result-title {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -40,7 +64,7 @@
} }
.size-delta { .size-delta {
font-size: 1.1rem; font-size: 0.8em;
font-style: italic; font-style: italic;
position: relative; position: relative;
top: -1px; top: -1px;
@@ -56,6 +80,8 @@
} }
.download { .download {
grid-row: 1;
grid-column: download-button;
background: #34B9EB; background: #34B9EB;
--size: 38px; --size: 38px;
width: var(--size); width: var(--size);
@@ -77,7 +103,8 @@
animation: action-leave 0.2s; animation: action-leave 0.2s;
} }
.download-icon { .download-icon,
.copy-icon {
color: #fff; color: #fff;
display: block; display: block;
--size: 24px; --size: 24px;
@@ -93,3 +120,12 @@
--size: 22px; --size: 22px;
grid-area: 1/1; grid-area: 1/1;
} }
.copy-to-other {
grid-row: 1;
grid-column: copy-button;
composes: unbutton from '../../lib/util.scss';
composes: download;
background: #656565;
}

View File

@@ -57,6 +57,8 @@ class RangeInputElement extends HTMLElement {
this.insertBefore(this._input, this.firstChild); this.insertBefore(this._input, this.firstChild);
this._valueDisplay = this.querySelector('.' + style.valueDisplay) as HTMLDivElement; this._valueDisplay = this.querySelector('.' + style.valueDisplay) as HTMLDivElement;
// Set inline styles (this is useful when used with frameworks which might clear inline styles)
this._update();
} }
get labelPrecision(): string { get labelPrecision(): string {

View File

@@ -1,114 +1,96 @@
import './styles.css'; import * as style from './styles.css';
const DEFAULT_TIMEOUT = 2750;
export interface SnackOptions { export interface SnackOptions {
message: string;
timeout?: number; timeout?: number;
actionText?: string; actions?: string[];
actionHandler?: () => boolean | null;
} }
export interface SnackShowResult { function createSnack(message: string, options: SnackOptions): [Element, Promise<string>] {
action: boolean; const {
} timeout = 0,
actions = [],
} = options;
class Snack { // Provide a default 'dismiss' action
private _onremove: ((result: SnackShowResult) => void)[] = []; if (!timeout && actions.length === 0) actions.push('dismiss');
private _options: SnackOptions;
private _element: Element = document.createElement('div');
private _text: Element = document.createElement('div');
private _button: Element = document.createElement('button');
private _showing = false;
private _closeTimer?: number;
private _result: SnackShowResult = {
action: false,
};
constructor (options: SnackOptions, callback?: (result: SnackShowResult) => void) { const el = document.createElement('div');
this._options = options; el.className = style.snackbar;
el.setAttribute('aria-live', 'assertive');
el.setAttribute('aria-atomic', 'true');
el.setAttribute('aria-hidden', 'false');
this._element.className = 'snackbar'; const text = document.createElement('div');
this._element.setAttribute('aria-live', 'assertive'); text.className = style.text;
this._element.setAttribute('aria-atomic', 'true'); text.textContent = message;
this._element.setAttribute('aria-hidden', 'true'); el.appendChild(text);
this._text.className = 'snackbar--text'; const result = new Promise<string>((resolve) => {
this._text.textContent = options.message; let timeoutId: number;
this._element.appendChild(this._text);
if (options.actionText) { // Add action buttons
this._button.className = 'snackbar--button'; for (const action of actions) {
this._button.textContent = options.actionText; const button = document.createElement('button');
this._button.addEventListener('click', () => { button.className = style.button;
if (this._showing) { button.textContent = action;
if (options.actionHandler && options.actionHandler() === false) return; button.addEventListener('click', () => {
this._result.action = true; clearTimeout(timeoutId);
} resolve(action);
this.hide();
}); });
this._element.appendChild(this._button); el.appendChild(button);
} }
if (callback) { // Add timeout
this._onremove.push(callback); if (timeout) {
timeoutId = self.setTimeout(
() => resolve(''),
timeout,
);
} }
}
cancelTimer () {
if (this._closeTimer != null) clearTimeout(this._closeTimer);
}
show (parent: Element): Promise<SnackShowResult> {
if (this._showing) return Promise.resolve(this._result);
this._showing = true;
this.cancelTimer();
if (parent !== this._element.parentNode) {
parent.appendChild(this._element);
}
this._element.removeAttribute('aria-hidden');
this._closeTimer = setTimeout(this.hide.bind(this), this._options.timeout || DEFAULT_TIMEOUT);
return new Promise((resolve) => {
this._onremove.push(resolve);
}); });
}
hide () { return [el, result];
if (!this._showing) return;
this._showing = false;
this.cancelTimer();
this._element.addEventListener('animationend', this.remove.bind(this));
this._element.setAttribute('aria-hidden', 'true');
}
remove () {
this.cancelTimer();
const parent = this._element.parentNode;
if (parent) parent.removeChild(this._element);
this._onremove.forEach(f => f(this._result));
this._onremove = [];
}
} }
export default class SnackBarElement extends HTMLElement { export default class SnackBarElement extends HTMLElement {
private _snackbars: Snack[] = []; private _snackbars: [string, SnackOptions, (action: Promise<string>) => void][] = [];
private _processingStack = false; private _processingQueue = false;
showSnackbar (options: SnackOptions): Promise<SnackShowResult> { /**
return new Promise((resolve) => { * Show a snackbar. Returns a promise for the name of the action clicked, or an empty string if no
const snack = new Snack(options, resolve); * action is clicked.
this._snackbars.push(snack); */
this._processStack(); showSnackbar(message: string, options: SnackOptions = {}): Promise<string> {
return new Promise<string>((resolve) => {
this._snackbars.push([message, options, resolve]);
if (!this._processingQueue) this._processQueue();
}); });
} }
private async _processStack () { private async _processQueue() {
if (this._processingStack === true || this._snackbars.length === 0) return; this._processingQueue = true;
this._processingStack = true;
await this._snackbars[0].show(this); while (this._snackbars[0]) {
const [message, options, resolver] = this._snackbars[0];
const [el, result] = createSnack(message, options);
// Pass the result back to the original showSnackbar call.
resolver(result);
this.appendChild(el);
// Wait for the user to click an action, or for the snack to timeout.
await result;
// Transition the snack away.
el.setAttribute('aria-hidden', 'true');
await new Promise((resolve) => {
el.addEventListener('animationend', () => resolve());
});
el.remove();
this._snackbars.shift(); this._snackbars.shift();
this._processingStack = false; }
this._processStack();
this._processingQueue = false;
} }
} }

View File

@@ -22,7 +22,6 @@ snack-bar {
transform-origin: center; transform-origin: center;
color: #eee; color: #eee;
z-index: 100; z-index: 100;
pointer-events: none;
cursor: default; cursor: default;
will-change: transform; will-change: transform;
animation: snackbar-show 300ms ease forwards 1; animation: snackbar-show 300ms ease forwards 1;
@@ -53,13 +52,13 @@ snack-bar {
} }
} }
.snackbar--text { .text {
flex: 1 1 auto; flex: 1 1 auto;
padding: 16px; padding: 16px;
font-size: 100%; font-size: 100%;
} }
.snackbar--button { .button {
position: relative; position: relative;
flex: 0 1 auto; flex: 0 1 auto;
padding: 8px; padding: 8px;
@@ -75,16 +74,15 @@ snack-bar {
font-size: 100%; font-size: 100%;
text-transform: uppercase; text-transform: uppercase;
text-align: center; text-align: center;
pointer-events: all;
cursor: pointer; cursor: pointer;
overflow: hidden; overflow: hidden;
transition: background-color 200ms ease; transition: background-color 200ms ease;
outline: none; outline: none;
} }
.snackbar--button:hover { .button:hover {
background-color: rgba(0,0,0,0.15); background-color: rgba(0,0,0,0.15);
} }
.snackbar--button:focus:before { .button:focus:before {
content: ''; content: '';
position: absolute; position: absolute;
left: 50%; left: 50%;

View File

@@ -47,3 +47,28 @@ export const ExpandIcon = (props: JSX.HTMLAttributes) => (
<path d="M16.6 8.6L12 13.2 7.4 8.6 6 10l6 6 6-6z"/> <path d="M16.6 8.6L12 13.2 7.4 8.6 6 10l6 6 6-6z"/>
</Icon> </Icon>
); );
const copyAcrossRotations = {
up: 90, right: 180, down: -90, left: 0,
};
export interface CopyAcrossIconProps extends JSX.HTMLAttributes {
copyDirection: keyof typeof copyAcrossRotations;
}
export const CopyAcrossIcon = (props: CopyAcrossIconProps) => {
const { copyDirection, ...otherProps } = props;
const id = 'point-' + copyDirection;
const rotation = copyAcrossRotations[copyDirection];
return (
<Icon {...otherProps}>
<defs>
<clipPath id={id}>
<path d="M-12-12v24h24v-24zM4.5 2h-4v3l-5-5 5-5v3h4z" transform={`translate(12 13) rotate(${rotation})`}/>
</clipPath>
</defs>
<path clip-path={`url(#${id})`} d="M19 3h-4.2c-.4-1.2-1.5-2-2.8-2s-2.4.8-2.8 2H5a2 2 0 0 0-2 2v14c0 1.1.9 2 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zm-7 0a1 1 0 0 1 0 2c-.6 0-1-.4-1-1s.4-1 1-1z"/>
</Icon>
);
};

View File

@@ -25,6 +25,7 @@ module.exports = function (_, env) {
path.join(__dirname, 'src/components'), path.join(__dirname, 'src/components'),
path.join(__dirname, 'src/codecs'), path.join(__dirname, 'src/codecs'),
path.join(__dirname, 'src/custom-els'), path.join(__dirname, 'src/custom-els'),
path.join(__dirname, 'src/lib'),
]; ];
return { return {
@@ -107,7 +108,7 @@ module.exports = function (_, env) {
loader: 'typings-for-css-modules-loader', loader: 'typings-for-css-modules-loader',
options: { options: {
modules: true, modules: true,
localIdentName: '[local]__[hash:base64:5]', localIdentName: isProd ? '[hash:base64:5]' : '[local]__[hash:base64:5]',
namedExport: true, namedExport: true,
camelCase: true, camelCase: true,
importLoaders: 1, importLoaders: 1,
@@ -199,8 +200,11 @@ module.exports = function (_, env) {
new OptimizeCssAssetsPlugin({ new OptimizeCssAssetsPlugin({
cssProcessorOptions: { cssProcessorOptions: {
zindex: false, postcssReduceIdents: {
discardComments: { removeAll: true } counterStyle: false,
gridTemplate: false,
keyframes: false
}
} }
}), }),