Rollup build

This commit is contained in:
Jake Archibald
2020-11-19 11:00:23 +00:00
parent dfee848a39
commit 56e10b3aa2
340 changed files with 37866 additions and 19153 deletions

View File

@@ -0,0 +1,23 @@
import { h, Component } from 'preact';
import * as style from './style.css';
import 'add-css:./style.css';
import { UncheckedIcon, CheckedIcon } from '../../../icons';
interface Props extends preact.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} />
)}
{/* @ts-ignore - TS bug https://github.com/microsoft/TypeScript/issues/16019 */}
<input class={style.realCheckbox} type="checkbox" {...props} />
</div>
);
}
}

View 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;
}

View File

@@ -0,0 +1,66 @@
import { h, Component, ComponentChild, ComponentChildren } from 'preact';
import * as style from './style.css';
import 'add-css:./style.css';
import { transitionHeight } from '../../../util';
interface Props {
children: ComponentChildren;
}
interface State {
children: ComponentChildren;
outgoingChildren: ComponentChildren;
}
export default class Expander extends Component<Props, State> {
static getDerivedStateFromProps(
props: Props,
state: State,
): Partial<State> | null {
if (!props.children && state.children) {
return { children: props.children, outgoingChildren: state.children };
}
if (props.children !== state.children) {
return { children: props.children, outgoingChildren: undefined };
}
return null;
}
async componentDidUpdate(_: Props, previousState: State) {
let heightFrom: number;
let heightTo: number;
if (previousState.children && !this.state.children) {
heightFrom = (this.base as HTMLElement).getBoundingClientRect().height;
heightTo = 0;
} else if (!previousState.children && this.state.children) {
heightFrom = 0;
heightTo = (this.base as HTMLElement).getBoundingClientRect().height;
} else {
return;
}
(this.base as HTMLElement).style.overflow = 'hidden';
await transitionHeight(this.base as HTMLElement, {
duration: 300,
from: heightFrom,
to: heightTo,
});
// Unset the height & overflow, so element changes do the right thing.
(this.base as HTMLElement).style.height = '';
(this.base as HTMLElement).style.overflow = '';
this.setState({ outgoingChildren: undefined });
}
render({}: Props, { children, outgoingChildren }: State) {
return (
<div class={outgoingChildren ? style.childrenExiting : ''}>
{outgoingChildren || children}
</div>
);
}
}

View File

@@ -0,0 +1,5 @@
.children-exiting {
& > * {
pointer-events: none;
}
}

View File

@@ -0,0 +1,167 @@
import PointerTracker from 'pointer-tracker';
import * as style from './style.css';
import 'add-css:./style.css';
const RETARGETED_EVENTS = ['focus', 'blur'];
const UPDATE_EVENTS = ['input', 'change'];
const REFLECTED_PROPERTIES = [
'name',
'min',
'max',
'step',
'value',
'disabled',
];
const REFLECTED_ATTRIBUTES = [
'name',
'min',
'max',
'step',
'value',
'disabled',
];
function getPrescision(value: string): number {
const afterDecimal = value.split('.')[1];
return afterDecimal ? afterDecimal.length : 0;
}
class RangeInputElement extends HTMLElement {
private _input: HTMLInputElement;
private _valueDisplay?: HTMLDivElement;
private _ignoreChange = false;
static get observedAttributes() {
return REFLECTED_ATTRIBUTES;
}
constructor() {
super();
this._input = document.createElement('input');
this._input.type = 'range';
this._input.className = style.input;
const tracker = new PointerTracker(this._input, {
start: (): boolean => {
if (tracker.currentPointers.length !== 0) return false;
this._input.classList.add(style.touchActive);
return true;
},
end: () => {
this._input.classList.remove(style.touchActive);
},
});
for (const event of RETARGETED_EVENTS) {
this._input.addEventListener(event, this._retargetEvent, true);
}
for (const event of UPDATE_EVENTS) {
this._input.addEventListener(event, this._update, true);
}
}
connectedCallback() {
if (this.contains(this._input)) return;
this.innerHTML =
`<div class="${style.thumbWrapper}">` +
`<div class="${style.thumb}"></div>` +
`<div class="${style.valueDisplay}"></div>` +
'</div>';
this.insertBefore(this._input, this.firstChild);
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 {
return this.getAttribute('label-precision') || '';
}
set labelPrecision(precision: string) {
this.setAttribute('label-precision', precision);
}
attributeChangedCallback(
name: string,
oldValue: string,
newValue: string | null,
) {
if (this._ignoreChange) return;
if (newValue === null) {
this._input.removeAttribute(name);
} else {
this._input.setAttribute(name, newValue);
}
this._reflectAttributes();
this._update();
}
private _retargetEvent = (event: Event) => {
event.stopImmediatePropagation();
const retargetted = new Event(event.type, event);
this.dispatchEvent(retargetted);
};
private _update = () => {
// Not connected?
if (!this._valueDisplay) return;
const value = Number(this.value) || 0;
const min = Number(this.min) || 0;
const max = Number(this.max) || 100;
const labelPrecision =
Number(this.labelPrecision) || getPrescision(this.step) || 0;
const percent = (100 * (value - min)) / (max - min);
const displayValue = labelPrecision
? value.toFixed(labelPrecision)
: Math.round(value).toString();
this._valueDisplay!.textContent = displayValue;
this.style.setProperty('--value-percent', percent + '%');
this.style.setProperty('--value-width', '' + displayValue.length);
};
private _reflectAttributes() {
this._ignoreChange = true;
for (const attributeName of REFLECTED_ATTRIBUTES) {
if (this._input.hasAttribute(attributeName)) {
this.setAttribute(
attributeName,
this._input.getAttribute(attributeName)!,
);
} else {
this.removeAttribute(attributeName);
}
}
this._ignoreChange = false;
}
}
interface RangeInputElement {
name: string;
min: string;
max: string;
step: string;
value: string;
disabled: boolean;
}
for (const prop of REFLECTED_PROPERTIES) {
Object.defineProperty(RangeInputElement.prototype, prop, {
get() {
return this._input[prop];
},
set(val) {
this._input[prop] = val;
this._reflectAttributes();
this._update();
},
});
}
export default RangeInputElement;
customElements.define('range-input', RangeInputElement);

View File

@@ -0,0 +1,9 @@
declare module 'preact' {
namespace createElement.JSX {
interface IntrinsicElements {
'range-input': HTMLAttributes;
}
}
}
export {};

View File

@@ -0,0 +1,98 @@
range-input {
position: relative;
display: flex;
height: 18px;
width: 130px;
margin: 2px;
font: inherit;
line-height: 16px;
overflow: visible;
}
/* Disabled inputs are greyed out */
range-input[disabled] {
filter: grayscale(1);
}
range-input::before {
content: '';
display: block;
position: absolute;
top: 8px;
left: 0;
width: 100%;
height: 2px;
border-radius: 1px;
box-shadow: 0 -0.5px 0 rgba(0, 0, 0, 0.3),
inset 0 0.5px 0 rgba(255, 255, 255, 0.2), 0 0.5px 0 rgba(255, 255, 255, 0.3);
background: linear-gradient(#34b9eb, #218ab1) 0 / var(--value-percent, 0%)
100% no-repeat #eee;
}
.input {
position: relative;
width: 100%;
padding: 0;
margin: 0;
opacity: 0;
}
.thumb {
pointer-events: none;
position: absolute;
bottom: 3px;
left: var(--value-percent, 0%);
margin-left: -6px;
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><circle cx="5" cy="5" r="1" fill="%235D509E" /></svg>')
center no-repeat #34b9eb;
border-radius: 50%;
width: 12px;
height: 12px;
box-shadow: 0 0.5px 2px rgba(0, 0, 0, 0.3);
}
.thumb-wrapper {
position: absolute;
left: 6px;
right: 6px;
bottom: 0;
height: 0;
overflow: visible;
}
.value-display {
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="32" height="62" fill="none"><path fill="%2334B9EB" d="M27.3 27.3C25 29.6 17 35.8 17 43v3c0 3 2.5 5 3.2 5.8a6 6 0 1 1-8.5 0C12.6 51 15 49 15 46v-3c0-7.2-8-13.4-10.3-15.7A16 16 0 0 1 16 0a16 16 0 0 1 11.3 27.3z"/><circle cx="16" cy="56" r="1" fill="%235D509E"/></svg>')
top center no-repeat;
position: absolute;
box-sizing: border-box;
left: var(--value-percent, 0%);
bottom: 3px;
width: 32px;
height: 62px;
text-align: center;
padding: 8px 3px 0;
margin: 0 0 0 -16px;
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.3));
transform-origin: 50% 90%;
opacity: 0.0001;
transform: scale(0.2);
color: #fff;
font: inherit;
font-size: calc(100% - var(--value-width, 3) / 5 * 0.2em);
text-overflow: clip;
text-shadow: 0 -0.5px 0 rgba(0, 0, 0, 0.4);
transition: all 200ms ease;
transition-property: opacity, transform;
will-change: transform;
pointer-events: none;
overflow: hidden;
}
.touch-active + .thumb-wrapper .value-display {
opacity: 1;
transform: scale(1);
}
.touch-active + .thumb-wrapper .thumb {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
}

View File

@@ -0,0 +1,55 @@
import { h, Component } from 'preact';
import * as style from './style.css';
import 'add-css:./style.css';
import RangeInputElement from './custom-els/RangeInput';
import './custom-els/RangeInput';
import { linkRef } from 'shared/initial-app/util';
interface Props extends preact.JSX.HTMLAttributes {}
interface State {}
export default class Range extends Component<Props, State> {
rangeWc?: RangeInputElement;
private onTextInput = (event: Event) => {
const input = event.target as HTMLInputElement;
const value = input.value.trim();
if (!value) return;
this.rangeWc!.value = input.value;
this.rangeWc!.dispatchEvent(
new InputEvent('input', {
bubbles: event.bubbles,
}),
);
};
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>
);
}
}

View 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;
}
}

View File

@@ -0,0 +1,27 @@
import { h, Component } from 'preact';
import * as style from './style.css';
import 'add-css:./style.css';
interface Props extends preact.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}>
{/* @ts-ignore - TS bug https://github.com/microsoft/TypeScript/issues/16019 */}
<select
class={`${style.builtinSelect} ${large ? style.large : ''}`}
{...otherProps}
/>
<svg class={style.arrow} viewBox="0 0 10 5">
<path d="M0 0l5 5 5-5z" />
</svg>
</div>
);
}
}

View File

@@ -0,0 +1,33 @@
.select {
position: relative;
}
.builtin-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;
}
}

View File

