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 { state: State = { scale: 1, editingScale: false, cropPreset: undefined, lockAspect: false, ...this.fromPreprocessorState(this.props.preprocessorState), }; pinchZoom = createRef(); scaleInput = createRef(); // 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; if (crop) { 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 = { 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; } if (crop.left < 0 || crop.right < 0) crop.left = crop.right = 0; if (crop.top < 0 || crop.bottom < 0) crop.top = crop.bottom = 0; this.setState({ crop }); } private adjustOffsetAfterRotation = (wideToTall: boolean) => { const image = this.props.source.decoded; let { x, y } = this.pinchZoom.current!; let { width, height } = image; if (wideToTall) { [width, height] = [height, width]; } x += (width - height) / 2; y += (height - width) / 2; this.pinchZoom.current!.setTransform({ x, y }); }; private rotateClockwise = () => { let { rotate, crop } = this.state; this.setState( { rotate: ((rotate + 90) % 360) as typeof ROTATE_ORIENTATIONS[number], }, () => { this.adjustOffsetAfterRotation(rotate === 0 || rotate === 180); }, ); this.setCrop({ 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], }, () => { this.adjustOffsetAfterRotation(rotate === 0 || rotate === 180); }, ); this.setCrop({ 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, ) => { 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, ) => { 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 rotated = rotate === 90 || rotate === 270; const displayWidth = rotated ? image.height : image.width; const displayHeight = rotated ? image.width : image.height; const width = displayWidth - crop.left - crop.right; const height = displayHeight - crop.top - crop.bottom; let transform = `translate(-50%, -50%) ` + `rotate(${rotate}deg) ` + `scale(${flip.horizontal ? -1 : 1}, ${flip.vertical ? -1 : 1})`; return (
{/* */}
{crop && ( )}
{editingScale ? ( ) : ( {Math.round(scale * 100)}% )}

Modify Source

Crop

Flip
Rotate
); } } const CancelButton = ({ onClick }: { onClick: () => void }) => ( ); const SaveButton = ({ onClick }: { onClick: () => void }) => ( ); interface BackdropProps { width: number; height: number; } /** @TODO this could at least use clip-path. It's too expensive this way. */ class Backdrop extends Component { 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 ( ); } }