Compare commits

..

15 Commits

Author SHA1 Message Date
Jake Archibald
b139119551 Prevent options overflow at larger widths 2018-11-06 13:37:13 +00:00
Jake Archibald
85323dff87 No longer need this. 2018-11-06 13:37:13 +00:00
Jake Archibald
849441f23a Range bubble now behaves properly on mobile 2018-11-06 13:37:12 +00:00
Jake Archibald
c125af564a Allow two-up and pinch-zoom to work beneath controls 2018-11-06 13:37:12 +00:00
Jake Archibald
ce67f6c538 Expand/collapse icon 2018-11-06 13:37:11 +00:00
Jake Archibald
c8e0c56687 Fixing animation bugs 2018-11-06 13:37:11 +00:00
Jake Archibald
ac4f845d8e Adding height animation to multi-panel 2018-11-06 13:37:11 +00:00
Jake Archibald
52f61dfccc Adding labels to collapsed view 2018-11-06 13:37:10 +00:00
Jake Archibald
068dfe1b19 Ordering of items in mobile view. Changing scrolling element. 2018-11-06 13:37:10 +00:00
Jake Archibald
637e859a1e Abstracting results so it can be used as a heading. 2018-11-06 13:37:09 +00:00
Jake Archibald
da072a015b Edge cases for one-open 2018-11-06 13:37:08 +00:00
Jake Archibald
04492f8f5e Allow multi-panel to keep one open only 2018-11-06 13:37:08 +00:00
Jake Archibald
b34dca744d Adding margin so you can still access the two-up 2018-11-06 13:37:08 +00:00
Jake Archibald
c3edde280a Fixing thumb on two-up 2018-11-06 13:37:07 +00:00
Jake Archibald
8db8892529 Basic grid setup 2018-11-06 13:37:07 +00:00
12 changed files with 156 additions and 221 deletions

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,6 @@ import artworkIcon from './imgs/demos/artwork-icon.jpg';
import deviceScreenIcon from './imgs/demos/device-screen-icon.jpg';
import logoIcon from './imgs/demos/logo-icon.png';
import * as style from './style.scss';
import SnackBarElement from '../../lib/SnackBar';
const demos = [
{
@@ -43,7 +42,7 @@ const demos = [
interface Props {
onFile: (file: File | Fileish) => void;
showSnack: SnackBarElement['showSnackbar'];
onError: (error: string) => void;
}
interface State {
fetchingDemoIndex?: number;
@@ -80,7 +79,7 @@ export default class Intro extends Component<Props, State> {
this.props.onFile(file);
} catch (err) {
this.setState({ fetchingDemoIndex: undefined });
this.props.showSnack("Couldn't fetch demo image");
this.props.onError("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 FileSize from './FileSize';
import { DownloadIcon, CopyAcrossIcon, CopyAcrossIconProps } from '../../lib/icons';
import { DownloadIcon } from '../../lib/icons';
import '../custom-els/LoadingSpinner';
import { SourceImage } from '../compress';
import { Fileish, bind } from '../../lib/initial-util';
import { Fileish } from '../../lib/initial-util';
interface Props {
loading: boolean;
@@ -13,21 +13,12 @@ interface Props {
imageFile?: Fileish;
downloadUrl?: string;
children: ComponentChildren;
copyDirection: CopyAcrossIconProps['copyDirection'];
buttonPosition: keyof typeof buttonPositionClass;
onCopyToOtherClick(): void;
}
interface State {
showLoadingState: boolean;
}
const buttonPositionClass = {
'stack-right': style.stackRight,
'download-right': style.downloadRight,
'download-left': style.downloadLeft,
};
const loadingReactionDelay = 500;
export default class Results extends Component<Props, State> {
@@ -52,19 +43,9 @@ export default class Results extends Component<Props, State> {
}
}
@bind
private onCopyToOtherClick(event: Event) {
event.preventDefault();
this.props.onCopyToOtherClick();
}
render(
{ source, imageFile, downloadUrl, children, copyDirection, buttonPosition }: Props,
{ showLoadingState }: State,
) {
render({ source, imageFile, downloadUrl, children }: Props, { showLoadingState }: State) {
return (
<div class={`${style.results} ${buttonPositionClass[buttonPosition]}`}>
<div class={style.results}>
<div class={style.resultData}>
{(children as ComponentChild[])[0]
? <div class={style.resultTitle}>{children}</div>
@@ -78,14 +59,6 @@ export default class Results extends Component<Props, State> {
}
</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}>
{(downloadUrl && imageFile) && (
<a

View File

@@ -16,17 +16,9 @@
.results {
display: grid;
grid-template-columns: [text] 1fr [copy-button] auto [download-button] auto;
grid-template-columns: 1fr auto;
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 {
outline: none;
@@ -34,29 +26,13 @@
}
.result-data {
grid-row: 1;
grid-column: text;
display: flex;
align-items: center;
padding: 0 10px;
padding: 0 15px;
white-space: nowrap;
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 {
display: flex;
align-items: center;
@@ -64,7 +40,7 @@
}
.size-delta {
font-size: 0.8em;
font-size: 1.1rem;
font-style: italic;
position: relative;
top: -1px;
@@ -80,8 +56,6 @@
}
.download {
grid-row: 1;
grid-column: download-button;
background: #34B9EB;
--size: 38px;
width: var(--size);
@@ -103,8 +77,7 @@
animation: action-leave 0.2s;
}
.download-icon,
.copy-icon {
.download-icon {
color: #fff;
display: block;
--size: 24px;
@@ -120,12 +93,3 @@
--size: 22px;
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

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

View File

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

View File

@@ -47,28 +47,3 @@ export const ExpandIcon = (props: JSX.HTMLAttributes) => (
<path d="M16.6 8.6L12 13.2 7.4 8.6 6 10l6 6 6-6z"/>
</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,7 +25,6 @@ module.exports = function (_, env) {
path.join(__dirname, 'src/components'),
path.join(__dirname, 'src/codecs'),
path.join(__dirname, 'src/custom-els'),
path.join(__dirname, 'src/lib'),
];
return {
@@ -108,7 +107,7 @@ module.exports = function (_, env) {
loader: 'typings-for-css-modules-loader',
options: {
modules: true,
localIdentName: isProd ? '[hash:base64:5]' : '[local]__[hash:base64:5]',
localIdentName: '[local]__[hash:base64:5]',
namedExport: true,
camelCase: true,
importLoaders: 1,
@@ -200,11 +199,8 @@ module.exports = function (_, env) {
new OptimizeCssAssetsPlugin({
cssProcessorOptions: {
postcssReduceIdents: {
counterStyle: false,
gridTemplate: false,
keyframes: false
}
zindex: false,
discardComments: { removeAll: true }
}
}),