@@ -0,0 +1,190 @@
import { h, Component } from 'preact';
import * as style from './style.css';
import 'add-css:./style.css';
import { cleanSet, cleanMerge } from '../../util/clean-modify';
import type { SourceImage, OutputType } from '..';
import {
EncoderOptions,
EncoderState,
ProcessorState,
ProcessorOptions,
encoderMap,
} from '../../feature-meta';
import Expander from './Expander';
import Checkbox from './Checkbox';
import Select from './Select';
import { Options as QuantOptionsComponent } from 'features/processors/quantize/client';
import { Options as ResizeOptionsComponent } from 'features/processors/resize/client';
interface Props {
mobileView: boolean;
source?: SourceImage;
encoderState?: EncoderState;
processorState: ProcessorState;
onEncoderTypeChange(newType: OutputType): void;
onEncoderOptionsChange(newOptions: EncoderOptions): void;
onProcessorOptionsChange(newOptions: ProcessorState): void;
}
interface State {
supportedEncoderMap?: PartialButNotUndefined<typeof encoderMap>;
}
type PartialButNotUndefined<T> = {
[P in keyof T]: T[P];
};
const supportedEncoderMapP: Promise<PartialButNotUndefined<
typeof encoderMap
>> = (async () => {
const supportedEncoderMap: PartialButNotUndefined<typeof encoderMap> = {
...encoderMap,
};
// Filter out entries where the feature test fails
await Promise.all(
Object.entries(encoderMap).map(async ([encoderName, details]) => {
if ('featureTest' in details && !(await details.featureTest())) {
delete supportedEncoderMap[encoderName as keyof typeof encoderMap];
}
}),
);
return supportedEncoderMap;
})();
export default class Options extends Component<Props, State> {
state: State = {
supportedEncoderMap: undefined,
};
constructor() {
super();
supportedEncoderMapP.then((supportedEncoderMap) =>
this.setState({ supportedEncoderMap }),
);
}
private onEncoderTypeChange = (event: Event) => {
const el = event.currentTarget as HTMLSelectElement;
// The select element only has values matching encoder types,
// so 'as' is safe here.
const type = el.value as OutputType;
this.props.onEncoderTypeChange(type);
};
private onProcessorEnabledChange = (event: Event) => {
const el = event.currentTarget as HTMLInputElement;
const processor = el.name.split('.')[0] as keyof ProcessorState;
this.props.onProcessorOptionsChange(
cleanSet(this.props.processorState, `${processor}.enabled`, el.checked),
);
};
private onQuantizerOptionsChange = (opts: ProcessorOptions['quantize']) => {
this.props.onProcessorOptionsChange(
cleanMerge(this.props.processorState, 'quantize', opts),
);
};
private onResizeOptionsChange = (opts: ProcessorOptions['resize']) => {
this.props.onProcessorOptionsChange(
cleanMerge(this.props.processorState, 'resize', opts),
);
};
render(
{ source, encoderState, processorState, onEncoderOptionsChange }: Props,
{ supportedEncoderMap }: State,
) {
const encoder = encoderState && encoderMap[encoderState.type];
const EncoderOptionComponent =
encoder && 'Options' in encoder ? encoder.Options : undefined;
return (
<div class={style.optionsScroller}>
<Expander>
{!encoderState ? null : (
<div>
<h3 class={style.optionsTitle}>Edit</h3>
<label class={style.sectionEnabler}>
<Checkbox
name="resize.enable"
checked={!!processorState.resize.enabled}
onChange={this.onProcessorEnabledChange}
/>
Resize
</label>
<Expander>
{processorState.resize.enabled ? (
<ResizeOptionsComponent
isVector={Boolean(source && source.vectorImage)}
inputWidth={source ? source.preprocessed.width : 1}
inputHeight={source ? source.preprocessed.height : 1}
options={processorState.resize}
onChange={this.onResizeOptionsChange}
/>
) : null}
</Expander>
<label class={style.sectionEnabler}>
<Checkbox
name="quantize.enable"
checked={!!processorState.quantize.enabled}
onChange={this.onProcessorEnabledChange}
/>
Reduce palette
</label>
<Expander>
{processorState.quantize.enabled ? (
<QuantOptionsComponent
options={processorState.quantize}
onChange={this.onQuantizerOptionsChange}
/>
) : null}
</Expander>
</div>
)}
</Expander>
<h3 class={style.optionsTitle}>Compress</h3>
<section class={`${style.optionOneCell} ${style.optionsSection}`}>
{supportedEncoderMap ? (
<Select
value={encoderState ? encoderState.type : 'identity'}
onChange={this.onEncoderTypeChange}
large
>
<option value="identity">Original Image</option>
{Object.entries(supportedEncoderMap).map(([type, encoder]) => (
<option value={type}>{encoder.meta.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}
/>
)}
</Expander>
</div>
);
}
}

View File

@@ -0,0 +1,58 @@
.options-scroller {
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
--horizontal-padding: 15px;
}
.options-title {
background: rgba(0, 0, 0, 0.9);
margin: 0;
padding: 10px var(--horizontal-padding);
font-weight: normal;
font-size: 1.4rem;
border-bottom: 1px solid #000;
}
.option-text-first {
display: grid;
align-items: center;
grid-template-columns: 87px 1fr;
grid-gap: 0.7em;
padding: 10px var(--horizontal-padding);
}
.option-one-cell {
display: grid;
grid-template-columns: 1fr;
padding: 10px var(--horizontal-padding);
}
.option-input-first,
.section-enabler {
cursor: pointer;
display: grid;
align-items: center;
grid-template-columns: auto 1fr;
grid-gap: 0.7em;
padding: 10px var(--horizontal-padding);
}
.section-enabler {
background: rgba(0, 0, 0, 0.8);
}
.options-section {
background: rgba(0, 0, 0, 0.7);
}
.text-field {
background: #fff;
color: #000;
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);
}

View File

@@ -0,0 +1,376 @@
import PointerTracker, { Pointer } from 'pointer-tracker';
import 'add-css:./styles.css';
interface Point {
clientX: number;
clientY: number;
}
interface ChangeOptions {
/**
* Fire a 'change' event if values are different to current values
*/
allowChangeEvent?: boolean;
}
interface ApplyChangeOpts extends ChangeOptions {
panX?: number;
panY?: number;
scaleDiff?: number;
originX?: number;
originY?: number;
}
interface SetTransformOpts extends ChangeOptions {
scale?: number;
x?: number;
y?: number;
}
type ScaleRelativeToValues = 'container' | 'content';
export interface ScaleToOpts extends ChangeOptions {
/** Transform origin. Can be a number, or string percent, eg "50%" */
originX?: number | string;
/** Transform origin. Can be a number, or string percent, eg "50%" */
originY?: number | string;
/** Should the transform origin be relative to the container, or content? */
relativeTo?: ScaleRelativeToValues;
}
function getDistance(a: Point, b?: Point): number {
if (!b) return 0;
return Math.sqrt((b.clientX - a.clientX) ** 2 + (b.clientY - a.clientY) ** 2);
}
function getMidpoint(a: Point, b?: Point): Point {
if (!b) return a;
return {
clientX: (a.clientX + b.clientX) / 2,
clientY: (a.clientY + b.clientY) / 2,
};
}
function getAbsoluteValue(value: string | number, max: number): number {
if (typeof value === 'number') return value;
if (value.trimRight().endsWith('%')) {
return (max * parseFloat(value)) / 100;
}
return parseFloat(value);
}
// I'd rather use DOMMatrix/DOMPoint here, but the browser support isn't good enough.
// Given that, better to use something everything supports.
let cachedSvg: SVGSVGElement;
function getSVG(): SVGSVGElement {
return (
cachedSvg ||
(cachedSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'))
);
}
function createMatrix(): SVGMatrix {
return getSVG().createSVGMatrix();
}
function createPoint(): SVGPoint {
return getSVG().createSVGPoint();
}
const MIN_SCALE = 0.01;
export default class PinchZoom extends HTMLElement {
// The element that we'll transform.
// Ideally this would be shadow DOM, but we don't have the browser
// support yet.
private _positioningEl?: Element;
// Current transform.
private _transform: SVGMatrix = createMatrix();
constructor() {
super();
// Watch for children changes.
// Note this won't fire for initial contents,
// so _stageElChange is also called in connectedCallback.
new MutationObserver(() => this._stageElChange()).observe(this, {
childList: true,
});
// Watch for pointers
const pointerTracker: PointerTracker = new PointerTracker(this, {
start: (pointer, event) => {
// We only want to track 2 pointers at most
if (pointerTracker.currentPointers.length === 2 || !this._positioningEl)
return false;
event.preventDefault();
return true;
},
move: (previousPointers) => {
this._onPointerMove(previousPointers, pointerTracker.currentPointers);
},
});
this.addEventListener('wheel', (event) => this._onWheel(event));
}
connectedCallback() {
this._stageElChange();
}
get x() {
return this._transform.e;
}
get y() {
return this._transform.f;
}
get scale() {
return this._transform.a;
}
/**
* Change the scale, adjusting x/y by a given transform origin.
*/
scaleTo(scale: number, opts: ScaleToOpts = {}) {
let { originX = 0, originY = 0 } = opts;
const { relativeTo = 'content', allowChangeEvent = false } = opts;
const relativeToEl = relativeTo === 'content' ? this._positioningEl : this;
// No content element? Fall back to just setting scale
if (!relativeToEl || !this._positioningEl) {
this.setTransform({ scale, allowChangeEvent });
return;
}
const rect = relativeToEl.getBoundingClientRect();
originX = getAbsoluteValue(originX, rect.width);
originY = getAbsoluteValue(originY, rect.height);
if (relativeTo === 'content') {
originX += this.x;
originY += this.y;
} else {
const currentRect = this._positioningEl.getBoundingClientRect();
originX -= currentRect.left;
originY -= currentRect.top;
}
this._applyChange({
allowChangeEvent,
originX,
originY,
scaleDiff: scale / this.scale,
});
}
/**
* Update the stage with a given scale/x/y.
*/
setTransform(opts: SetTransformOpts = {}) {
const { scale = this.scale, allowChangeEvent = false } = opts;
let { x = this.x, y = this.y } = opts;
// If we don't have an element to position, just set the value as given.
// We'll check bounds later.
if (!this._positioningEl) {
this._updateTransform(scale, x, y, allowChangeEvent);
return;
}
// Get current layout
const thisBounds = this.getBoundingClientRect();
const positioningElBounds = this._positioningEl.getBoundingClientRect();
// Not displayed. May be disconnected or display:none.
// Just take the values, and we'll check bounds later.
if (!thisBounds.width || !thisBounds.height) {
this._updateTransform(scale, x, y, allowChangeEvent);
return;
}
// Create points for _positioningEl.
let topLeft = createPoint();
topLeft.x = positioningElBounds.left - thisBounds.left;
topLeft.y = positioningElBounds.top - thisBounds.top;
let bottomRight = createPoint();
bottomRight.x = positioningElBounds.width + topLeft.x;
bottomRight.y = positioningElBounds.height + topLeft.y;
// Calculate the intended position of _positioningEl.
const matrix = createMatrix()
.translate(x, y)
.scale(scale)
// Undo current transform
.multiply(this._transform.inverse());
topLeft = topLeft.matrixTransform(matrix);
bottomRight = bottomRight.matrixTransform(matrix);
// Ensure _positioningEl can't move beyond out-of-bounds.
// Correct for x
if (topLeft.x > thisBounds.width) {
x += thisBounds.width - topLeft.x;
} else if (bottomRight.x < 0) {
x += -bottomRight.x;
}
// Correct for y
if (topLeft.y > thisBounds.height) {
y += thisBounds.height - topLeft.y;
} else if (bottomRight.y < 0) {
y += -bottomRight.y;
}
this._updateTransform(scale, x, y, allowChangeEvent);
}
/**
* Update transform values without checking bounds. This is only called in setTransform.
*/
private _updateTransform(
scale: number,
x: number,
y: number,
allowChangeEvent: boolean,
) {
// Avoid scaling to zero
if (scale < MIN_SCALE) return;
// Return if there's no change
if (scale === this.scale && x === this.x && y === this.y) return;
this._transform.e = x;
this._transform.f = y;
this._transform.d = this._transform.a = scale;
this.style.setProperty('--x', this.x + 'px');
this.style.setProperty('--y', this.y + 'px');
this.style.setProperty('--scale', this.scale + '');
if (allowChangeEvent) {
const event = new Event('change', { bubbles: true });
this.dispatchEvent(event);
}
}
/**
* Called when the direct children of this element change.
* Until we have have shadow dom support across the board, we
* require a single element to be the child of <pinch-zoom>, and
* that's the element we pan/scale.
*/
private _stageElChange() {
this._positioningEl = undefined;
if (this.children.length === 0) return;
this._positioningEl = this.children[0];
if (this.children.length > 1) {
console.warn('<pinch-zoom> must not have more than one child.');
}
// Do a bounds check
this.setTransform({ allowChangeEvent: true });
}
private _onWheel(event: WheelEvent) {
if (!this._positioningEl) return;
event.preventDefault();
const currentRect = this._positioningEl.getBoundingClientRect();
let { deltaY } = event;
const { ctrlKey, deltaMode } = event;
if (deltaMode === 1) {
// 1 is "lines", 0 is "pixels"
// Firefox uses "lines" for some types of mouse
deltaY *= 15;
}
// ctrlKey is true when pinch-zooming on a trackpad.
const divisor = ctrlKey ? 100 : 300;
const scaleDiff = 1 - deltaY / divisor;
this._applyChange({
scaleDiff,
originX: event.clientX - currentRect.left,
originY: event.clientY - currentRect.top,
allowChangeEvent: true,
});
}
private _onPointerMove(
previousPointers: Pointer[],
currentPointers: Pointer[],
) {
if (!this._positioningEl) return;
// Combine next points with previous points
const currentRect = this._positioningEl.getBoundingClientRect();
// For calculating panning movement
const prevMidpoint = getMidpoint(previousPointers[0], previousPointers[1]);
const newMidpoint = getMidpoint(currentPointers[0], currentPointers[1]);
// Midpoint within the element
const originX = prevMidpoint.clientX - currentRect.left;
const originY = prevMidpoint.clientY - currentRect.top;
// Calculate the desired change in scale
const prevDistance = getDistance(previousPointers[0], previousPointers[1]);
const newDistance = getDistance(currentPointers[0], currentPointers[1]);
const scaleDiff = prevDistance ? newDistance / prevDistance : 1;
this._applyChange({
originX,
originY,
scaleDiff,
panX: newMidpoint.clientX - prevMidpoint.clientX,
panY: newMidpoint.clientY - prevMidpoint.clientY,
allowChangeEvent: true,
});
}
/** Transform the view & fire a change event */
private _applyChange(opts: ApplyChangeOpts = {}) {
const {
panX = 0,
panY = 0,
originX = 0,
originY = 0,
scaleDiff = 1,
allowChangeEvent = false,
} = opts;
const matrix = createMatrix()
// Translate according to panning.
.translate(panX, panY)
// Scale about the origin.
.translate(originX, originY)
// Apply current translate
.translate(this.x, this.y)
.scale(scaleDiff)
.translate(-originX, -originY)
// Apply current scale.
.scale(this.scale);
// Convert the transform into basic translate & scale.
this.setTransform({
allowChangeEvent,
scale: matrix.a,
x: matrix.e,
y: matrix.f,
});
}
}
customElements.define('pinch-zoom', PinchZoom);

View File

@@ -0,0 +1,9 @@
declare module 'preact' {
namespace createElement.JSX {
interface IntrinsicElements {
'pinch-zoom': HTMLAttributes;
}
}
}
export {};

View File

@@ -0,0 +1,14 @@
pinch-zoom {
display: block;
overflow: hidden;
touch-action: none;
--scale: 1;
--x: 0;
--y: 0;
}
pinch-zoom > * {
transform: translate(var(--x), var(--y)) scale(var(--scale));
transform-origin: 0 0;
will-change: transform;
}

View File

@@ -0,0 +1,172 @@
import PointerTracker, { Pointer } from 'pointer-tracker';
import * as styles from './styles.css';
import 'add-css:./styles.css';
const legacyClipCompatAttr = 'legacy-clip-compat';
const orientationAttr = 'orientation';
type TwoUpOrientation = 'horizontal' | 'vertical';
/**
* A split view that the user can adjust. The first child becomes
* the left-hand side, and the second child becomes the right-hand side.
*/
export default class TwoUp extends HTMLElement {
static get observedAttributes() {
return [orientationAttr];
}
private readonly _handle = document.createElement('div');
/**
* The position of the split in pixels.
*/
private _position = 0;
/**
* The position of the split in %.
*/
private _relativePosition = 0.5;
/**
* The value of _position when the pointer went down.
*/
private _positionOnPointerStart = 0;
/**
* Has connectedCallback been called yet?
*/
private _everConnected = false;
constructor() {
super();
this._handle.className = styles.twoUpHandle;
// Watch for children changes.
// Note this won't fire for initial contents,
// so _childrenChange is also called in connectedCallback.
new MutationObserver(() => this._childrenChange()).observe(this, {
childList: true,
});
// Watch for element size changes.
if ('ResizeObserver' in window) {
new ResizeObserver(() => this._resetPosition()).observe(this);
} else {
window.addEventListener('resize', () => this._resetPosition());
}
// Watch for pointers on the handle.
const pointerTracker: PointerTracker = new PointerTracker(this._handle, {
start: (_, event) => {
// We only want to track 1 pointer.
if (pointerTracker.currentPointers.length === 1) return false;
event.preventDefault();
this._positionOnPointerStart = this._position;
return true;
},
move: () => {
this._pointerChange(
pointerTracker.startPointers[0],
pointerTracker.currentPointers[0],
);
},
});
}
connectedCallback() {
this._childrenChange();
this._handle.innerHTML = `<div class="${
styles.scrubber
}">${`<svg viewBox="0 0 27 20" fill="currentColor">${'<path d="M17 19.2l9.5-9.6L16.9 0zM9.6 0L0 9.6l9.6 9.6z"/>'}</svg>`}</div>`;
if (!this._everConnected) {
this._resetPosition();
this._everConnected = true;
}
}
attributeChangedCallback(name: string) {
if (name === orientationAttr) {
this._resetPosition();
}
}
private _resetPosition() {
// Set the initial position of the handle.
requestAnimationFrame(() => {
const bounds = this.getBoundingClientRect();
const dimensionAxis =
this.orientation === 'vertical' ? 'height' : 'width';
this._position = bounds[dimensionAxis] * this._relativePosition;
this._setPosition();
});
}
/**
* If true, this element works in browsers that don't support clip-path (Edge).
* However, this means you'll have to set the height of this element manually.
*/
get legacyClipCompat() {
return this.hasAttribute(legacyClipCompatAttr);
}
set legacyClipCompat(val: boolean) {
if (val) {
this.setAttribute(legacyClipCompatAttr, '');
} else {
this.removeAttribute(legacyClipCompatAttr);
}
}
/**
* Split vertically rather than horizontally.
*/
get orientation(): TwoUpOrientation {
const value = this.getAttribute(orientationAttr);
// This mirrors the behaviour of input.type, where setting just sets the attribute, but getting
// returns the value only if it's valid.
if (value && value.toLowerCase() === 'vertical') return 'vertical';
return 'horizontal';
}
set orientation(val: TwoUpOrientation) {
this.setAttribute(orientationAttr, val);
}
/**
* Called when element's child list changes
*/
private _childrenChange() {
// Ensure the handle is the last child.
// The CSS depends on this.
if (this.lastElementChild !== this._handle) {
this.appendChild(this._handle);
}
}
/**
* Called when a pointer moves.
*/
private _pointerChange(startPoint: Pointer, currentPoint: Pointer) {
const pointAxis = this.orientation === 'vertical' ? 'clientY' : 'clientX';
const dimensionAxis = this.orientation === 'vertical' ? 'height' : 'width';
const bounds = this.getBoundingClientRect();
this._position =
this._positionOnPointerStart +
(currentPoint[pointAxis] - startPoint[pointAxis]);
// Clamp position to element bounds.
this._position = Math.max(
0,
Math.min(this._position, bounds[dimensionAxis]),
);
this._relativePosition = this._position / bounds[dimensionAxis];
this._setPosition();
}
private _setPosition() {
this.style.setProperty('--split-point', `${this._position}px`);
}
}
customElements.define('two-up', TwoUp);

View File

@@ -0,0 +1,14 @@
interface TwoUpAttributes extends preact.JSX.HTMLAttributes {
orientation?: string;
'legacy-clip-compat'?: boolean;
}
declare module 'preact' {
namespace createElement.JSX {
interface IntrinsicElements {
'two-up': TwoUpAttributes;
}
}
}
export {};

View File

@@ -0,0 +1,131 @@
two-up {
display: grid;
position: relative;
--split-point: 0;
--accent-color: #777;
--track-color: var(--accent-color);
--thumb-background: #fff;
--thumb-color: var(--accent-color);
--thumb-size: 62px;
--bar-size: 6px;
--bar-touch-size: 30px;
}
two-up > * {
/* Overlay all children on top of each other, and let two-up's layout contain all of them. */
grid-area: 1/1;
}
two-up[legacy-clip-compat] > :not(.two-up-handle) {
/* Legacy mode uses clip rather than clip-path (Edge doesn't support clip-path), but clip requires
elements to be positioned absolutely */
position: absolute;
}
.two-up-handle {
touch-action: none;
position: relative;
width: var(--bar-touch-size);
transform: translateX(var(--split-point)) translateX(-50%);
will-change: transform;
cursor: ew-resize;
}
.two-up-handle::before {
content: '';
display: block;
height: 100%;
width: var(--bar-size);
margin: 0 auto;
box-shadow: inset calc(var(--bar-size) / 2) 0 0 rgba(0, 0, 0, 0.1),
0 1px 4px rgba(0, 0, 0, 0.4);
background: var(--track-color);
}
.scrubber {
display: flex;
position: absolute;
top: 50%;
left: 50%;
transform-origin: 50% 50%;
transform: translate(-50%, -50%);
width: var(--thumb-size);
height: calc(var(--thumb-size) * 0.9);
background: var(--thumb-background);
border: 1px solid rgba(0, 0, 0, 0.2);
border-radius: var(--thumb-size);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
color: var(--thumb-color);
box-sizing: border-box;
padding: 0 calc(var(--thumb-size) * 0.24);
}
.scrubber svg {
flex: 1;
}
two-up[orientation='vertical'] .two-up-handle {
width: auto;
height: var(--bar-touch-size);
transform: translateY(var(--split-point)) translateY(-50%);
cursor: ns-resize;
}
two-up[orientation='vertical'] .two-up-handle::before {
width: auto;
height: var(--bar-size);
box-shadow: inset 0 calc(var(--bar-size) / 2) 0 rgba(0, 0, 0, 0.1),
0 1px 4px rgba(0, 0, 0, 0.4);
margin: calc((var(--bar-touch-size) - var(--bar-size)) / 2) 0 0 0;
}
two-up[orientation='vertical'] .scrubber {
box-shadow: 1px 0 4px rgba(0, 0, 0, 0.1);
transform: translate(-50%, -50%) rotate(-90deg);
}
two-up > :nth-child(1):not(.two-up-handle) {
-webkit-clip-path: inset(0 calc(100% - var(--split-point)) 0 0);
clip-path: inset(0 calc(100% - var(--split-point)) 0 0);
}
two-up > :nth-child(2):not(.two-up-handle) {
-webkit-clip-path: inset(0 0 0 var(--split-point));
clip-path: inset(0 0 0 var(--split-point));
}
two-up[orientation='vertical'] > :nth-child(1):not(.two-up-handle) {
-webkit-clip-path: inset(0 0 calc(100% - var(--split-point)) 0);
clip-path: inset(0 0 calc(100% - var(--split-point)) 0);
}
two-up[orientation='vertical'] > :nth-child(2):not(.two-up-handle) {
-webkit-clip-path: inset(var(--split-point) 0 0 0);
clip-path: inset(var(--split-point) 0 0 0);
}
/*
Even in legacy-clip-compat, prefer clip-path if it's supported.
It performs way better in Safari.
*/
@supports not (
(clip-path: inset(0 0 0 0)) or (-webkit-clip-path: inset(0 0 0 0))
) {
two-up[legacy-clip-compat] > :nth-child(1):not(.two-up-handle) {
clip: rect(auto var(--split-point) auto auto);
}
two-up[legacy-clip-compat] > :nth-child(2):not(.two-up-handle) {
clip: rect(auto auto auto var(--split-point));
}
two-up[orientation='vertical'][legacy-clip-compat]
> :nth-child(1):not(.two-up-handle) {
clip: rect(auto auto var(--split-point) auto);
}
two-up[orientation='vertical'][legacy-clip-compat]
> :nth-child(2):not(.two-up-handle) {
clip: rect(var(--split-point) auto auto auto);
}
}

View File

@@ -0,0 +1,377 @@
import { h, Component } from 'preact';
import type PinchZoom from './custom-els/PinchZoom';
import type { ScaleToOpts } from './custom-els/PinchZoom';
import './custom-els/PinchZoom';
import './custom-els/TwoUp';
import * as style from './style.css';
import 'add-css:./style.css';
import { shallowEqual, drawDataToCanvas } from '../../util';
import {
ToggleBackgroundIcon,
AddIcon,
RemoveIcon,
BackIcon,
ToggleBackgroundActiveIcon,
RotateIcon,
} from '../../icons';
import { twoUpHandle } from './custom-els/TwoUp/styles.css';
import type { PreprocessorState } from '../../feature-meta';
import { cleanSet } from '../../util/clean-modify';
import type { SourceImage } from '../../Compress';
import { linkRef } from 'shared/initial-app/util';
interface Props {
source?: SourceImage;
preprocessorState?: PreprocessorState;
mobileView: boolean;
leftCompressed?: ImageData;
rightCompressed?: ImageData;
leftImgContain: boolean;
rightImgContain: boolean;
onBack: () => void;
onPreprocessorChange: (newState: PreprocessorState) => void;
}
interface State {
scale: number;
editingScale: boolean;
altBackground: boolean;
}
const scaleToOpts: ScaleToOpts = {
originX: '50%',
originY: '50%',
relativeTo: 'container',
allowChangeEvent: true,
};
export default class Output extends Component<Props, State> {
state: State = {
scale: 1,
editingScale: false,
altBackground: false,
};
canvasLeft?: HTMLCanvasElement;
canvasRight?: HTMLCanvasElement;
pinchZoomLeft?: PinchZoom;
pinchZoomRight?: PinchZoom;
scaleInput?: HTMLInputElement;
retargetedEvents = new WeakSet<Event>();
componentDidMount() {
const leftDraw = this.leftDrawable();
const rightDraw = this.rightDrawable();
// Reset the pinch zoom, which may have an position set from the previous view, after pressing
// the back button.
this.pinchZoomLeft!.setTransform({
allowChangeEvent: true,
x: 0,
y: 0,
scale: 1,
});
if (this.canvasLeft && leftDraw) {
drawDataToCanvas(this.canvasLeft, leftDraw);
}
if (this.canvasRight && rightDraw) {
drawDataToCanvas(this.canvasRight, rightDraw);
}
}
componentDidUpdate(prevProps: Props, prevState: State) {
const prevLeftDraw = this.leftDrawable(prevProps);
const prevRightDraw = this.rightDrawable(prevProps);
const leftDraw = this.leftDrawable();
const rightDraw = this.rightDrawable();
const sourceFileChanged =
// Has the value become (un)defined?
!!this.props.source !== !!prevProps.source ||
// Or has the file changed?
(this.props.source &&
prevProps.source &&
this.props.source.file !== prevProps.source.file);
const oldSourceData = prevProps.source && prevProps.source.preprocessed;
const newSourceData = this.props.source && this.props.source.preprocessed;
const pinchZoom = this.pinchZoomLeft!;
if (sourceFileChanged) {
// New image? Reset the pinch-zoom.
pinchZoom.setTransform({
allowChangeEvent: true,
x: 0,
y: 0,
scale: 1,
});
} else if (
oldSourceData &&
newSourceData &&
oldSourceData !== newSourceData
) {
// Since the pinch zoom transform origin is the top-left of the content, we need to flip
// things around a bit when the content size changes, so the new content appears as if it were
// central to the previous content.
const scaleChange = 1 - pinchZoom.scale;
const oldXScaleOffset = (oldSourceData.width / 2) * scaleChange;
const oldYScaleOffset = (oldSourceData.height / 2) * scaleChange;
pinchZoom.setTransform({
allowChangeEvent: true,
x: pinchZoom.x - oldXScaleOffset + oldYScaleOffset,
y: pinchZoom.y - oldYScaleOffset + oldXScaleOffset,
});
}
if (leftDraw && leftDraw !== prevLeftDraw && this.canvasLeft) {
drawDataToCanvas(this.canvasLeft, leftDraw);
}
if (rightDraw && rightDraw !== prevRightDraw && this.canvasRight) {
drawDataToCanvas(this.canvasRight, rightDraw);
}
}
shouldComponentUpdate(nextProps: Props, nextState: State) {
return (
!shallowEqual(this.props, nextProps) ||
!shallowEqual(this.state, nextState)
);
}
private leftDrawable(props: Props = this.props): ImageData | undefined {
return props.leftCompressed || (props.source && props.source.preprocessed);
}
private rightDrawable(props: Props = this.props): ImageData | undefined {
return props.rightCompressed || (props.source && props.source.preprocessed);
}
private toggleBackground = () => {
this.setState({
altBackground: !this.state.altBackground,
});
};
private zoomIn = () => {
if (!this.pinchZoomLeft) throw Error('Missing pinch-zoom element');
this.pinchZoomLeft.scaleTo(this.state.scale * 1.25, scaleToOpts);
};
private zoomOut = () => {
if (!this.pinchZoomLeft) throw Error('Missing pinch-zoom element');
this.pinchZoomLeft.scaleTo(this.state.scale / 1.25, scaleToOpts);
};
private onRotateClick = () => {
const { preprocessorState: inputProcessorState } = this.props;
if (!inputProcessorState) return;
const newState = cleanSet(
inputProcessorState,
'rotate.rotate',
(inputProcessorState.rotate.rotate + 90) % 360,
);
this.props.onPreprocessorChange(newState);
};
private onScaleValueFocus = () => {
this.setState({ editingScale: true }, () => {
if (this.scaleInput) {
// Firefox unfocuses the input straight away unless I force a style
// calculation here. I have no idea why, but it's late and I'm quite
// tired.
getComputedStyle(this.scaleInput).transform;
this.scaleInput.focus();
}
});
};
private onScaleInputBlur = () => {
this.setState({ editingScale: false });
};
private onScaleInputChanged = (event: Event) => {
const target = event.target as HTMLInputElement;
const percent = parseFloat(target.value);
if (isNaN(percent)) return;
if (!this.pinchZoomLeft) throw Error('Missing pinch-zoom element');
this.pinchZoomLeft.scaleTo(percent / 100, scaleToOpts);
};
private onPinchZoomLeftChange = (event: Event) => {
if (!this.pinchZoomRight || !this.pinchZoomLeft) {
throw Error('Missing pinch-zoom element');
}
this.setState({
scale: this.pinchZoomLeft.scale,
});
this.pinchZoomRight.setTransform({
scale: this.pinchZoomLeft.scale,
x: this.pinchZoomLeft.x,
y: this.pinchZoomLeft.y,
});
};
/**
* We're using two pinch zoom elements, but we want them to stay in sync. When one moves, we
* update the position of the other. However, this is tricky when it comes to multi-touch, when
* one finger is on one pinch-zoom, and the other finger is on the other. To overcome this, we
* redirect all relevant pointer/touch/mouse events to the first pinch zoom element.
*
* @param event Event to redirect
*/
private onRetargetableEvent = (event: Event) => {
const targetEl = event.target as HTMLElement;
if (!this.pinchZoomLeft) throw Error('Missing pinch-zoom element');
// If the event is on the handle of the two-up, let it through,
// unless it's a wheel event, in which case always let it through.
if (event.type !== 'wheel' && targetEl.closest(`.${twoUpHandle}`)) return;
// If we've already retargeted this event, let it through.
if (this.retargetedEvents.has(event)) return;
// Stop the event in its tracks.
event.stopImmediatePropagation();
event.preventDefault();
// Clone the event & dispatch
// Some TypeScript trickery needed due to https://github.com/Microsoft/TypeScript/issues/3841
const clonedEvent = new (event.constructor as typeof Event)(
event.type,
event,
);
this.retargetedEvents.add(clonedEvent);
this.pinchZoomLeft.dispatchEvent(clonedEvent);
// Unfocus any active element on touchend. This fixes an issue on (at least) Android Chrome,
// where the software keyboard is hidden, but the input remains focused, then after interaction
// with this element the keyboard reappears for NO GOOD REASON. Thanks Android.
if (
event.type === 'touchend' &&
document.activeElement &&
document.activeElement instanceof HTMLElement
) {
document.activeElement.blur();
}
};
render(
{ mobileView, leftImgContain, rightImgContain, source, onBack }: Props,
{ scale, editingScale, altBackground }: State,
) {
const leftDraw = this.leftDrawable();
const rightDraw = this.rightDrawable();
// To keep position stable, the output is put in a square using the longest dimension.
const originalImage = source && source.preprocessed;
return (
<div
class={`${style.output} ${altBackground ? style.altBackground : ''}`}
>
<two-up
legacy-clip-compat
class={style.twoUp}
orientation={mobileView ? 'vertical' : 'horizontal'}
// Event redirecting. See onRetargetableEvent.
onTouchStartCapture={this.onRetargetableEvent}
onTouchEndCapture={this.onRetargetableEvent}
onTouchMoveCapture={this.onRetargetableEvent}
onPointerDownCapture={this.onRetargetableEvent}
onMouseDownCapture={this.onRetargetableEvent}
onWheelCapture={this.onRetargetableEvent}
>
<pinch-zoom
class={style.pinchZoom}
onChange={this.onPinchZoomLeftChange}
ref={linkRef(this, 'pinchZoomLeft')}
>
<canvas
class={style.pinchTarget}
ref={linkRef(this, 'canvasLeft')}
width={leftDraw && leftDraw.width}
height={leftDraw && leftDraw.height}
style={{
width: originalImage ? originalImage.width : '',
height: originalImage ? originalImage.height : '',
objectFit: leftImgContain ? 'contain' : '',
}}
/>
</pinch-zoom>
<pinch-zoom
class={style.pinchZoom}
ref={linkRef(this, 'pinchZoomRight')}
>
<canvas
class={style.pinchTarget}
ref={linkRef(this, 'canvasRight')}
width={rightDraw && rightDraw.width}
height={rightDraw && rightDraw.height}
style={{
width: originalImage ? originalImage.width : '',
height: originalImage ? originalImage.height : '',
objectFit: rightImgContain ? 'contain' : '',
}}
/>
</pinch-zoom>
</two-up>
<div class={style.back}>
<button class={style.button} onClick={onBack}>
<BackIcon />
</button>
</div>
<div class={style.controls}>
<div class={style.zoomControls}>
<button class={style.button} onClick={this.zoomOut}>
<RemoveIcon />
</button>
{editingScale ? (
<input
type="number"
step="1"
min="1"
max="1000000"
ref={linkRef(this, 'scaleInput')}
class={style.zoom}
value={Math.round(scale * 100)}
onInput={this.onScaleInputChanged}
onBlur={this.onScaleInputBlur}
/>
) : (
<span
class={style.zoom}
tabIndex={0}
onFocus={this.onScaleValueFocus}
>
<span class={style.zoomValue}>{Math.round(scale * 100)}</span>%
</span>
)}
<button class={style.button} onClick={this.zoomIn}>
<AddIcon />
</button>
</div>
<div class={style.buttonsNoWrap}>
<button
class={style.button}
onClick={this.onRotateClick}
title="Rotate image"
>
<RotateIcon />
</button>
<button
class={`${style.button} ${altBackground ? style.active : ''}`}
onClick={this.toggleBackground}
title="Change canvas color"
>
{altBackground ? (
<ToggleBackgroundActiveIcon />
) : (
<ToggleBackgroundIcon />
)}
</button>
</div>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,166 @@
.output {
composes: abs-fill from '../../../../shared/initial-app/util.css';
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #000;
opacity: 0;
transition: opacity 500ms ease;
}
&.alt-background::before {
opacity: 0.6;
}
}
.two-up {
composes: abs-fill from '../../../../shared/initial-app/util.css';
--accent-color: var(--button-fg);
}
.pinch-zoom {
composes: abs-fill from '../../../../shared/initial-app/util.css';
outline: none;
display: flex;
justify-content: center;
align-items: center;
}
.pinch-target {
/* 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;
/* Prevent the image becoming misshapen due to default flexbox layout. */
flex-shrink: 0;
}
.controls {
position: absolute;
display: flex;
justify-content: center;
top: 0;
left: 0;
right: 0;
padding: 9px 84px;
overflow: hidden;
flex-wrap: wrap;
contain: content;
/* Allow clicks to fall through to the pinch zoom area */
pointer-events: none;
& > * {
pointer-events: auto;
}
@media (min-width: 860px) {
padding: 9px;
top: auto;
left: 320px;
right: 320px;
bottom: 0;
flex-wrap: wrap-reverse;
}
}
.zoom-controls {
display: flex;
& :not(:first-child) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
margin-left: 0;
}
& :not(:last-child) {
margin-right: 0;
border-right-width: 0;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
}
.button,
.zoom {
display: flex;
align-items: center;
box-sizing: border-box;
margin: 4px;
background-color: #fff;
border: 1px solid rgba(0, 0, 0, 0.2);
border-radius: 5px;
line-height: 1;
white-space: nowrap;
height: 36px;
padding: 0 8px;
cursor: pointer;
@media (min-width: 600px) {
height: 48px;
padding: 0 16px;
}
&:focus {
box-shadow: 0 0 0 2px var(--button-fg);
outline: none;
z-index: 1;
}
}
.button {
color: var(--button-fg);
&:hover {
background-color: #eee;
}
&.active {
background: #34b9eb;
color: #fff;
&:hover {
background: #32a3ce;
}
}
}
.zoom {
color: #625e80;
cursor: text;
width: 6em;
font: inherit;
text-align: center;
justify-content: center;
&:focus {
box-shadow: inset 0 1px 4px rgba(0, 0, 0, 0.2), 0 0 0 2px var(--button-fg);
}
}
.zoom-value {
position: relative;
top: 1px;
margin: 0 3px 0 0;
color: #888;
border-bottom: 1px dashed #999;
}
.back {
position: absolute;
top: 0;
left: 0;
padding: 9px;
}
.buttons-no-wrap {
display: flex;
pointer-events: none;
& > * {
pointer-events: auto;
}
}

View File

@@ -0,0 +1,43 @@
import { h, Component } from 'preact';
import prettyBytes from 'pretty-bytes';
import * as style from './style.css';
interface Props {
blob: Blob;
compareTo?: Blob;
}
interface State {}
export default class FileSize extends Component<Props, State> {
render({ blob, compareTo }: Props) {
let comparison: preact.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>
);
}
}

View File

@@ -0,0 +1,135 @@
import { h, Component, ComponentChildren, ComponentChild } from 'preact';
import * as style from './style.css';
import 'add-css:./style.css';
import FileSize from './FileSize';
import {
DownloadIcon,
CopyAcrossIcon,
CopyAcrossIconProps,
} from 'client/lazy-app/icons';
import 'shared/initial-app/custom-els/loading-spinner';
import { SourceImage } from '../';
interface Props {
loading: boolean;
source?: SourceImage;
imageFile?: File;
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> {
state: State = {
showLoadingState: this.props.loading,
};
/** The timeout ID between entering the loading state, and changing UI */
private loadingTimeoutId: number = 0;
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,
);
}
}
private onCopyToOtherClick = (event: Event) => {
event.preventDefault();
this.props.onCopyToOtherClick();
};
private onDownload = () => {
// GA cant do floats. So we round to ints. We're deliberately rounding to nearest kilobyte to
// avoid cases where exact image sizes leak something interesting about the user.
const before = Math.round(this.props.source!.file.size / 1024);
const after = Math.round(this.props.imageFile!.size / 1024);
const change = Math.round((after / before) * 1000);
ga('send', 'event', 'compression', 'download', {
metric1: before,
metric2: after,
metric3: change,
});
};
render(
{
source,
imageFile,
downloadUrl,
children,
copyDirection,
buttonPosition,
}: Props,
{ showLoadingState }: State,
) {
return (
<div class={`${style.results} ${buttonPositionClass[buttonPosition]}`}>
<div class={style.resultData}>
{children ? <div class={style.resultTitle}>{children}</div> : null}
{!imageFile || showLoadingState ? (
'Working…'
) : (
<FileSize
blob={imageFile}
compareTo={
source && imageFile !== source.file ? source.file : undefined
}
/>
)}
</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
class={`${style.downloadLink} ${
showLoadingState ? style.downloadLinkDisable : ''
}`}
href={downloadUrl}
download={imageFile.name}
title="Download"
onClick={this.onDownload}
>
<DownloadIcon class={style.downloadIcon} />
</a>
)}
{showLoadingState && <loading-spinner class={style.spinner} />}
</div>
</div>
);
}
}

