mirror of
https://github.com/GoogleChromeLabs/squoosh.git
synced 2025-11-14 09:39:15 +00:00
Rollup build
This commit is contained in:
23
src/client/lazy-app/Compress/Options/Checkbox/index.tsx
Normal file
23
src/client/lazy-app/Compress/Options/Checkbox/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
22
src/client/lazy-app/Compress/Options/Checkbox/style.css
Normal file
22
src/client/lazy-app/Compress/Options/Checkbox/style.css
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;
|
||||
}
|
||||
66
src/client/lazy-app/Compress/Options/Expander/index.tsx
Normal file
66
src/client/lazy-app/Compress/Options/Expander/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
5
src/client/lazy-app/Compress/Options/Expander/style.css
Normal file
5
src/client/lazy-app/Compress/Options/Expander/style.css
Normal file
@@ -0,0 +1,5 @@
|
||||
.children-exiting {
|
||||
& > * {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
9
src/client/lazy-app/Compress/Options/Range/custom-els/RangeInput/missing-types.d.ts
vendored
Normal file
9
src/client/lazy-app/Compress/Options/Range/custom-els/RangeInput/missing-types.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
declare module 'preact' {
|
||||
namespace createElement.JSX {
|
||||
interface IntrinsicElements {
|
||||
'range-input': HTMLAttributes;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -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);
|
||||
}
|
||||
55
src/client/lazy-app/Compress/Options/Range/index.tsx
Normal file
55
src/client/lazy-app/Compress/Options/Range/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
55
src/client/lazy-app/Compress/Options/Range/style.css
Normal file
55
src/client/lazy-app/Compress/Options/Range/style.css
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;
|
||||
}
|
||||
}
|
||||
27
src/client/lazy-app/Compress/Options/Select/index.tsx
Normal file
27
src/client/lazy-app/Compress/Options/Select/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
33
src/client/lazy-app/Compress/Options/Select/style.css
Normal file
33
src/client/lazy-app/Compress/Options/Select/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
190
src/client/lazy-app/Compress/Options/index.tsx
Normal file
190
src/client/lazy-app/Compress/Options/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
58
src/client/lazy-app/Compress/Options/style.css
Normal file
58
src/client/lazy-app/Compress/Options/style.css
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
9
src/client/lazy-app/Compress/Output/custom-els/PinchZoom/missing-types.d.ts
vendored
Normal file
9
src/client/lazy-app/Compress/Output/custom-els/PinchZoom/missing-types.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
declare module 'preact' {
|
||||
namespace createElement.JSX {
|
||||
interface IntrinsicElements {
|
||||
'pinch-zoom': HTMLAttributes;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -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;
|
||||
}
|
||||
172
src/client/lazy-app/Compress/Output/custom-els/TwoUp/index.ts
Normal file
172
src/client/lazy-app/Compress/Output/custom-els/TwoUp/index.ts
Normal 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);
|
||||
14
src/client/lazy-app/Compress/Output/custom-els/TwoUp/missing-types.d.ts
vendored
Normal file
14
src/client/lazy-app/Compress/Output/custom-els/TwoUp/missing-types.d.ts
vendored
Normal 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 {};
|
||||
131
src/client/lazy-app/Compress/Output/custom-els/TwoUp/styles.css
Normal file
131
src/client/lazy-app/Compress/Output/custom-els/TwoUp/styles.css
Normal 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);
|
||||
}
|
||||
}
|
||||
377
src/client/lazy-app/Compress/Output/index.tsx
Normal file
377
src/client/lazy-app/Compress/Output/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
166
src/client/lazy-app/Compress/Output/style.css
Normal file
166
src/client/lazy-app/Compress/Output/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
43
src/client/lazy-app/Compress/Results/FileSize.tsx
Normal file
43
src/client/lazy-app/Compress/Results/FileSize.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
135
src/client/lazy-app/Compress/Results/index.tsx
Normal file
135
src/client/lazy-app/Compress/Results/index.tsx
Normal 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 can’t 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
131
src/client/lazy-app/Compress/Results/style.css
Normal file
131
src/client/lazy-app/Compress/Results/style.css
Normal 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;
|
||||
}
|
||||
321
src/client/lazy-app/Compress/custom-els/MultiPanel/index.ts
Normal file
321
src/client/lazy-app/Compress/custom-els/MultiPanel/index.ts
Normal 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;
|
||||
|
||||
// don’t 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);
|
||||
13
src/client/lazy-app/Compress/custom-els/MultiPanel/missing-types.d.ts
vendored
Normal file
13
src/client/lazy-app/Compress/custom-els/MultiPanel/missing-types.d.ts
vendored
Normal 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 {};
|
||||
@@ -0,0 +1,10 @@
|
||||
.panel-heading {
|
||||
background: gray;
|
||||
}
|
||||
.panel-content {
|
||||
height: 0px;
|
||||
overflow: auto;
|
||||
}
|
||||
.panel-content[aria-expanded='true'] {
|
||||
height: auto;
|
||||
}
|
||||
886
src/client/lazy-app/Compress/index.tsx
Normal file
886
src/client/lazy-app/Compress/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
70
src/client/lazy-app/Compress/result-cache.ts
Normal file
70
src/client/lazy-app/Compress/result-cache.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
75
src/client/lazy-app/Compress/style.css
Normal file
75
src/client/lazy-app/Compress/style.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user