mirror of
https://github.com/GoogleChromeLabs/squoosh.git
synced 2025-11-17 19:19:47 +00:00
331 lines
9.2 KiB
TypeScript
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;
|
|
}
|
|
}
|