View File

@@ -0,0 +1,131 @@
@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;
}
}
.results {
display: grid;
grid-template-columns: [text] 1fr [copy-button] auto [download-button] 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;
}
&:focus {
outline: none;
}
}
.result-data {
grid-row: 1;
grid-column: text;
display: flex;
align-items: center;
padding: 0 10px;
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;
margin-right: 0.4em;
}
.size-delta {
font-size: 0.8em;
font-style: italic;
position: relative;
top: -1px;
margin-left: 0.3em;
}
.size-increase {
color: #e35050;
}
.size-decrease {
color: #50e3c2;
}
.download {
grid-row: 1;
grid-column: download-button;
background: #34b9eb;
--size: 38px;
width: var(--size);
height: var(--size);
display: grid;
align-items: center;
justify-items: center;
}
.download-link {
animation: action-enter 0.2s;
grid-area: 1/1;
}
.download-link-disable {
pointer-events: none;
opacity: 0;
transform: rotate(90deg);
animation: action-leave 0.2s;
}
.download-icon,
.copy-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));
}
.spinner {
--color: #fff;
--delay: 0;
--size: 22px;
grid-area: 1/1;
}
.copy-to-other {
grid-row: 1;
grid-column: copy-button;
composes: unbutton from '../../../../shared/initial-app/util.css';
composes: download;
background: #656565;
}

