mirror of
https://github.com/GoogleChromeLabs/squoosh.git
synced 2025-11-13 17:27:09 +00:00
Options ui (#222)
* wip * Commenting stuff to keep the build happy * Revealing sections * Custom select elements & more form work * Range input styles * Text fields with inputs do the right thing * Safari & Firefox fixes * Large compress select * oops * MozJPEG options updated * OptPNG options * These asserts weren't true * Generic options * WebP options * Hiding "edit" when "original image" * Download icon * Copy setting button - still not happy with this * Progress indicator * Loading icon enter/exit anim * Preventing controls going under options * Ahh so that's what was causing scrolling * Ahh so that's what was causing outlines * Simplifying range styles and fixing cross-browser * Processing custom element styles * Get precision from step by default * I don't know how or when this happened. * Don't need that many steps * Avoid having an element that covers the pinch zoom * Preventing overlap with zoom controls * Prevent ts warning * Fixing spinner position * Simplifying FileSize
This commit is contained in:
@@ -1,7 +1,3 @@
|
||||
/*
|
||||
Note: These styles are temporary. They will be replaced before going live.
|
||||
*/
|
||||
|
||||
.app {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
41
src/components/Options/FileSize.tsx
Normal file
41
src/components/Options/FileSize.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { h, Component } from 'preact';
|
||||
import * as prettyBytes from 'pretty-bytes';
|
||||
import * as style from './style.scss';
|
||||
|
||||
interface Props {
|
||||
blob: Blob;
|
||||
compareTo?: Blob;
|
||||
}
|
||||
|
||||
interface State {}
|
||||
|
||||
export default class FileSize extends Component<Props, State> {
|
||||
render({ blob, compareTo }: Props) {
|
||||
let comparison: JSX.Element | undefined;
|
||||
|
||||
if (compareTo) {
|
||||
const delta = blob.size / compareTo.size;
|
||||
if (delta > 1) {
|
||||
const percent = Math.round((delta - 1) * 100) + '%';
|
||||
comparison = (
|
||||
<span class={`${style.sizeDelta} ${style.sizeIncrease}`}>
|
||||
{percent === '0%' ? 'slightly' : percent} bigger
|
||||
</span>
|
||||
);
|
||||
} else if (delta < 1) {
|
||||
const percent = Math.round((1 - delta) * 100) + '%';
|
||||
comparison = (
|
||||
<span class={`${style.sizeDelta} ${style.sizeDecrease}`}>
|
||||
{percent === '0%' ? 'slightly' : percent} smaller
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
comparison = (
|
||||
<span class={style.sizeDelta}>no change</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return <span>{prettyBytes(blob.size)} {comparison}</span>;
|
||||
}
|
||||
}
|
||||
@@ -31,14 +31,17 @@ import {
|
||||
encoders,
|
||||
encodersSupported,
|
||||
EncoderSupportMap,
|
||||
encoderMap,
|
||||
} from '../../codecs/encoders';
|
||||
import { QuantizeOptions } from '../../codecs/imagequant/processor-meta';
|
||||
import { ResizeOptions } from '../../codecs/resize/processor-meta';
|
||||
import { PreprocessorState } from '../../codecs/preprocessors';
|
||||
import FileSize from '../FileSize';
|
||||
import FileSize from './FileSize';
|
||||
import { DownloadIcon } from '../../lib/icons';
|
||||
import { SourceImage } from '../App';
|
||||
import Checkbox from '../checkbox';
|
||||
import Expander from '../expander';
|
||||
import Select from '../select';
|
||||
import '../custom-els/LoadingSpinner';
|
||||
|
||||
const encoderOptionsComponentMap = {
|
||||
[identity.type]: undefined,
|
||||
@@ -56,13 +59,9 @@ const encoderOptionsComponentMap = {
|
||||
[browserPDF.type]: undefined,
|
||||
};
|
||||
|
||||
const titles = {
|
||||
horizontal: ['Left Image', 'Right Image'],
|
||||
vertical: ['Top Image', 'Bottom Image'],
|
||||
};
|
||||
|
||||
interface Props {
|
||||
orientation: 'horizontal' | 'vertical';
|
||||
loading: boolean;
|
||||
source?: SourceImage;
|
||||
imageIndex: number;
|
||||
imageFile?: Fileish;
|
||||
@@ -77,16 +76,39 @@ interface Props {
|
||||
|
||||
interface State {
|
||||
encoderSupportMap?: EncoderSupportMap;
|
||||
showLoadingState: boolean;
|
||||
}
|
||||
|
||||
const loadingReactionDelay = 500;
|
||||
|
||||
export default class Options extends Component<Props, State> {
|
||||
typeSelect?: HTMLSelectElement;
|
||||
state: State = {
|
||||
encoderSupportMap: undefined,
|
||||
showLoadingState: false,
|
||||
};
|
||||
|
||||
/** The timeout ID between entering the loading state, and changing UI */
|
||||
private loadingTimeoutId: number = 0;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
encodersSupported.then(encoderSupportMap => this.setState({ encoderSupportMap }));
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props, prevState: State) {
|
||||
if (prevProps.loading && !this.props.loading) {
|
||||
// Just stopped loading
|
||||
clearTimeout(this.loadingTimeoutId);
|
||||
this.setState({ showLoadingState: false });
|
||||
} else if (!prevProps.loading && this.props.loading) {
|
||||
// Just started loading
|
||||
this.loadingTimeoutId = self.setTimeout(
|
||||
() => this.setState({ showLoadingState: true }),
|
||||
loadingReactionDelay,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@bind
|
||||
onEncoderTypeChange(event: Event) {
|
||||
const el = event.currentTarget as HTMLSelectElement;
|
||||
@@ -138,105 +160,118 @@ export default class Options extends Component<Props, State> {
|
||||
preprocessorState,
|
||||
onEncoderOptionsChange,
|
||||
}: Props,
|
||||
{ encoderSupportMap }: State,
|
||||
{ encoderSupportMap, showLoadingState }: State,
|
||||
) {
|
||||
// tslint:disable variable-name
|
||||
const EncoderOptionComponent = encoderOptionsComponentMap[encoderState.type];
|
||||
|
||||
return (
|
||||
<div class={`${style.options} ${style[orientation]}`}>
|
||||
<h2 class={style.title}>
|
||||
{titles[orientation][imageIndex]}
|
||||
{', '}
|
||||
{encoderMap[encoderState.type].label}
|
||||
</h2>
|
||||
|
||||
<div class={style.content}>
|
||||
<section class={style.picker}>
|
||||
{encoderSupportMap ?
|
||||
<select value={encoderState.type} onChange={this.onEncoderTypeChange}>
|
||||
{encoders.filter(encoder => encoderSupportMap[encoder.type]).map(encoder => (
|
||||
<option value={encoder.type}>{encoder.label}</option>
|
||||
))}
|
||||
</select>
|
||||
:
|
||||
<select><option>Loading…</option></select>
|
||||
}
|
||||
</section>
|
||||
|
||||
{encoderState.type !== 'identity' && (
|
||||
<div key="preprocessors" class={style.preprocessors}>
|
||||
<label class={style.toggle}>
|
||||
<input
|
||||
<div class={style.options}>
|
||||
<Expander>
|
||||
{encoderState.type === identity.type ? null :
|
||||
<div>
|
||||
<h3 class={style.optionsTitle}>Edit</h3>
|
||||
<label class={style.sectionEnabler}>
|
||||
<Checkbox
|
||||
name="resize.enable"
|
||||
type="checkbox"
|
||||
checked={!!preprocessorState.resize.enabled}
|
||||
onChange={this.onPreprocessorEnabledChange}
|
||||
/>
|
||||
Resize
|
||||
</label>
|
||||
{preprocessorState.resize.enabled &&
|
||||
<ResizeOptionsComponent
|
||||
isVector={Boolean(source && source.vectorImage)}
|
||||
aspect={source ? (source.data.width / source.data.height) : 1}
|
||||
options={preprocessorState.resize}
|
||||
onChange={this.onResizeOptionsChange}
|
||||
/>
|
||||
}
|
||||
<label class={style.toggle}>
|
||||
<input
|
||||
<Expander>
|
||||
{preprocessorState.resize.enabled ?
|
||||
<ResizeOptionsComponent
|
||||
isVector={Boolean(source && source.vectorImage)}
|
||||
aspect={source ? (source.data.width / source.data.height) : 1}
|
||||
options={preprocessorState.resize}
|
||||
onChange={this.onResizeOptionsChange}
|
||||
/>
|
||||
: null}
|
||||
</Expander>
|
||||
<label class={style.sectionEnabler}>
|
||||
<Checkbox
|
||||
name="quantizer.enable"
|
||||
type="checkbox"
|
||||
checked={!!preprocessorState.quantizer.enabled}
|
||||
onChange={this.onPreprocessorEnabledChange}
|
||||
/>
|
||||
Quantize
|
||||
Reduce palette
|
||||
</label>
|
||||
{preprocessorState.quantizer.enabled &&
|
||||
<QuantizerOptionsComponent
|
||||
options={preprocessorState.quantizer}
|
||||
onChange={this.onQuantizerOptionsChange}
|
||||
/>
|
||||
}
|
||||
<Expander>
|
||||
{preprocessorState.quantizer.enabled ?
|
||||
<QuantizerOptionsComponent
|
||||
options={preprocessorState.quantizer}
|
||||
onChange={this.onQuantizerOptionsChange}
|
||||
/>
|
||||
: null}
|
||||
</Expander>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{EncoderOptionComponent &&
|
||||
<EncoderOptionComponent
|
||||
options={
|
||||
// Casting options, as encoderOptionsComponentMap[encodeData.type] ensures
|
||||
// the correct type, but typescript isn't smart enough.
|
||||
encoderState.options as any
|
||||
}
|
||||
onChange={onEncoderOptionsChange}
|
||||
/>
|
||||
}
|
||||
</Expander>
|
||||
|
||||
<h3 class={style.optionsTitle}>Compress</h3>
|
||||
|
||||
<div class={style.optionsScroller}>
|
||||
<section class={`${style.optionOneCell} ${style.optionsSection}`}>
|
||||
{encoderSupportMap ?
|
||||
<Select value={encoderState.type} onChange={this.onEncoderTypeChange} large>
|
||||
{encoders.filter(encoder => encoderSupportMap[encoder.type]).map(encoder => (
|
||||
<option value={encoder.type}>{encoder.label}</option>
|
||||
))}
|
||||
</Select>
|
||||
:
|
||||
<Select large><option>Loading…</option></Select>
|
||||
}
|
||||
</section>
|
||||
|
||||
<Expander>
|
||||
{EncoderOptionComponent ?
|
||||
<EncoderOptionComponent
|
||||
options={
|
||||
// Casting options, as encoderOptionsComponentMap[encodeData.type] ensures
|
||||
// the correct type, but typescript isn't smart enough.
|
||||
encoderState.options as any
|
||||
}
|
||||
onChange={onEncoderOptionsChange}
|
||||
/>
|
||||
: null}
|
||||
</Expander>
|
||||
</div>
|
||||
|
||||
<div class={style.row}>
|
||||
<button onClick={this.onCopyToOtherClick}>Copy settings to other side</button>
|
||||
<div class={style.optionsCopy}>
|
||||
<button onClick={this.onCopyToOtherClick} class={style.copyButton}>
|
||||
{imageIndex === 1 && '← '}
|
||||
Copy settings across
|
||||
{imageIndex === 0 && ' →'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class={style.sizeDetails}>
|
||||
<FileSize
|
||||
class={style.size}
|
||||
increaseClass={style.increase}
|
||||
decreaseClass={style.decrease}
|
||||
file={imageFile}
|
||||
compareTo={(source && imageFile !== source.file) ? source.file : undefined}
|
||||
/>
|
||||
<div class={style.results}>
|
||||
<div class={style.resultData}>
|
||||
{!imageFile || showLoadingState ? 'Working…' :
|
||||
<FileSize
|
||||
blob={imageFile}
|
||||
compareTo={(source && imageFile !== source.file) ? source.file : undefined}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class={style.download}>
|
||||
{(downloadUrl && imageFile) && (
|
||||
<a
|
||||
class={`${style.downloadLink} ${showLoadingState ? style.downloadLinkDisable : ''}`}
|
||||
href={downloadUrl}
|
||||
download={imageFile.name}
|
||||
title="Download"
|
||||
>
|
||||
<DownloadIcon class={style.downloadIcon} />
|
||||
</a>
|
||||
)}
|
||||
{showLoadingState && <loading-spinner class={style.spinner} />}
|
||||
</div>
|
||||
|
||||
{(downloadUrl && imageFile) && (
|
||||
<a
|
||||
class={style.download}
|
||||
href={downloadUrl}
|
||||
download={imageFile.name}
|
||||
title="Download"
|
||||
>
|
||||
<DownloadIcon />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,225 +1,166 @@
|
||||
/*
|
||||
Note: These styles are temporary. They will be replaced before going live.
|
||||
*/
|
||||
|
||||
.row {
|
||||
padding: 5px;
|
||||
margin: 0 10px;
|
||||
}
|
||||
$horizontalPadding: 15px;
|
||||
|
||||
.options {
|
||||
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;
|
||||
color: #fff;
|
||||
width: 300px;
|
||||
opacity: 0.9;
|
||||
transform-origin: 50% 140%;
|
||||
transition: opacity 300ms linear;
|
||||
animation: options-open 500ms cubic-bezier(.6,1.6,.6,1) forwards 1;
|
||||
|
||||
&.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);
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
max-height: calc(75vh - 100px);
|
||||
overflow: auto;
|
||||
touch-action: pan-y;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.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;
|
||||
transition: box-shadow 150ms ease;
|
||||
|
||||
&: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;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
padding: 5px;
|
||||
margin: 0 10px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
// prevent labels from wrapping below checkboxes
|
||||
> span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
input[type=checkbox],
|
||||
input[type=radio] {
|
||||
flex: 0;
|
||||
margin: 2px 8px 0 0;
|
||||
}
|
||||
|
||||
range-input {
|
||||
display: block;
|
||||
flex: 1 0 100%;
|
||||
margin: 2px 0;
|
||||
}
|
||||
}
|
||||
|
||||
hr {
|
||||
height: 1px;
|
||||
border: none;
|
||||
margin: 5px 0;
|
||||
box-shadow: inset 0 0.5px 0 rgba(0, 0, 0, 0.4), inset 0 -0.5px 0 rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
font-size: 1.2rem;
|
||||
max-height: 100%;
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
hr {
|
||||
height: 1px;
|
||||
border: none;
|
||||
margin: 5px 0;
|
||||
box-shadow: inset 0 0.5px 0 rgba(0, 0, 0, 0.4), inset 0 -0.5px 0 rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
.options-title {
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
margin: 0;
|
||||
padding: 10px $horizontalPadding;
|
||||
font-weight: normal;
|
||||
font-size: 1.4rem;
|
||||
border-bottom: 1px solid #000;
|
||||
}
|
||||
|
||||
.size-details {
|
||||
.option-text-first {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
grid-template-columns: 87px 1fr;
|
||||
grid-gap: 0.7em;
|
||||
padding: 10px $horizontalPadding;
|
||||
}
|
||||
|
||||
.option-one-cell {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
padding: 10px $horizontalPadding;
|
||||
}
|
||||
|
||||
.option-input-first,
|
||||
.section-enabler {
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
align-items: center;
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-gap: 0.7em;
|
||||
padding: 10px $horizontalPadding;
|
||||
}
|
||||
|
||||
.section-enabler {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.options-section {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.text-field {
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
border: none;
|
||||
padding: 2px 0 2px 10px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.options-scroller {
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.results {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.result-data {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 5px 15px;
|
||||
background: rgba(0,0,0,0.5);
|
||||
padding: 0 $horizontalPadding;
|
||||
}
|
||||
|
||||
.size-delta {
|
||||
font-size: 1.1rem;
|
||||
font-style: italic;
|
||||
position: relative;
|
||||
top: -1px;
|
||||
margin-left: 0.3em;
|
||||
}
|
||||
|
||||
.size-increase {
|
||||
color: #e35050;
|
||||
}
|
||||
|
||||
.size-decrease {
|
||||
color: #50e3c2;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@keyframes action-enter {
|
||||
from {
|
||||
transform: rotate(-90deg);
|
||||
opacity: 0;
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes action-leave {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
opacity: 1;
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
.download {
|
||||
flex: 0;
|
||||
margin: 0 0 0 auto;
|
||||
background: rgba(255,255,255,0.1);
|
||||
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);
|
||||
}
|
||||
background: #34B9EB;
|
||||
--size: 38px;
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
display: grid;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.size-details {
|
||||
padding: 5px 15px;
|
||||
background: rgba(0,0,0,0.5);
|
||||
.download-link {
|
||||
animation: action-enter 0.2s;
|
||||
grid-area: 1/1;
|
||||
}
|
||||
|
||||
.size {
|
||||
font-weight: normal;
|
||||
.download-link-disable {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: rotate(90deg);
|
||||
animation: action-leave 0.2s;
|
||||
}
|
||||
|
||||
.increase,
|
||||
.decrease {
|
||||
font-style: italic;
|
||||
filter: #{"grayscale(calc(50% - var(--size-delta, 50) * 0.5%))"};
|
||||
|
||||
&:before {
|
||||
content: ' (';
|
||||
}
|
||||
&:after {
|
||||
content: ')';
|
||||
}
|
||||
.download-icon {
|
||||
color: #fff;
|
||||
display: block;
|
||||
--size: 24px;
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
padding: 7px;
|
||||
filter: drop-shadow(0 1px 0 rgba(0, 0, 0, 0.7));
|
||||
}
|
||||
|
||||
.increase {
|
||||
color: var(--negative);
|
||||
}
|
||||
.decrease {
|
||||
color: var(--positive);
|
||||
}
|
||||
|
||||
.preprocessors {
|
||||
padding: 5px 0;
|
||||
margin: 5px 0;
|
||||
box-shadow: inset 0 -.5px 0 rgba(0,0,0,0.4), 0 .5px 0 rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: flex;
|
||||
position: relative;
|
||||
align-content: center;
|
||||
font-size: 14px;
|
||||
box-shadow: inset 0 -.5px 0 rgba(0,0,0,0.4), 0 .5px 0 rgba(255,255,255,0.2);
|
||||
.spinner {
|
||||
--color: #fff;
|
||||
--delay: 0;
|
||||
--size: 22px;
|
||||
grid-area: 1/1;
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ two-up[legacy-clip-compat] > :not(.two-up-handle) {
|
||||
height: calc(var(--thumb-size) * 0.9);
|
||||
background: var(--thumb-background);
|
||||
border: 1px solid rgba(0,0,0,0.2);
|
||||
border-radius: calc(var(--thumb-size) * 0.08);
|
||||
border-radius: var(--thumb-size);
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
|
||||
color: var(--thumb-color);
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -47,10 +47,10 @@ Note: These styles are temporary. They will be replaced before going live.
|
||||
flex-wrap: wrap;
|
||||
contain: content;
|
||||
|
||||
@media (min-width: 680px) {
|
||||
@media (min-width: 860px) {
|
||||
top: auto;
|
||||
left: 220px;
|
||||
right: 220px;
|
||||
left: 320px;
|
||||
right: 320px;
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
20
src/components/checkbox/index.tsx
Normal file
20
src/components/checkbox/index.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { h, Component } from 'preact';
|
||||
import * as style from './style.scss';
|
||||
import { UncheckedIcon, CheckedIcon } from '../../lib/icons';
|
||||
|
||||
interface Props extends JSX.HTMLAttributes {}
|
||||
interface State {}
|
||||
|
||||
export default class Checkbox extends Component<Props, State> {
|
||||
render(props: Props) {
|
||||
return (
|
||||
<div class={style.checkbox}>
|
||||
{props.checked
|
||||
? <CheckedIcon class={`${style.icon} ${style.checked}`} />
|
||||
: <UncheckedIcon class={style.icon} />
|
||||
}
|
||||
<input class={style.realCheckbox} type="checkbox" {...props}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
22
src/components/checkbox/style.scss
Normal file
22
src/components/checkbox/style.scss
Normal file
@@ -0,0 +1,22 @@
|
||||
.checkbox {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
--size: 17px;
|
||||
}
|
||||
|
||||
.real-checkbox {
|
||||
top: 0;
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: block;
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
}
|
||||
|
||||
.checked {
|
||||
fill: #34B9EB;
|
||||
}
|
||||
@@ -65,7 +65,9 @@ interface Props {
|
||||
interface State {
|
||||
source?: SourceImage;
|
||||
images: [EncodedImage, EncodedImage];
|
||||
/** Source image load */
|
||||
loading: boolean;
|
||||
loadingCounter: number;
|
||||
error?: string;
|
||||
orientation: Orientation;
|
||||
}
|
||||
@@ -159,6 +161,7 @@ export default class Compress extends Component<Props, State> {
|
||||
state: State = {
|
||||
source: undefined,
|
||||
loading: false,
|
||||
loadingCounter: 0,
|
||||
images: [
|
||||
{
|
||||
preprocessorState: defaultPreprocessorState,
|
||||
@@ -252,7 +255,9 @@ export default class Compress extends Component<Props, State> {
|
||||
|
||||
@bind
|
||||
private async updateFile(file: File | Fileish) {
|
||||
this.setState({ loading: true });
|
||||
const loadingCounter = this.state.loadingCounter + 1;
|
||||
|
||||
this.setState({ loadingCounter, loading: true });
|
||||
|
||||
// Abort any current encode jobs, as they're redundant now.
|
||||
this.leftProcessor.abortCurrent();
|
||||
@@ -273,6 +278,9 @@ export default class Compress extends Component<Props, State> {
|
||||
data = await decodeImage(file, this.leftProcessor);
|
||||
}
|
||||
|
||||
// Another file has been opened before this one processed.
|
||||
if (this.state.loadingCounter !== loadingCounter) return;
|
||||
|
||||
let newState: State = {
|
||||
...this.state,
|
||||
source: { data, file, vectorImage },
|
||||
@@ -303,6 +311,8 @@ export default class Compress extends Component<Props, State> {
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') return;
|
||||
console.error(err);
|
||||
// Another file has been opened before this one processed.
|
||||
if (this.state.loadingCounter !== loadingCounter) return;
|
||||
this.props.onError('Invalid image');
|
||||
this.setState({ loading: false });
|
||||
}
|
||||
@@ -388,10 +398,9 @@ export default class Compress extends Component<Props, State> {
|
||||
render({ }: Props, { loading, images, source, orientation }: State) {
|
||||
const [leftImage, rightImage] = images;
|
||||
const [leftImageData, rightImageData] = images.map(i => i.data);
|
||||
const anyLoading = loading || images.some(image => image.loading);
|
||||
|
||||
return (
|
||||
<div class={style.compress}>
|
||||
<div class={`${style.compress} ${style[orientation]}`}>
|
||||
<Output
|
||||
originalImage={source && source.data}
|
||||
orientation={orientation}
|
||||
@@ -400,24 +409,22 @@ export default class Compress extends Component<Props, State> {
|
||||
leftImgContain={leftImage.preprocessorState.resize.fitMethod === 'cover'}
|
||||
rightImgContain={rightImage.preprocessorState.resize.fitMethod === 'cover'}
|
||||
/>
|
||||
<div class={`${style.optionPair} ${style[orientation]}`}>
|
||||
{images.map((image, index) => (
|
||||
<Options
|
||||
source={source}
|
||||
orientation={orientation}
|
||||
imageIndex={index}
|
||||
imageFile={image.file}
|
||||
downloadUrl={image.downloadUrl}
|
||||
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)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{anyLoading && <span style={{ position: 'fixed', top: 0, left: 0 }}>Loading...</span>}
|
||||
{images.map((image, index) => (
|
||||
<Options
|
||||
loading={loading || image.loading}
|
||||
source={source}
|
||||
orientation={orientation}
|
||||
imageIndex={index}
|
||||
imageFile={image.file}
|
||||
downloadUrl={image.downloadUrl}
|
||||
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)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
.compress {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.option-pair {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
contain: strict;
|
||||
display: grid;
|
||||
|
||||
&.horizontal {
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-rows: calc(100% - 75px);
|
||||
|
||||
@media (min-width: 860px) {
|
||||
grid-template-rows: 100%;
|
||||
}
|
||||
align-items: end;
|
||||
align-content: end;
|
||||
}
|
||||
|
||||
&.vertical {
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
// TODO: make the mobile view work
|
||||
background: red;
|
||||
}
|
||||
}
|
||||
|
||||
92
src/components/expander/index.tsx
Normal file
92
src/components/expander/index.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { h, Component, ComponentChild, ComponentChildren } from 'preact';
|
||||
import * as style from './style.scss';
|
||||
import { linkRef } from '../../lib/initial-util';
|
||||
|
||||
interface Props {
|
||||
children: ComponentChildren;
|
||||
}
|
||||
interface State {
|
||||
outgoingChildren: ComponentChild[];
|
||||
}
|
||||
|
||||
export default class Expander extends Component<Props, State> {
|
||||
state: State = {
|
||||
outgoingChildren: [],
|
||||
};
|
||||
private el?: HTMLDivElement;
|
||||
private lastElHeight: number = 0;
|
||||
|
||||
componentWillReceiveProps(nextProps: Props) {
|
||||
const children = this.props.children as ComponentChild[];
|
||||
const nextChildren = nextProps.children as ComponentChild[];
|
||||
|
||||
if (!nextChildren[0] && children[0]) {
|
||||
// Cache the current children for the shrink animation.
|
||||
this.setState({ outgoingChildren: children });
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUpdate(nextProps: Props) {
|
||||
const children = this.props.children as ComponentChild[];
|
||||
const nextChildren = nextProps.children as ComponentChild[];
|
||||
|
||||
// Only interested if going from empty to not-empty, or not-empty to empty.
|
||||
if ((children[0] && nextChildren[0]) || (!children[0] && !nextChildren[0])) return;
|
||||
this.lastElHeight = this.el!.getBoundingClientRect().height;
|
||||
}
|
||||
|
||||
componentDidUpdate(previousProps: Props) {
|
||||
const children = this.props.children as ComponentChild[];
|
||||
const previousChildren = previousProps.children as ComponentChild[];
|
||||
|
||||
// Only interested if going from empty to not-empty, or not-empty to empty.
|
||||
if ((children[0] && previousChildren[0]) || (!children[0] && !previousChildren[0])) return;
|
||||
|
||||
// What height do we need to transition to?
|
||||
this.el!.style.transition = 'none';
|
||||
this.el!.style.height = '';
|
||||
const newHeight = children[0] ? this.el!.getBoundingClientRect().height : 0;
|
||||
|
||||
if (this.lastElHeight === newHeight) {
|
||||
this.el!.style.transition = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the currently rendered height absolutely.
|
||||
this.el!.style.height = this.lastElHeight + 'px';
|
||||
this.el!.style.transition = '';
|
||||
this.el!.style.overflow = 'hidden';
|
||||
// Force a style calc so the browser picks up the start value.
|
||||
getComputedStyle(this.el!).height;
|
||||
// Animate to the new height.
|
||||
this.el!.style.height = newHeight + 'px';
|
||||
|
||||
const listener = () => {
|
||||
// Unset the height & overflow, so element changes do the right thing.
|
||||
this.el!.style.height = '';
|
||||
this.el!.style.overflow = '';
|
||||
this.el!.removeEventListener('transitionend', listener);
|
||||
this.el!.removeEventListener('transitioncancel', listener);
|
||||
if (this.state.outgoingChildren[0]) {
|
||||
this.setState({ outgoingChildren: [] });
|
||||
}
|
||||
};
|
||||
|
||||
this.el!.addEventListener('transitionend', listener);
|
||||
this.el!.addEventListener('transitioncancel', listener);
|
||||
}
|
||||
|
||||
render(props: Props, { outgoingChildren }: State) {
|
||||
const children = props.children as ComponentChild[];
|
||||
const childrenExiting = !children[0] && outgoingChildren[0];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={linkRef(this, 'el')}
|
||||
class={`${style.expander} ${childrenExiting ? style.childrenExiting : ''}`}
|
||||
>
|
||||
{children[0] ? children : outgoingChildren}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
9
src/components/expander/style.scss
Normal file
9
src/components/expander/style.scss
Normal file
@@ -0,0 +1,9 @@
|
||||
.expander {
|
||||
transition: height 200ms ease-in-out;
|
||||
}
|
||||
|
||||
.children-exiting {
|
||||
& > * {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
55
src/components/range/index.tsx
Normal file
55
src/components/range/index.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { h, Component } from 'preact';
|
||||
import * as style from './style.scss';
|
||||
import RangeInputElement from '../../custom-els/RangeInput';
|
||||
import '../../custom-els/RangeInput';
|
||||
import { linkRef, bind } from '../../lib/initial-util';
|
||||
|
||||
interface Props extends JSX.HTMLAttributes {}
|
||||
interface State {}
|
||||
|
||||
export default class Range extends Component<Props, State> {
|
||||
rangeWc?: RangeInputElement;
|
||||
|
||||
@bind
|
||||
private onTextInput(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
this.rangeWc!.value = input.value;
|
||||
const { onInput } = this.props;
|
||||
if (onInput) onInput(event);
|
||||
}
|
||||
|
||||
render(props: Props) {
|
||||
const {
|
||||
children,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
const {
|
||||
value, min, max, step,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<label class={style.range}>
|
||||
<span class={style.labelText}>{children}</span>
|
||||
{/* On interaction, Safari gives focus to the first element in the label, so the
|
||||
<range-input> is deliberately first. */}
|
||||
<div class={style.rangeWcContainer}>
|
||||
<range-input
|
||||
ref={linkRef(this, 'rangeWc')}
|
||||
class={style.rangeWc}
|
||||
{...otherProps}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
class={style.textInput}
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
onInput={this.onTextInput}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
}
|
||||
55
src/components/range/style.scss
Normal file
55
src/components/range/style.scss
Normal file
@@ -0,0 +1,55 @@
|
||||
.range {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
.label-text {
|
||||
color: #fff; /* TEMP */
|
||||
}
|
||||
|
||||
.range-wc-container {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
grid-row: 2 / 3;
|
||||
grid-column: 1 / 3;
|
||||
}
|
||||
|
||||
.range-wc {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
grid-row: 1 / 2;
|
||||
grid-column: 2 / 3;
|
||||
|
||||
text-align: right;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
border: none;
|
||||
padding: 2px 5px;
|
||||
box-sizing: border-box;
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
text-underline-position: under;
|
||||
width: 48px;
|
||||
position: relative;
|
||||
left: 5px;
|
||||
|
||||
&:focus {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
// Remove the number controls
|
||||
-moz-appearance: textfield;
|
||||
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
-moz-appearance: none;
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
20
src/components/select/index.tsx
Normal file
20
src/components/select/index.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { h, Component } from 'preact';
|
||||
import * as style from './style.scss';
|
||||
|
||||
interface Props extends JSX.HTMLAttributes {
|
||||
large?: boolean;
|
||||
}
|
||||
interface State {}
|
||||
|
||||
export default class Select extends Component<Props, State> {
|
||||
render(props: Props) {
|
||||
const { large, ...otherProps } = props;
|
||||
|
||||
return (
|
||||
<div class={style.select}>
|
||||
<select class={`${style.nativeSelect} ${large ? style.large : ''}`} {...otherProps}/>
|
||||
<svg class={style.arrow} viewBox="0 0 10 5"><path d="M0 0l5 5 5-5z"/></svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
33
src/components/select/style.scss
Normal file
33
src/components/select/style.scss
Normal file
@@ -0,0 +1,33 @@
|
||||
.select {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.native-select {
|
||||
background: #2f2f2f;
|
||||
border-radius: 4px;
|
||||
font: inherit;
|
||||
padding: 4px 25px 4px 10px;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
border: none;
|
||||
color: #fff;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
fill: #fff;
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
.large {
|
||||
padding: 10px 35px 10px 10px;
|
||||
background: #151515;
|
||||
|
||||
& .arrow {
|
||||
right: 13px;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user