Files
squoosh/src/client/lazy-app/Compress/Transform/index.tsx
2020-12-09 11:22:22 -05:00

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