View File

@@ -0,0 +1,321 @@
import * as style from './styles.css';
import 'add-css:./styles.css';
import { transitionHeight } from 'client/lazy-app/util';
interface CloseAllOptions {
exceptFirst?: boolean;
}
const openOneOnlyAttr = 'open-one-only';
function getClosestHeading(el: Element): HTMLElement | undefined {
// Look for the child of multi-panel, but stop at interactive elements like links & buttons
const closestEl = el.closest('multi-panel > *, a, button');
if (closestEl && closestEl.classList.contains(style.panelHeading)) {
return closestEl as HTMLElement;
}
return undefined;
}
async function close(heading: HTMLElement) {
const content = heading.nextElementSibling as HTMLElement;
// if there is no content, nothing to expand
if (!content) return;
const from = content.getBoundingClientRect().height;
heading.removeAttribute('content-expanded');
content.setAttribute('aria-expanded', 'false');
// Wait a microtask so other calls to open/close can get the final sizes.
await null;
await transitionHeight(content, {
from,
to: 0,
duration: 300,
});
content.style.height = '';
}
async function open(heading: HTMLElement) {
const content = heading.nextElementSibling as HTMLElement;
// if there is no content, nothing to expand
if (!content) return;
const from = content.getBoundingClientRect().height;
heading.setAttribute('content-expanded', '');
content.setAttribute('aria-expanded', 'true');
const to = content.getBoundingClientRect().height;
// Wait a microtask so other calls to open/close can get the final sizes.
await null;
await transitionHeight(content, {
from,
to,
duration: 300,
});
content.style.height = '';
}
/**
* A multi-panel view that the user can add any number of 'panels'.
* 'a panel' consists of two elements. Even index element becomes heading,
* and odd index element becomes the expandable content.
*/
export default class MultiPanel extends HTMLElement {
static get observedAttributes() {
return [openOneOnlyAttr];
}
constructor() {
super();
// add EventListeners
this.addEventListener('click', this._onClick);
this.addEventListener('keydown', this._onKeyDown);
// Watch for children changes.
new MutationObserver(() => this._childrenChange()).observe(this, {
childList: true,
});
}
connectedCallback() {
this._childrenChange();
}
attributeChangedCallback(
name: string,
oldValue: string | null,
newValue: string | null,
) {
if (name === openOneOnlyAttr && newValue === null) {
this._closeAll({ exceptFirst: true });
}
}
// Click event handler
private _onClick(event: MouseEvent) {
const el = event.target as HTMLElement;
const heading = getClosestHeading(el);
if (!heading) return;
this._toggle(heading);
}
// KeyDown event handler
private _onKeyDown(event: KeyboardEvent) {
const selectedEl = document.activeElement!;
const heading = getClosestHeading(selectedEl);
// if keydown event is not on heading element, ignore
if (!heading) return;
// if something inside of heading has focus, ignore
if (selectedEl !== heading) return;
// dont handle modifier shortcuts used by assistive technology.
if (event.altKey) return;
let newHeading: HTMLElement | undefined;
switch (event.key) {
case 'ArrowLeft':
case 'ArrowUp':
newHeading = this._prevHeading();
break;
case 'ArrowRight':
case 'ArrowDown':
newHeading = this._nextHeading();
break;
case 'Home':
newHeading = this._firstHeading();
break;
case 'End':
newHeading = this._lastHeading();
break;
// this has 3 cases listed to support IEs and FF before 37
case 'Enter':
case ' ':
case 'Spacebar':
this._toggle(heading);
break;
// Any other key press is ignored and passed back to the browser.
default:
return;
}
event.preventDefault();
if (newHeading) {
selectedEl.setAttribute('tabindex', '-1');
newHeading.setAttribute('tabindex', '0');
newHeading.focus();
}
}
private _toggle(heading: HTMLElement) {
if (!heading) return;
// toggle expanded and aria-expanded attributes
if (heading.hasAttribute('content-expanded')) {
close(heading);
} else {
if (this.openOneOnly) this._closeAll();
open(heading);
}
}
private _closeAll(options: CloseAllOptions = {}): void {
const { exceptFirst = false } = options;
let els = [...this.children].filter((el) =>
el.matches('[content-expanded]'),
) as HTMLElement[];
if (exceptFirst) {
els = els.slice(1);
}
for (const el of els) close(el);
}
// children of multi-panel should always be even number (heading/content pair)
private _childrenChange() {
let preserveTabIndex = false;
let heading = this.firstElementChild;
while (heading) {
const content = heading.nextElementSibling;
const randomId = Math.random().toString(36).substr(2, 9);
// if at the end of this loop, runout of element for content,
// it means it has odd number of elements. log error and set heading to end the loop.
if (!content) {
console.error(
'<multi-panel> requires an even number of element children.',
);
break;
}
// When odd number of elements were inserted in the middle,
// what was heading before may become content after the insertion.
// Remove classes and attributes to prepare for this change.
heading.classList.remove(style.panelContent);
content.classList.remove(style.panelHeading);
heading.removeAttribute('aria-expanded');
heading.removeAttribute('content-expanded');
// If appreciable, remove tabindex from content which used to be header.
content.removeAttribute('tabindex');
// Assign heading and content classes
heading.classList.add(style.panelHeading);
content.classList.add(style.panelContent);
// Assign ids and aria-X for heading/content pair.
heading.id = `panel-heading-${randomId}`;
heading.setAttribute('aria-controls', `panel-content-${randomId}`);
content.id = `panel-content-${randomId}`;
content.setAttribute('aria-labelledby', `panel-heading-${randomId}`);
// If tabindex 0 is assigned to a heading, flag to preserve tab index position.
// Otherwise, make sure tabindex -1 is set to heading elements.
if (heading.getAttribute('tabindex') === '0') {
preserveTabIndex = true;
} else {
heading.setAttribute('tabindex', '-1');
}
// It's possible that the heading & content expanded attributes are now out of sync. Resync
// them using the heading as the source of truth.
content.setAttribute(
'aria-expanded',
heading.hasAttribute('content-expanded') ? 'true' : 'false',
);
// next sibling of content = next heading
heading = content.nextElementSibling;
}
// if no flag, make 1st heading as tabindex 0 (otherwise keep previous tab index position).
if (!preserveTabIndex && this.firstElementChild) {
this.firstElementChild.setAttribute('tabindex', '0');
}
// In case we're openOneOnly, and an additional open item has been added:
if (this.openOneOnly) this._closeAll({ exceptFirst: true });
}
// returns heading that is before currently selected one.
private _prevHeading() {
// activeElement would be the currently selected heading
// 2 elements before that would be the previous heading unless it is the first element.
if (this.firstElementChild === document.activeElement) {
return this.firstElementChild as HTMLElement;
}
// previous Element of active Element is previous Content,
// previous Element of previous Content is previousHeading
const previousContent = document.activeElement!.previousElementSibling;
if (previousContent) {
return previousContent.previousElementSibling as HTMLElement;
}
}
// returns heading that is after currently selected one.
private _nextHeading() {
// activeElement would be the currently selected heading
// 2 elemements after that would be the next heading.
const nextContent = document.activeElement!.nextElementSibling;
if (nextContent) {
return nextContent.nextElementSibling as HTMLElement;
}
}
// returns first heading in multi-panel.
private _firstHeading() {
// first element is always first heading
return this.firstElementChild as HTMLElement;
}
// returns last heading in multi-panel.
private _lastHeading() {
// if the last element is heading, return last element
const lastEl = this.lastElementChild as HTMLElement;
if (lastEl && lastEl.classList.contains(style.panelHeading)) {
return lastEl;
}
// otherwise return 2nd from the last
const lastContent = this.lastElementChild;
if (lastContent) {
return lastContent.previousElementSibling as HTMLElement;
}
}
/**
* If true, only one panel can be open at once. When one opens, others close.
*/
get openOneOnly() {
return this.hasAttribute(openOneOnlyAttr);
}
set openOneOnly(val: boolean) {
if (val) {
this.setAttribute(openOneOnlyAttr, '');
} else {
this.removeAttribute(openOneOnlyAttr);
}
}
}
customElements.define('multi-panel', MultiPanel);

