forked from external-repos/squoosh
588 lines
17 KiB
TypeScript
588 lines
17 KiB
TypeScript
import {
|
|
h,
|
|
Component,
|
|
Fragment,
|
|
createRef,
|
|
FunctionComponent,
|
|
ComponentChildren,
|
|
} from 'preact';
|
|
import type {
|
|
default as PinchZoom,
|
|
ScaleToOpts,
|
|
} from '../Output/custom-els/PinchZoom';
|
|
import '../Output/custom-els/PinchZoom';
|
|
import * as style from './style.css';
|
|
import 'add-css:./style.css';
|
|
import {
|
|
AddIcon,
|
|
CheckmarkIcon,
|
|
CompareIcon,
|
|
FlipHorizontallyIcon,
|
|
FlipVerticallyIcon,
|
|
RemoveIcon,
|
|
RotateClockwiseIcon,
|
|
RotateCounterClockwiseIcon,
|
|
SwapIcon,
|
|
} from '../../icons';
|
|
import { cleanSet } from '../../util/clean-modify';
|
|
import type { SourceImage } from '../../Compress';
|
|
import { PreprocessorState } from 'client/lazy-app/feature-meta';
|
|
import Cropper, { CropBox } from './Cropper';
|
|
import CanvasImage from '../CanvasImage';
|
|
import Expander from '../Options/Expander';
|
|
import Select from '../Options/Select';
|
|
import Checkbox from '../Options/Checkbox';
|
|
|
|
const ROTATE_ORIENTATIONS = [0, 90, 180, 270] as const;
|
|
|
|
const cropPresets = {
|
|
square: {
|
|
name: 'Square',
|
|
ratio: 1,
|
|
},
|
|
'4:3': {
|
|
name: '4:3',
|
|
ratio: 4 / 3,
|
|
},
|
|
'16:9': {
|
|
name: '16:9',
|
|
ratio: 16 / 9,
|
|
},
|
|
'16:10': {
|
|
name: '16:10',
|
|
ratio: 16 / 10,
|
|
},
|
|
};
|
|
|
|
type CropPresetId = keyof typeof cropPresets;
|
|
|
|
interface Props {
|
|
source: SourceImage;
|
|
preprocessorState: PreprocessorState;
|
|
mobileView: boolean;
|
|
onCancel?(): void;
|
|
onSave?(e: { preprocessorState: PreprocessorState }): void;
|
|
}
|
|
|
|
interface State {
|
|
scale: number;
|
|
editingScale: boolean;
|
|
rotate: typeof ROTATE_ORIENTATIONS[number];
|
|
// crop: false | CropBox;
|
|
crop: CropBox;
|
|
cropPreset: keyof typeof cropPresets | undefined;
|
|
lockAspect: boolean;
|
|
flip: PreprocessorState['flip'];
|
|
}
|
|
|
|
const scaleToOpts: ScaleToOpts = {
|
|
originX: '50%',
|
|
originY: '50%',
|
|
relativeTo: 'container',
|
|
allowChangeEvent: true,
|
|
};
|
|
|
|
export default class Transform extends Component<Props, State> {
|
|
state: State = {
|
|
scale: 1,
|
|
editingScale: false,
|
|
cropPreset: undefined,
|
|
lockAspect: false,
|
|
...this.fromPreprocessorState(this.props.preprocessorState),
|
|
};
|
|
pinchZoom = createRef<PinchZoom>();
|
|
scaleInput = createRef<HTMLInputElement>();
|
|
|
|
// static getDerivedStateFromProps({ source, preprocessorState }: Props) {
|
|
// return {
|
|
// rotate: preprocessorState.rotate.rotate || 0,
|
|
// crop: preprocessorState.crop || false,
|
|
// flip: preprocessorState.flip || { horizontal: false, vertical: false },
|
|
// };
|
|
// }
|
|
|
|
componentWillReceiveProps(
|
|
{ source, preprocessorState }: Props,
|
|
{ crop, cropPreset }: State,
|
|
) {
|
|
if (preprocessorState !== this.props.preprocessorState) {
|
|
this.setState(this.fromPreprocessorState(preprocessorState));
|
|
}
|
|
const { width, height } = source.decoded;
|
|
const cropWidth = width - crop.left - crop.right;
|
|
const cropHeight = height - crop.top - crop.bottom;
|
|
for (const [id, preset] of Object.entries(cropPresets)) {
|
|
if (cropHeight * preset.ratio === cropWidth) {
|
|
if (cropPreset !== id) {
|
|
this.setState({ cropPreset: id as CropPresetId });
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
private fromPreprocessorState(preprocessorState?: PreprocessorState) {
|
|
const state: Pick<State, 'rotate' | 'crop' | 'flip'> = {
|
|
rotate: preprocessorState ? preprocessorState.rotate.rotate : 0,
|
|
crop: Object.assign(
|
|
{
|
|
left: 0,
|
|
right: 0,
|
|
top: 0,
|
|
bottom: 0,
|
|
},
|
|
(preprocessorState && preprocessorState.crop) || {},
|
|
),
|
|
flip: Object.assign(
|
|
{
|
|
horizontal: false,
|
|
vertical: false,
|
|
},
|
|
(preprocessorState && preprocessorState.flip) || {},
|
|
),
|
|
};
|
|
return state;
|
|
}
|
|
|
|
private save = () => {
|
|
const { preprocessorState, onSave } = this.props;
|
|
const { rotate, crop, flip } = this.state;
|
|
|
|
let newState = cleanSet(preprocessorState, 'rotate.rotate', rotate);
|
|
newState = cleanSet(newState, 'crop', crop);
|
|
newState = cleanSet(newState, 'flip', flip);
|
|
|
|
if (onSave) onSave({ preprocessorState: newState });
|
|
};
|
|
|
|
private cancel = () => {
|
|
const { onCancel, onSave } = this.props;
|
|
if (onCancel) onCancel();
|
|
else if (onSave)
|
|
onSave({ preprocessorState: this.props.preprocessorState });
|
|
};
|
|
|
|
private zoomIn = () => {
|
|
if (!this.pinchZoom.current) throw Error('Missing pinch-zoom element');
|
|
this.pinchZoom.current.scaleTo(this.state.scale * 1.25, scaleToOpts);
|
|
};
|
|
|
|
private zoomOut = () => {
|
|
if (!this.pinchZoom.current) throw Error('Missing pinch-zoom element');
|
|
this.pinchZoom.current.scaleTo(this.state.scale / 1.25, scaleToOpts);
|
|
};
|
|
|
|
private onScaleValueFocus = () => {
|
|
this.setState({ editingScale: true }, () => {
|
|
if (this.scaleInput.current) {
|
|
// 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.current).transform;
|
|
this.scaleInput.current.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.pinchZoom.current) throw Error('Missing pinch-zoom element');
|
|
|
|
this.pinchZoom.current.scaleTo(percent / 100, scaleToOpts);
|
|
};
|
|
|
|
private onPinchZoomChange = () => {
|
|
if (!this.pinchZoom.current) throw Error('Missing pinch-zoom element');
|
|
this.setState({
|
|
scale: this.pinchZoom.current.scale,
|
|
});
|
|
};
|
|
|
|
private onCropChange = (crop: CropBox) => {
|
|
this.setState({ crop });
|
|
};
|
|
|
|
private onCropPresetChange = (event: Event) => {
|
|
const { value } = event.target as HTMLSelectElement;
|
|
const cropPreset = value ? (value as keyof typeof cropPresets) : undefined;
|
|
const crop = { ...this.state.crop };
|
|
if (cropPreset) {
|
|
const preset = cropPresets[cropPreset];
|
|
const { width, height } = this.props.source.decoded;
|
|
const w = width - crop.left - crop.right;
|
|
const h = w / preset.ratio;
|
|
crop.bottom = height - crop.top - h;
|
|
if (crop.bottom < 0) {
|
|
crop.top += crop.bottom;
|
|
crop.bottom = 0;
|
|
}
|
|
}
|
|
this.setState({
|
|
crop,
|
|
cropPreset,
|
|
lockAspect: !!cropPreset,
|
|
});
|
|
};
|
|
|
|
private swapCropDimensions = () => {
|
|
const { width, height } = this.props.source.decoded;
|
|
let { left, right, top, bottom } = this.state.crop;
|
|
const cropWidth = width - left - right;
|
|
const cropHeight = height - top - bottom;
|
|
const centerX = left - right;
|
|
const centerY = top - bottom;
|
|
const crop = {
|
|
top: (width - cropWidth) / 2 + centerY / 2,
|
|
bottom: (width - cropWidth) / 2 - centerY / 2,
|
|
left: (height - cropHeight) / 2 + centerX / 2,
|
|
right: (height - cropHeight) / 2 - centerX / 2,
|
|
};
|
|
this.setCrop(crop);
|
|
};
|
|
|
|
private setCrop(crop: CropBox) {
|
|
if (crop.top < 0) {
|
|
crop.bottom += crop.top;
|
|
crop.top = 0;
|
|
}
|
|
if (crop.bottom < 0) {
|
|
crop.top += crop.bottom;
|
|
crop.bottom = 0;
|
|
}
|
|
if (crop.left < 0) {
|
|
crop.right += crop.left;
|
|
crop.left = 0;
|
|
}
|
|
if (crop.right < 0) {
|
|
crop.left += crop.right;
|
|
crop.right = 0;
|
|
}
|
|
this.setState({ crop });
|
|
}
|
|
|
|
// yeah these could just += 90
|
|
private rotateClockwise = () => {
|
|
let { rotate, crop } = this.state;
|
|
this.setState({
|
|
rotate: ((rotate + 90) % 360) as typeof ROTATE_ORIENTATIONS[number],
|
|
crop: {
|
|
top: crop.left,
|
|
left: crop.bottom,
|
|
bottom: crop.right,
|
|
right: crop.top,
|
|
},
|
|
});
|
|
};
|
|
|
|
private rotateCounterClockwise = () => {
|
|
let { rotate, crop } = this.state;
|
|
this.setState({
|
|
rotate: (rotate
|
|
? rotate - 90
|
|
: 270) as typeof ROTATE_ORIENTATIONS[number],
|
|
crop: {
|
|
top: crop.right,
|
|
right: crop.bottom,
|
|
bottom: crop.left,
|
|
left: crop.top,
|
|
},
|
|
});
|
|
};
|
|
|
|
private flipHorizontally = () => {
|
|
const { horizontal, vertical } = this.state.flip;
|
|
this.setState({ flip: { horizontal: !horizontal, vertical } });
|
|
};
|
|
|
|
private flipVertically = () => {
|
|
const { horizontal, vertical } = this.state.flip;
|
|
this.setState({ flip: { horizontal, vertical: !vertical } });
|
|
};
|
|
|
|
private toggleLockAspect = () => {
|
|
this.setState({ lockAspect: !this.state.lockAspect });
|
|
};
|
|
|
|
private setCropWidth = (
|
|
event: preact.JSX.TargetedEvent<HTMLInputElement, Event>,
|
|
) => {
|
|
const { width, height } = this.props.source.decoded;
|
|
const newWidth = Math.min(width, parseInt(event.currentTarget.value, 10));
|
|
let { top, right, bottom, left } = this.state.crop;
|
|
const aspect = (width - left - right) / (height - top - bottom);
|
|
right = width - newWidth - left;
|
|
if (this.state.lockAspect) {
|
|
const newHeight = newWidth / aspect;
|
|
if (newHeight > height) return;
|
|
bottom = height - newHeight - top;
|
|
}
|
|
this.setCrop({ top, right, bottom, left });
|
|
};
|
|
|
|
private setCropHeight = (
|
|
event: preact.JSX.TargetedEvent<HTMLInputElement, Event>,
|
|
) => {
|
|
const { width, height } = this.props.source.decoded;
|
|
const newHeight = Math.min(height, parseInt(event.currentTarget.value, 10));
|
|
let { top, right, bottom, left } = this.state.crop;
|
|
const aspect = (width - left - right) / (height - top - bottom);
|
|
bottom = height - newHeight - top;
|
|
if (this.state.lockAspect) {
|
|
const newWidth = newHeight * aspect;
|
|
if (newWidth > width) return;
|
|
right = width - newWidth - left;
|
|
}
|
|
this.setCrop({ top, right, bottom, left });
|
|
};
|
|
|
|
render(
|
|
{ mobileView, source }: Props,
|
|
{ scale, editingScale, rotate, flip, crop, cropPreset, lockAspect }: State,
|
|
) {
|
|
const image = source.decoded;
|
|
|
|
const width = source.decoded.width - crop.left - crop.right;
|
|
const height = source.decoded.height - crop.top - crop.bottom;
|
|
|
|
let transform =
|
|
`rotate(${rotate}deg) ` +
|
|
`scale(${flip.horizontal ? -1 : 1}, ${flip.vertical ? -1 : 1})`;
|
|
|
|
return (
|
|
<Fragment>
|
|
<CancelButton onClick={this.cancel} />
|
|
<SaveButton onClick={this.save} />
|
|
|
|
<div class={style.transform}>
|
|
<pinch-zoom
|
|
class={style.pinchZoom}
|
|
onChange={this.onPinchZoomChange}
|
|
ref={this.pinchZoom}
|
|
>
|
|
{/* <Backdrop width={image.width} height={image.height} /> */}
|
|
<div
|
|
class={style.wrap}
|
|
style={{
|
|
width: image.width,
|
|
height: image.height,
|
|
}}
|
|
>
|
|
<CanvasImage
|
|
class={style.pinchTarget}
|
|
image={image}
|
|
style={{ transform }}
|
|
/>
|
|
{crop && (
|
|
<Cropper
|
|
size={{ width: image.width, height: image.height }}
|
|
scale={scale}
|
|
lockAspect={lockAspect}
|
|
crop={crop}
|
|
onChange={this.onCropChange}
|
|
/>
|
|
)}
|
|
</div>
|
|
</pinch-zoom>
|
|
</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={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>
|
|
|
|
<div class={style.options}>
|
|
<h3 class={style.optionsTitle}>Modify Source</h3>
|
|
|
|
<div class={style.optionsSection}>
|
|
<h4 class={style.optionsSectionTitle}>Crop</h4>
|
|
<div class={style.optionOneCell}>
|
|
<Select
|
|
large
|
|
value={cropPreset}
|
|
onChange={this.onCropPresetChange}
|
|
>
|
|
<option value="">Custom</option>
|
|
{Object.entries(cropPresets).map(([type, preset]) => (
|
|
<option value={type}>{preset.name}</option>
|
|
))}
|
|
</Select>
|
|
</div>
|
|
<label class={style.optionCheckbox}>
|
|
<Checkbox checked={lockAspect} onClick={this.toggleLockAspect} />
|
|
Lock aspect-ratio
|
|
</label>
|
|
<div class={style.optionsDimensions}>
|
|
<input
|
|
type="number"
|
|
name="width"
|
|
value={width}
|
|
title="Crop width"
|
|
onInput={this.setCropWidth}
|
|
/>
|
|
<button
|
|
class={style.optionsButton}
|
|
title="swap"
|
|
onClick={this.swapCropDimensions}
|
|
>
|
|
<SwapIcon />
|
|
</button>
|
|
<input
|
|
type="number"
|
|
name="height"
|
|
value={height}
|
|
title="Crop height"
|
|
onInput={this.setCropHeight}
|
|
/>
|
|
</div>
|
|
|
|
<div class={style.optionButtonRow}>
|
|
Flip
|
|
<button
|
|
class={style.optionsButton}
|
|
data-active={flip.vertical}
|
|
title="Flip vertically"
|
|
onClick={this.flipVertically}
|
|
>
|
|
<FlipVerticallyIcon />
|
|
</button>
|
|
<button
|
|
class={style.optionsButton}
|
|
data-active={flip.horizontal}
|
|
title="Flip horizontally"
|
|
onClick={this.flipHorizontally}
|
|
>
|
|
<FlipHorizontallyIcon />
|
|
</button>
|
|
</div>
|
|
|
|
<div class={style.optionButtonRow}>
|
|
Rotate
|
|
<button
|
|
class={style.optionsButton}
|
|
title="Rotate clockwise"
|
|
onClick={this.rotateClockwise}
|
|
>
|
|
<RotateClockwiseIcon />
|
|
</button>
|
|
<button
|
|
class={style.optionsButton}
|
|
title="Rotate counter-clockwise"
|
|
onClick={this.rotateCounterClockwise}
|
|
>
|
|
<RotateCounterClockwiseIcon />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Fragment>
|
|
);
|
|
}
|
|
}
|
|
|
|
const CancelButton = ({ onClick }: { onClick: () => void }) => (
|
|
<button class={style.cancel} onClick={onClick}>
|
|
<svg viewBox="0 0 80 80" width="80" height="80">
|
|
<path d="M8.06 40.98c-.53-7.1 4.05-14.52 9.98-19.1s13.32-6.35 22.13-6.43c8.84-.12 19.12 1.51 24.4 7.97s5.6 17.74 1.68 26.97c-3.89 9.26-11.97 16.45-20.46 18-8.43 1.55-17.28-2.62-24.5-8.08S8.54 48.08 8.07 40.98z" />
|
|
</svg>
|
|
<CompareIcon class={style.icon} />
|
|
<span>Cancel</span>
|
|
</button>
|
|
);
|
|
|
|
const SaveButton = ({ onClick }: { onClick: () => void }) => (
|
|
<button class={style.save} onClick={onClick}>
|
|
<svg viewBox="0 0 89 87" width="89" height="87">
|
|
<path
|
|
fill="#0c99ff"
|
|
opacity=".7"
|
|
d="M27.3 71.9c-8-4-15.6-12.3-16.9-21-1.2-8.7 4-17.8 10.5-26s14.4-15.6 24-16 21.2 6 28.6 16.5c7.4 10.5 10.8 25 6.6 34S64.1 71.7 54 73.5c-10.2 2-18.7 2.3-26.7-1.6z"
|
|
/>
|
|
<path
|
|
fill="#0c99ff"
|
|
opacity=".7"
|
|
d="M14.6 24.8c4.3-7.8 13-15 21.8-15.7 8.7-.8 17.5 4.8 25.4 11.8 7.8 6.9 14.8 15.2 14.8 24.9s-7.2 20.7-18 27.6c-10.9 6.8-25.6 9.5-34.3 4.8S13 61.6 11.6 51.4c-1.3-10.3-1.3-18.8 3-26.6z"
|
|
/>
|
|
</svg>
|
|
<CheckmarkIcon class={style.icon} />
|
|
</button>
|
|
);
|
|
|
|
interface BackdropProps {
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
/** @TODO this could at least use clip-path. It's too expensive this way. */
|
|
class Backdrop extends Component<BackdropProps> {
|
|
shouldComponentUpdate({ width, height }: BackdropProps) {
|
|
return width !== this.props.width || height !== this.props.height;
|
|
}
|
|
|
|
render({ width, height }: BackdropProps) {
|
|
const transform =
|
|
`transform-origin: 50% 50%; transform: translate(var(--x), var(--y)) ` +
|
|
`translate(-${width / 2}px, -${height / 2}px) ` +
|
|
`scale(calc(var(--scale, 1) * 0.99999));`;
|
|
return (
|
|
<svg
|
|
class={style.backdrop}
|
|
preserveAspectRatio="xMidYMid meet"
|
|
width="100%"
|
|
height="100%"
|
|
shape-rendering="optimizeSpeed"
|
|
>
|
|
<mask id="bgmask">
|
|
<rect width="100%" height="100%" fill="white" />
|
|
<rect
|
|
style={transform}
|
|
width={width}
|
|
height={height}
|
|
x="50%"
|
|
y="50%"
|
|
fill="black"
|
|
/>
|
|
</mask>
|
|
<rect
|
|
class={style.backdropArea}
|
|
width="100%"
|
|
height="100%"
|
|
mask="url(#bgmask)"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
}
|