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

331 lines
9.2 KiB
TypeScript

import { h, Component, ComponentChildren } from 'preact';
import * as style from './style.css';
import 'add-css:./style.css';
import { shallowEqual } from 'client/lazy-app/util';
export interface CropBox {
left: number;
top: number;
right: number;
bottom: number;
}
// Minimum CropBox size
const MIN_SIZE = 2;
export interface Props {
size: { width: number; height: number };
scale?: number;
lockAspect?: boolean;
crop: CropBox;
onChange?(crop: CropBox): void;
}
type Edge = keyof CropBox;
interface PointerTrack {
x: number;
y: number;
edges: { edge: Edge; value: number }[];
aspect: number | undefined;
}
interface State {
crop: CropBox;
pan: boolean;
}
export default class Cropper extends Component<Props, State> {
private pointers = new Map<number, PointerTrack>();
state = {
crop: this.normalizeCrop({ ...this.props.crop }),
pan: false,
};
shouldComponentUpdate(nextProps: Props, nextState: State) {
if (!shallowEqual(nextState, this.state)) return true;
const { size, scale, lockAspect, crop } = this.props;
return (
size.width !== nextProps.size.width ||
size.height !== nextProps.size.height ||
scale !== nextProps.scale ||
lockAspect !== nextProps.lockAspect ||
!shallowEqual(crop, nextProps.crop)
);
}
componentWillReceiveProps({ crop }: Props, nextState: State) {
const current = nextState.crop || this.state.crop;
if (crop !== this.props.crop && !shallowEqual(crop, current)) {
// this.setState({ crop: nextProps.crop });
this.setCrop(crop);
}
}
private normalizeCrop(crop: CropBox) {
crop.left = Math.round(Math.max(0, crop.left));
crop.top = Math.round(Math.max(0, crop.top));
crop.right = Math.round(Math.max(0, crop.right));
crop.bottom = Math.round(Math.max(0, crop.bottom));
return crop;
}
private setCrop(cropUpdate: Partial<CropBox>) {
const crop = this.normalizeCrop({ ...this.state.crop, ...cropUpdate });
// ignore crop updates that normalize to the same values
const old = this.state.crop;
if (
crop.left === old.left &&
crop.right === old.right &&
crop.top === old.top &&
crop.bottom === old.bottom
) {
return;
}
this.setState({ crop });
if (this.props.onChange) {
this.props.onChange(crop);
}
}
private onPointerDown = (event: PointerEvent) => {
if (event.button !== 0 || this.state.pan) return;
const target = event.target as SVGElement;
const edgeAttr = target.getAttribute('data-edge');
if (edgeAttr) {
event.stopPropagation();
event.preventDefault();
let aspect;
const edges = edgeAttr.split(/ *, */) as Edge[];
if (this.props.lockAspect) {
if (edges.length === 1) return;
const { size } = this.props;
const oldCrop = this.state.crop;
aspect =
(size.width - oldCrop.left - oldCrop.right) /
(size.height - oldCrop.top - oldCrop.bottom);
}
this.pointers.set(event.pointerId, {
x: event.x,
y: event.y,
edges: edges.map((edge) => ({ edge, value: this.state.crop[edge] })),
aspect,
});
target.setPointerCapture(event.pointerId);
}
};
private onPointerMove = (event: PointerEvent) => {
const target = event.target as SVGElement;
const down = this.pointers.get(event.pointerId);
if (down && target.hasPointerCapture(event.pointerId)) {
const { size } = this.props;
const oldCrop = this.state.crop;
const scale = this.props.scale || 1;
let dx = (event.x - down.x) / scale;
let dy = (event.y - down.y) / scale;
if (down.aspect && down.edges.length === 2) {
const dir = (dx + dy) / 2;
dx = dir * down.aspect;
dy = dir / down.aspect;
}
const crop: Partial<CropBox> = {};
for (const { edge, value } of down.edges) {
let edgeValue = value;
switch (edge) {
case 'left':
edgeValue += dx;
break;
case 'right':
edgeValue -= dx;
break;
case 'top':
edgeValue += dy;
break;
case 'bottom':
edgeValue -= dy;
break;
}
crop[edge] = edgeValue;
}
// Prevent MOVE from resizing the cropbox:
if (crop.left && crop.right) {
if (crop.left < 0) crop.right += crop.left;
if (crop.right < 0) crop.left += crop.right;
} else {
// enforce minimum 1px cropbox width
if (crop.left) {
if (down.aspect) crop.left = Math.max(0, crop.left);
else
crop.left = Math.min(
crop.left,
size.width - oldCrop.right - MIN_SIZE,
);
}
if (crop.right) {
if (down.aspect) crop.right = Math.max(0, crop.right);
crop.right = Math.min(
crop.right,
size.width - oldCrop.left - MIN_SIZE,
);
}
if (
down.aspect &&
(crop.left ?? oldCrop.left) + (crop.right ?? oldCrop.right) >
size.width
)
return;
}
if (crop.top && crop.bottom) {
if (crop.top < 0) crop.bottom += crop.top;
if (crop.bottom < 0) crop.top += crop.bottom;
} else {
// enforce minimum 1px cropbox height
if (crop.top) {
if (down.aspect) crop.top = Math.max(0, crop.top);
crop.top = Math.min(
crop.top,
size.height - oldCrop.bottom - MIN_SIZE,
);
}
if (crop.bottom) {
if (down.aspect) crop.bottom = Math.max(0, crop.bottom);
crop.bottom = Math.min(
crop.bottom,
size.height - oldCrop.top - MIN_SIZE,
);
}
if (
down.aspect &&
(crop.top ?? oldCrop.top) + (crop.bottom ?? oldCrop.bottom) >
size.height
)
return;
}
this.setCrop(crop);
event.stopPropagation();
event.preventDefault();
}
};
private onPointerUp = (event: PointerEvent) => {
const target = event.target as SVGElement;
const down = this.pointers.get(event.pointerId);
if (down && target.hasPointerCapture(event.pointerId)) {
this.onPointerMove(event);
target.releasePointerCapture(event.pointerId);
event.stopPropagation();
event.preventDefault();
this.pointers.delete(event.pointerId);
}
};
private onKeyDown = (event: KeyboardEvent) => {
if (event.key === ' ') {
if (!this.state.pan) {
this.setState({ pan: true });
}
event.preventDefault();
}
};
private onKeyUp = (event: KeyboardEvent) => {
if (event.key === ' ') this.setState({ pan: false });
};
componentDidMount() {
addEventListener('keydown', this.onKeyDown);
addEventListener('keyup', this.onKeyUp);
}
componentWillUnmount() {
addEventListener('keydown', this.onKeyDown);
addEventListener('keyup', this.onKeyUp);
}
render({ size, scale }: Props, { crop, pan }: State) {
const x = crop.left;
const y = crop.top;
const width = size.width - crop.left - crop.right;
const height = size.height - crop.top - crop.bottom;
const s = (x: number) => x / (scale || 1);
const clip = `polygon(0 0, 0 100%, 100% 100%, 100% 0, 0 0, ${s(x)}px ${s(
y,
)}px, ${s(x + width)}px ${s(y)}px, ${s(x + width)}px ${s(
y + height,
)}px, ${s(x)}px ${s(y + height)}px, ${s(x)}px ${s(y)}px)`;
return (
<svg
class={`${style.cropper} ${pan ? style.pan : ''}`}
width={size.width + 20}
height={size.height + 20}
viewBox={`-10 -10 ${size.width + 20} ${size.height + 20}`}
style={{
// this is hack to force style invalidation in Chrome
zoom: (scale || 1).toFixed(3),
}}
onPointerDown={this.onPointerDown}
onPointerMove={this.onPointerMove}
onPointerUp={this.onPointerUp}
>
<rect
class={style.background}
width={size.width}
height={size.height}
clip-path={clip}
/>
<svg x={x} y={y} width={width} height={height}>
<Freezer>
<rect
id="box"
class={style.cropbox}
data-edge="left,right,top,bottom"
width="100%"
height="100%"
/>
<rect class={style.edge} data-edge="top" width="100%" />
<rect class={style.edge} data-edge="bottom" width="100%" y="100%" />
<rect class={style.edge} data-edge="left" height="100%" />
<rect class={style.edge} data-edge="right" height="100%" x="100%" />
<circle class={style.corner} data-edge="left,top" />
<circle class={style.corner} data-edge="right,top" cx="100%" />
<circle
class={style.corner}
data-edge="right,bottom"
cx="100%"
cy="100%"
/>
<circle class={style.corner} data-edge="left,bottom" cy="100%" />
</Freezer>
</svg>
</svg>
);
}
}
interface FreezerProps {
children: ComponentChildren;
}
class Freezer extends Component<FreezerProps> {
shouldComponentUpdate() {
return false;
}
render({ children }: FreezerProps) {
return children;
}
}