View File

@@ -0,0 +1,13 @@
interface MultiPanelAttributes extends preact.JSX.HTMLAttributes {
'open-one-only'?: boolean;
}
declare module 'preact' {
namespace createElement.JSX {
interface IntrinsicElements {
'multi-panel': MultiPanelAttributes;
}
}
}
export {};

View File

@@ -0,0 +1,10 @@
.panel-heading {
background: gray;
}
.panel-content {
height: 0px;
overflow: auto;
}
.panel-content[aria-expanded='true'] {
height: auto;
}

View File

@@ -0,0 +1,886 @@
import { h, Component } from 'preact';
import * as style from './style.css';
import 'add-css:./style.css';
import {
blobToImg,
drawableToImageData,
blobToText,
builtinDecode,
sniffMimeType,
canDecodeImageType,
abortable,
assertSignal,
} from '../util';
import {
PreprocessorState,
ProcessorState,
EncoderState,
encoderMap,
defaultPreprocessorState,
defaultProcessorState,
EncoderType,
EncoderOptions,
} from '../feature-meta';
import Output from './Output';
import Options from './Options';
import ResultCache from './result-cache';
import { cleanMerge, cleanSet } from '../util/clean-modify';
import './custom-els/MultiPanel';
import Results from './Results';
import WorkerBridge from '../worker-bridge';
import { resize } from 'features/processors/resize/client';
import type SnackBarElement from 'shared/initial-app/custom-els/snack-bar';
import { CopyAcrossIconProps, ExpandIcon } from '../icons';
export type OutputType = EncoderType | 'identity';
export interface SourceImage {
file: File;
decoded: ImageData;
preprocessed: ImageData;
vectorImage?: HTMLImageElement;
}
interface SideSettings {
processorState: ProcessorState;
encoderState?: EncoderState;
}
interface Side {
processed?: ImageData;
file?: File;
downloadUrl?: string;
data?: ImageData;
latestSettings: SideSettings;
encodedSettings?: SideSettings;
loading: boolean;
}
interface Props {
file: File;
showSnack: SnackBarElement['showSnackbar'];
onBack: () => void;
}
interface State {
source?: SourceImage;
sides: [Side, Side];
/** Source image load */
loading: boolean;
error?: string;
mobileView: boolean;
preprocessorState: PreprocessorState;
encodedPreprocessorState?: PreprocessorState;
}
interface MainJob {
file: File;
preprocessorState: PreprocessorState;
}
interface SideJob {
processorState: ProcessorState;
encoderState?: EncoderState;
}
async function decodeImage(
signal: AbortSignal,
blob: Blob,
workerBridge: WorkerBridge,
): Promise<ImageData> {
assertSignal(signal);
const mimeType = await abortable(signal, sniffMimeType(blob));
const canDecode = await abortable(signal, canDecodeImageType(mimeType));
try {
if (!canDecode) {
if (mimeType === 'image/avif') {
return await workerBridge.avifDecode(signal, blob);
}
if (mimeType === 'image/webp') {
return await workerBridge.webpDecode(signal, blob);
}
if (mimeType === 'image/jpegxl') {
return await workerBridge.jxlDecode(signal, blob);
}
if (mimeType === 'image/webp2') {
return await workerBridge.wp2Decode(signal, blob);
}
// If it's not one of those types, fall through and try built-in decoding for a laugh.
}
return await abortable(signal, builtinDecode(blob));
} catch (err) {
if (err.name === 'AbortError') throw err;
console.log(err);
throw Error("Couldn't decode image");
}
}
async function preprocessImage(
signal: AbortSignal,
data: ImageData,
preprocessorState: PreprocessorState,
workerBridge: WorkerBridge,
): Promise<ImageData> {
assertSignal(signal);
let processedData = data;
if (preprocessorState.rotate.rotate !== 0) {
processedData = await workerBridge.rotate(
signal,
processedData,
preprocessorState.rotate,
);
}
return processedData;
}
async function processImage(
signal: AbortSignal,
source: SourceImage,
processorState: ProcessorState,
workerBridge: WorkerBridge,
): Promise<ImageData> {
assertSignal(signal);
let result = source.preprocessed;
if (processorState.resize.enabled) {
result = await resize(signal, source, processorState.resize, workerBridge);
}
if (processorState.quantize.enabled) {
result = await workerBridge.quantize(
signal,
result,
processorState.quantize,
);
}
return result;
}
async function compressImage(
signal: AbortSignal,
image: ImageData,
encodeData: EncoderState,
sourceFilename: string,
workerBridge: WorkerBridge,
): Promise<File> {
assertSignal(signal);
const encoder = encoderMap[encodeData.type];
const compressedData = await encoder.encode(
signal,
workerBridge,
image,
// The type of encodeData.options is enforced via the previous line
encodeData.options as any,
);
return new File(
[compressedData],
sourceFilename.replace(/.[^.]*$/, `.${encoder.meta.extension}`),
{ type: encoder.meta.mimeType },
);
}
function stateForNewSourceData(state: State): State {
let newState = { ...state };
for (const i of [0, 1]) {
// Ditch previous encodings
const downloadUrl = state.sides[i].downloadUrl;
if (downloadUrl) URL.revokeObjectURL(downloadUrl);
newState = cleanMerge(state, `sides.${i}`, {
preprocessed: undefined,
file: undefined,
downloadUrl: undefined,
data: undefined,
encodedSettings: undefined,
});
}
return newState;
}
async function processSvg(
signal: AbortSignal,
blob: Blob,
): Promise<HTMLImageElement> {
assertSignal(signal);
// Firefox throws if you try to draw an SVG to canvas that doesn't have width/height.
// In Chrome it loads, but drawImage behaves weirdly.
// This function sets width/height if it isn't already set.
const parser = new DOMParser();
const text = await abortable(signal, blobToText(blob));
const document = parser.parseFromString(text, 'image/svg+xml');
const svg = document.documentElement!;
if (svg.hasAttribute('width') && svg.hasAttribute('height')) {
return blobToImg(blob);
}
const viewBox = svg.getAttribute('viewBox');
if (viewBox === null) throw Error('SVG must have width/height or viewBox');
const viewboxParts = viewBox.split(/\s+/);
svg.setAttribute('width', viewboxParts[2]);
svg.setAttribute('height', viewboxParts[3]);
const serializer = new XMLSerializer();
const newSource = serializer.serializeToString(document);
return abortable(
signal,
blobToImg(new Blob([newSource], { type: 'image/svg+xml' })),
);
}
/**
* If two processors are disabled, they're considered equivalent, otherwise
* equivalence is based on ===
*/
function processorStateEquivalent(a: ProcessorState, b: ProcessorState) {
// Quick exit
if (a === b) return true;
// All processors have the same keys
for (const key of Object.keys(a) as Array<keyof ProcessorState>) {
// If both processors are disabled, they're the same.
if (!a[key].enabled && !b[key].enabled) continue;
if (a !== b) return false;
}
return true;
}
// These are only used in the mobile view
const resultTitles = ['Top', 'Bottom'] as const;
// These are only used in the desktop view
const buttonPositions = ['download-left', 'download-right'] as const;
const originalDocumentTitle = document.title;
function updateDocumentTitle(filename: string = ''): void {
document.title = filename
? `${filename} - ${originalDocumentTitle}`
: originalDocumentTitle;
}
export default class Compress extends Component<Props, State> {
widthQuery = window.matchMedia('(max-width: 599px)');
state: State = {
source: undefined,
loading: false,
preprocessorState: defaultPreprocessorState,
sides: [
{
latestSettings: {
processorState: defaultProcessorState,
encoderState: undefined,
},
loading: false,
},
{
latestSettings: {
processorState: defaultProcessorState,
encoderState: {
type: 'mozJPEG',
options: encoderMap.mozJPEG.meta.defaultOptions,
},
},
loading: false,
},
],
mobileView: this.widthQuery.matches,
};
private readonly encodeCache = new ResultCache();
// One for each side
private readonly workerBridges = [new WorkerBridge(), new WorkerBridge()];
/** Abort controller for actions that impact both sites, like source image decoding and preprocessing */
private mainAbortController = new AbortController();
// And again one for each side
private sideAbortControllers = [new AbortController(), new AbortController()];
/** For debouncing calls to updateImage for each side. */
private updateImageTimeout?: number;
constructor(props: Props) {
super(props);
this.widthQuery.addListener(this.onMobileWidthChange);
this.sourceFile = props.file;
this.queueUpdateImage({ immediate: true });
import('../sw-bridge').then(({ mainAppLoaded }) => mainAppLoaded());
}
private onMobileWidthChange = () => {
this.setState({ mobileView: this.widthQuery.matches });
};
private onEncoderTypeChange(index: 0 | 1, newType: OutputType): void {
this.setState({
sides: cleanSet(
this.state.sides,
`${index}.latestSettings.encoderState`,
newType === 'identity'
? undefined
: {
type: newType,
options: encoderMap[newType].meta.defaultOptions,
},
),
});
}
private onProcessorOptionsChange(
index: 0 | 1,
options: ProcessorState,
): void {
this.setState({
sides: cleanSet(
this.state.sides,
`${index}.latestSettings.processorState`,
options,
),
});
}
private onEncoderOptionsChange(index: 0 | 1, options: EncoderOptions): void {
this.setState({
sides: cleanSet(
this.state.sides,
`${index}.latestSettings.encoderState.options`,
options,
),
});
}
componentWillReceiveProps(nextProps: Props): void {
if (nextProps.file !== this.props.file) {
this.sourceFile = nextProps.file;
this.queueUpdateImage({ immediate: true });
}
}
componentWillUnmount(): void {
updateDocumentTitle();
this.mainAbortController.abort();
for (const controller of this.sideAbortControllers) {
controller.abort();
}
}
componentDidUpdate(prevProps: Props, prevState: State): void {
this.queueUpdateImage();
}
private async onCopyToOtherClick(index: 0 | 1) {
const otherIndex = index ? 0 : 1;
const oldSettings = this.state.sides[otherIndex];
const newSettings = { ...this.state.sides[index] };
// Create a new object URL for the new settings. This avoids both sides sharing a URL, which
// means it can be safely revoked without impacting the other side.
if (newSettings.file) {
newSettings.downloadUrl = URL.createObjectURL(newSettings.file);
}
this.setState({
sides: cleanSet(this.state.sides, otherIndex, newSettings),
});
const result = await this.props.showSnack('Settings copied across', {
timeout: 5000,
actions: ['undo', 'dismiss'],
});
if (result !== 'undo') return;
this.setState({
sides: cleanSet(this.state.sides, otherIndex, oldSettings),
});
}
private onPreprocessorChange = async (
preprocessorState: PreprocessorState,
): Promise<void> => {
const source = this.state.source;
if (!source) return;
const oldRotate = this.state.preprocessorState.rotate.rotate;
const newRotate = preprocessorState.rotate.rotate;
const orientationChanged = oldRotate % 180 !== newRotate % 180;
this.setState((state) => ({
loading: true,
preprocessorState,
// Flip resize values if orientation has changed
sides: !orientationChanged
? state.sides
: (state.sides.map((side) => {
const currentResizeSettings =
side.latestSettings.processorState.resize;
const resizeSettings: Partial<ProcessorState['resize']> = {
width: currentResizeSettings.height,
height: currentResizeSettings.width,
};
return cleanMerge(
side,
'latestSettings.processorState.resize',
resizeSettings,
);
}) as [Side, Side]),
}));
};
/**
* Debounce the heavy lifting of updateImage.
* Otherwise, the thrashing causes jank, and sometimes crashes iOS Safari.
*/
private queueUpdateImage({ immediate }: { immediate?: boolean } = {}): void {
// Call updateImage after this delay, unless queueUpdateImage is called
// again, in which case the timeout is reset.
const delay = 100;
clearTimeout(this.updateImageTimeout);
if (immediate) {
this.updateImage();
} else {
this.updateImageTimeout = setTimeout(() => this.updateImage(), delay);
}
}
private sourceFile: File;
/** The in-progress job for decoding and preprocessing */
private activeMainJob?: MainJob;
/** The in-progress job for each side (processing and encoding) */
private activeSideJobs: [SideJob?, SideJob?] = [undefined, undefined];
/**
* Perform image processing.
*
* This function is a monster, but I didn't want to break it up, because it
* never gets partially called. Instead, it looks at the current state, and
* decides which steps can be skipped, and which can be cached.
*/
private async updateImage() {
const currentState = this.state;
// State of the last completed job, or ongoing job
const latestMainJobState: Partial<MainJob> = this.activeMainJob || {
file: currentState.source && currentState.source.file,
preprocessorState: currentState.encodedPreprocessorState,
};
const latestSideJobStates: Partial<SideJob>[] = currentState.sides.map(
(side, i) =>
this.activeSideJobs[i] || {
processorState:
side.encodedSettings && side.encodedSettings.processorState,
encoderState:
side.encodedSettings && side.encodedSettings.encoderState,
},
);
// State for this job
const mainJobState: MainJob = {
file: this.sourceFile,
preprocessorState: currentState.preprocessorState,
};
const sideJobStates: SideJob[] = currentState.sides.map((side) => ({
// If there isn't an encoder selected, we don't process either
processorState: side.latestSettings.encoderState
? side.latestSettings.processorState
: defaultProcessorState,
encoderState: side.latestSettings.encoderState,
}));
// Figure out what needs doing:
const needsDecoding = latestMainJobState.file != mainJobState.file;
const needsPreprocessing =
needsDecoding ||
latestMainJobState.preprocessorState !== mainJobState.preprocessorState;
const sideWorksNeeded = latestSideJobStates.map((latestSideJob, i) => {
const needsProcessing =
needsPreprocessing ||
!latestSideJob.processorState ||
// If we're going to or from 'original image' we should reprocess
!!latestSideJob.encoderState !== !!sideJobStates[i].encoderState ||
!processorStateEquivalent(
latestSideJob.processorState,
sideJobStates[i].processorState,
);
return {
processing: needsProcessing,
encoding:
needsProcessing ||
latestSideJob.encoderState !== sideJobStates[i].encoderState,
};
});
let jobNeeded = false;
// Abort running tasks & cycle the controllers
if (needsDecoding || needsPreprocessing) {
this.mainAbortController.abort();
this.mainAbortController = new AbortController();
jobNeeded = true;
this.activeMainJob = mainJobState;
}
for (const [i, sideWorkNeeded] of sideWorksNeeded.entries()) {
if (sideWorkNeeded.processing || sideWorkNeeded.encoding) {
this.sideAbortControllers[i].abort();
this.sideAbortControllers[i] = new AbortController();
jobNeeded = true;
this.activeSideJobs[i] = sideJobStates[i];
}
}
if (!jobNeeded) return;
const mainSignal = this.mainAbortController.signal;
const sideSignals = this.sideAbortControllers.map((ac) => ac.signal);
let decoded: ImageData;
let vectorImage: HTMLImageElement | undefined;
// Handle decoding
if (needsDecoding) {
try {
assertSignal(mainSignal);
this.setState({
source: undefined,
loading: true,
});
// Special-case SVG. We need to avoid createImageBitmap because of
// https://bugs.chromium.org/p/chromium/issues/detail?id=606319.
// Also, we cache the HTMLImageElement so we can perform vector resizing later.
if (mainJobState.file.type.startsWith('image/svg+xml')) {
vectorImage = await processSvg(mainSignal, mainJobState.file);
decoded = drawableToImageData(vectorImage);
} else {
decoded = await decodeImage(
mainSignal,
mainJobState.file,
// Either worker is good enough here.
this.workerBridges[0],
);
}
// Set default resize values
this.setState((currentState) => {
if (mainSignal.aborted) return {};
const sides = currentState.sides.map((side) => {
const resizeState: Partial<ProcessorState['resize']> = {
width: decoded.width,
height: decoded.height,
// Disable resizing, to make it clearer to the user that something changed here
enabled: false,
};
return cleanMerge(
side,
'latestSettings.processorState.resize',
resizeState,
);
}) as [Side, Side];
return { sides };
});
} catch (err) {
if (err.name === 'AbortError') return;
this.props.showSnack(`Source decoding error: ${err}`);
throw err;
}
} else {
({ decoded, vectorImage } = currentState.source!);
}
let source: SourceImage;
// Handle preprocessing
if (needsPreprocessing) {
try {
assertSignal(mainSignal);
this.setState({
loading: true,
});
const preprocessed = await preprocessImage(
mainSignal,
decoded,
mainJobState.preprocessorState,
// Either worker is good enough here.
this.workerBridges[0],
);
source = {
decoded,
vectorImage,
preprocessed,
file: mainJobState.file,
};
// Update state for process completion, including intermediate render
this.setState((currentState) => {
if (mainSignal.aborted) return {};
let newState: State = {
...currentState,
loading: false,
source,
encodedPreprocessorState: mainJobState.preprocessorState,
sides: currentState.sides.map((side) => {
if (side.downloadUrl) URL.revokeObjectURL(side.downloadUrl);
const newSide: Side = {
...side,
// Intermediate render
data: preprocessed,
processed: undefined,
encodedSettings: undefined,
};
return newSide;
}) as [Side, Side],
};
newState = stateForNewSourceData(newState);
updateDocumentTitle(source.file.name);
return newState;
});
} catch (err) {
if (err.name === 'AbortError') return;
this.props.showSnack(`Preprocessing error: ${err}`);
throw err;
}
} else {
source = currentState.source!;
}
// That's the main part of the job done.
this.activeMainJob = undefined;
// Allow side jobs to happen in parallel
sideWorksNeeded.forEach(async (sideWorkNeeded, sideIndex) => {
try {
// If processing is true, encoding is always true.
if (!sideWorkNeeded.encoding) return;
const signal = sideSignals[sideIndex];
const jobState = sideJobStates[sideIndex];
const workerBridge = this.workerBridges[sideIndex];
let file: File;
let data: ImageData;
let processed: ImageData | undefined = undefined;
// If there's no encoder state, this is "original image", which also
// doesn't allow processing.
if (!jobState.encoderState) {
file = source.file;
data = source.preprocessed;
} else {
const cacheResult = this.encodeCache.match(
source.preprocessed,
jobState.processorState,
jobState.encoderState,
);
if (cacheResult) {
({ file, processed, data } = cacheResult);
} else {
// Set loading state for this side
this.setState((currentState) => {
if (signal.aborted) return {};
const sides = cleanMerge(currentState.sides, sideIndex, {
loading: true,
});
return { sides };
});
if (sideWorkNeeded.processing) {
processed = await processImage(
signal,
source,
jobState.processorState,
workerBridge,
);
// Update state for process completion, including intermediate render
this.setState((currentState) => {
if (signal.aborted) return {};
const currentSide = currentState.sides[sideIndex];
const side: Side = {
...currentSide,
processed,
// Intermediate render
data: processed,
encodedSettings: {
...currentSide.encodedSettings,
processorState: jobState.processorState,
},
};
const sides = cleanSet(currentState.sides, sideIndex, side);
return { sides };
});
} else {
processed = currentState.sides[sideIndex].processed!;
}
file = await compressImage(
signal,
processed,
jobState.encoderState,
source.file.name,
workerBridge,
);
data = await decodeImage(signal, file, workerBridge);
this.encodeCache.add({
data,
processed,
file,
preprocessed: source.preprocessed,
encoderState: jobState.encoderState,
processorState: jobState.processorState,
});
}
}
this.setState((currentState) => {
if (signal.aborted) return {};
const currentSide = currentState.sides[sideIndex];
if (currentSide.downloadUrl) {
URL.revokeObjectURL(currentSide.downloadUrl);
}
const side: Side = {
...currentSide,
data,
file,
downloadUrl: URL.createObjectURL(file),
loading: false,
processed,
encodedSettings: {
processorState: jobState.processorState,
encoderState: jobState.encoderState,
},
};
const sides = cleanSet(currentState.sides, sideIndex, side);
return { sides };
});
this.activeSideJobs[sideIndex] = undefined;
} catch (err) {
if (err.name === 'AbortError') return;
this.props.showSnack(`Processing error: ${err}`);
throw err;
}
});
}
render(
{ onBack }: Props,
{ loading, sides, source, mobileView, preprocessorState }: State,
) {
const [leftSide, rightSide] = sides;
const [leftImageData, rightImageData] = sides.map((i) => i.data);
const options = sides.map((side, index) => (
<Options
source={source}
mobileView={mobileView}
processorState={side.latestSettings.processorState}
encoderState={side.latestSettings.encoderState}
onEncoderTypeChange={this.onEncoderTypeChange.bind(
this,
index as 0 | 1,
)}
onEncoderOptionsChange={this.onEncoderOptionsChange.bind(
this,
index as 0 | 1,
)}
onProcessorOptionsChange={this.onProcessorOptionsChange.bind(
this,
index as 0 | 1,
)}
/>
));
const copyDirections = (mobileView
? ['down', 'up']
: ['right', 'left']) as CopyAcrossIconProps['copyDirection'][];
const results = sides.map((side, index) => (
<Results
downloadUrl={side.downloadUrl}
imageFile={side.file}
source={source}
loading={loading || side.loading}
copyDirection={copyDirections[index]}
onCopyToOtherClick={this.onCopyToOtherClick.bind(this, index as 0 | 1)}
buttonPosition={mobileView ? 'stack-right' : buttonPositions[index]}
>
{!mobileView
? null
: [
<ExpandIcon class={style.expandIcon} key="expand-icon" />,
`${resultTitles[index]} (${
side.latestSettings.encoderState
? encoderMap[side.latestSettings.encoderState.type].meta.label
: 'Original Image'
})`,
]}
</Results>
));
// For rendering, we ideally want the settings that were used to create the
// data, not the latest settings.
const leftDisplaySettings =
leftSide.encodedSettings || leftSide.latestSettings;
const rightDisplaySettings =
rightSide.encodedSettings || rightSide.latestSettings;
const leftImgContain =
leftDisplaySettings.processorState.resize.enabled &&
leftDisplaySettings.processorState.resize.fitMethod === 'contain';
const rightImgContain =
rightDisplaySettings.processorState.resize.enabled &&
rightDisplaySettings.processorState.resize.fitMethod === 'contain';
return (
<div class={style.compress}>
<Output
source={source}
mobileView={mobileView}
leftCompressed={leftImageData}
rightCompressed={rightImageData}
leftImgContain={leftImgContain}
rightImgContain={rightImgContain}
onBack={onBack}
preprocessorState={preprocessorState}
onPreprocessorChange={this.onPreprocessorChange}
/>
{mobileView ? (
<div class={style.options}>
<multi-panel class={style.multiPanel} open-one-only>
{results[0]}
{options[0]}
{results[1]}
{options[1]}
</multi-panel>
</div>
) : (
[
<div class={style.options} key="options0">
{options[0]}
{results[0]}
</div>,
<div class={style.options} key="options1">
{options[1]}
{results[1]}
</div>,
]
)}
</div>
);
}
}

View File

@@ -0,0 +1,70 @@
import { EncoderState, ProcessorState } from '../feature-meta';
import { shallowEqual } from '../util';
interface CacheResult {
processed: ImageData;
data: ImageData;
file: File;
}
interface CacheEntry extends CacheResult {
processorState: ProcessorState;
encoderState: EncoderState;
preprocessed: ImageData;
}
const SIZE = 5;
export default class ResultCache {
private readonly _entries: CacheEntry[] = [];
add(entry: CacheEntry) {
// Add the new entry to the start
this._entries.unshift(entry);
// Remove the last entry if we're now bigger than SIZE
if (this._entries.length > SIZE) this._entries.pop();
}
match(
preprocessed: ImageData,
processorState: ProcessorState,
encoderState: EncoderState,
): CacheResult | undefined {
const matchingIndex = this._entries.findIndex((entry) => {
// Check for quick exits:
if (entry.preprocessed !== preprocessed) return false;
if (entry.encoderState.type !== encoderState.type) return false;
// Check that each set of options in the preprocessor are the same
for (const prop in processorState) {
if (
!shallowEqual(
(processorState as any)[prop],
(entry.processorState as any)[prop],
)
) {
return false;
}
}
// Check detailed encoder options
if (!shallowEqual(encoderState.options, entry.encoderState.options)) {
return false;
}
return true;
});
if (matchingIndex === -1) return undefined;
const matchingEntry = this._entries[matchingIndex];
if (matchingIndex !== 0) {
// Move the matched result to 1st position (LRU)
this._entries.splice(matchingIndex, 1);
this._entries.unshift(matchingEntry);
}
return { ...matchingEntry };
}
}

View File

@@ -0,0 +1,75 @@
.compress {
width: 100%;
height: 100%;
contain: strict;
display: grid;
align-items: end;
align-content: end;
grid-template-rows: 1fr auto;
@media (min-width: 600px) {
grid-template-columns: 1fr auto;
grid-template-rows: 100%;
}
}
.options {
color: #fff;
opacity: 0.9;
font-size: 1.2rem;
display: flex;
flex-flow: column;
max-width: 400px;
margin: 0 auto;
width: calc(100% - 60px);
max-height: calc(100% - 104px);
overflow: hidden;
@media (min-width: 600px) {
max-height: calc(100% - 75px);
width: 300px;
margin: 0;
}
@media (min-width: 860px) {
max-height: calc(100% - 40px);
}
}
.multi-panel {
position: relative;
display: flex;
flex-flow: column;
overflow: hidden;
/* Reorder so headings appear after content: */
& > :nth-child(1) {
order: 2;
margin-bottom: 10px;
}
& > :nth-child(2) {
order: 1;
}
& > :nth-child(3) {
order: 4;
}
& > :nth-child(4) {
order: 3;
}
}
.expand-icon {
transform: rotate(180deg);
margin-left: -12px;
}
[content-expanded] .expand-icon {
transform: none;
}
:focus .expand-icon {
fill: #34b9eb;
}