From bde3a93b6eb43170ed7494f3ebcc3c6a0b2566bf Mon Sep 17 00:00:00 2001 From: Jason Miller Date: Wed, 9 Dec 2020 02:10:06 -0500 Subject: [PATCH] Add Transform modal --- src/client/lazy-app/Compress/CanvasImage.tsx | 37 ++ .../Compress/Transform/Cropper/index.tsx | 388 +++++++++++ .../Compress/Transform/Cropper/style.css | 119 ++++ .../lazy-app/Compress/Transform/index.tsx | 617 ++++++++++++++++++ .../lazy-app/Compress/Transform/style.css | 351 ++++++++++ src/client/lazy-app/Compress/index.tsx | 67 +- src/client/lazy-app/Compress/style.css | 19 + src/client/lazy-app/icons/index.tsx | 52 ++ 8 files changed, 1642 insertions(+), 8 deletions(-) create mode 100644 src/client/lazy-app/Compress/CanvasImage.tsx create mode 100644 src/client/lazy-app/Compress/Transform/Cropper/index.tsx create mode 100644 src/client/lazy-app/Compress/Transform/Cropper/style.css create mode 100644 src/client/lazy-app/Compress/Transform/index.tsx create mode 100644 src/client/lazy-app/Compress/Transform/style.css diff --git a/src/client/lazy-app/Compress/CanvasImage.tsx b/src/client/lazy-app/Compress/CanvasImage.tsx new file mode 100644 index 00000000..e43b4ba9 --- /dev/null +++ b/src/client/lazy-app/Compress/CanvasImage.tsx @@ -0,0 +1,37 @@ +import { h, Component, createRef } from 'preact'; +import { drawDataToCanvas } from '../util'; + +export interface CanvasImageProps + extends h.JSX.HTMLAttributes { + image?: ImageData; +} + +export default class CanvasImage extends Component { + canvas = createRef(); + componentDidUpdate(prevProps: CanvasImageProps) { + if (this.props.image !== prevProps.image) { + this.draw(this.props.image); + } + } + componentDidMount() { + if (this.props.image) { + this.draw(this.props.image); + } + } + draw(image?: ImageData) { + const canvas = this.canvas.current; + if (!canvas) return; + if (!image) canvas.getContext('2d'); + else drawDataToCanvas(canvas, image); + } + render({ image, ...props }: CanvasImageProps) { + return ( + + ); + } +} diff --git a/src/client/lazy-app/Compress/Transform/Cropper/index.tsx b/src/client/lazy-app/Compress/Transform/Cropper/index.tsx new file mode 100644 index 00000000..bb0f1c13 --- /dev/null +++ b/src/client/lazy-app/Compress/Transform/Cropper/index.tsx @@ -0,0 +1,388 @@ +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 }[]; +} + +interface State { + crop: CropBox; + pan: boolean; +} + +export default class Cropper extends Component { + private pointers = new Map(); + + 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) { + 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; + } + // crop.left = Math.max(0, crop.left) | 0; + // crop.top = Math.max(0, crop.top) | 0; + // crop.right = Math.max(0, crop.right) | 0; + // crop.bottom = Math.max(0, crop.bottom) | 0; + 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(); + + const edges = edgeAttr.split(/ *, */) as Edge[]; + // console.log(this.props.lockAspect); + if (this.props.lockAspect && edges.length === 1) return; + + this.pointers.set(event.pointerId, { + x: event.x, + y: event.y, + edges: edges.map((edge) => ({ edge, value: this.state.crop[edge] })), + }); + 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 aspect = + (size.width - oldCrop.left - oldCrop.right) / + (size.height - oldCrop.top - oldCrop.bottom); + const scale = this.props.scale || 1; + let dx = (event.x - down.x) / scale; + let dy = (event.y - down.y) / scale; + // console.log(this.props.lockAspect, aspect); + if (this.props.lockAspect) { + const dir = (dx + dy) / 2; + dx = dir * aspect; + dy = dir / aspect; + } + const crop: Partial = {}; + 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) + crop.left = Math.min( + crop.left, + size.width - oldCrop.right - MIN_SIZE, + ); + if (crop.right) + crop.right = Math.min( + crop.right, + size.width - oldCrop.left - MIN_SIZE, + ); + } + 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) + crop.top = Math.min( + crop.top, + size.height - oldCrop.bottom - MIN_SIZE, + ); + if (crop.bottom) + crop.bottom = Math.min( + crop.bottom, + size.height - oldCrop.top - MIN_SIZE, + ); + } + 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 x = crop.left.toFixed(2); + // const y = crop.top.toFixed(2); + // const width = (size.width - crop.left - crop.right).toFixed(2); + // const height = (size.height - crop.top - crop.bottom).toFixed(2); + + return ( + + + {/* + + + + */} + {/* + + + + + + + */} + + + + + + + + + + + + + + + + + + {/* + + + + + + */} + + ); + } +} + +interface FreezerProps { + children: ComponentChildren; +} +class Freezer extends Component { + shouldComponentUpdate() { + return false; + } + render({ children }: FreezerProps) { + return children; + } +} diff --git a/src/client/lazy-app/Compress/Transform/Cropper/style.css b/src/client/lazy-app/Compress/Transform/Cropper/style.css new file mode 100644 index 00000000..58a36e4d --- /dev/null +++ b/src/client/lazy-app/Compress/Transform/Cropper/style.css @@ -0,0 +1,119 @@ +.cropper { + position: absolute; + left: calc(-10px / var(--scale, 1)); + top: calc(-10px / var(--scale, 1)); + right: calc(-10px / var(--scale, 1)); + bottom: calc(-10px / var(--scale, 1)); + shape-rendering: crispedges; + overflow: hidden; + contain: strict; + transform-origin: 0 0; + transform: scale(calc(1 / var(--scale))) !important; + zoom: var(--scale, 1); + + &.pan { + cursor: grabbing; + & * { + pointer-events: none; + } + } + + & > svg { + margin: -10px; + padding: 10px; + overflow: visible; + contain: strict; + /* overflow: visible; */ + } +} + +.background { + pointer-events: none; + fill: rgba(0, 0, 0, 0.25); +} + +.cropbox { + fill: none; + stroke: white; + stroke-width: calc(1.5 / var(--scale, 1)); + stroke-dasharray: calc(5 / var(--scale, 1)), calc(5 / var(--scale, 1)); + stroke-dashoffset: 50%; + /* Accept pointer input even though this is unpainted transparent */ + pointer-events: all; + cursor: move; + + /* animation: ants 1s linear forwards infinite; */ +} +/* +@keyframes ants { + 0% { stroke-dashoffset: 0; } + 100% { stroke-dashoffset: -12; } +} +*/ + +.edge { + fill: #aaa; + opacity: 0; + transition: opacity 250ms ease; + z-index: 2; + pointer-events: all; + --edge-width: calc(10px / var(--scale, 1)); + + @media (max-width: 779px) { + --edge-width: calc(20px / var(--scale, 1)); + fill: rgba(0, 0, 0, 0.01); + } + + &[data-edge='left'], + &[data-edge='right'] { + cursor: ew-resize; + transform: translate(calc(var(--edge-width, 10px) / -2), 0); + width: var(--edge-width, 10px); + } + &[data-edge='top'], + &[data-edge='bottom'] { + cursor: ns-resize; + transform: translate(0, calc(var(--edge-width, 10px) / -2)); + height: var(--edge-width, 10px); + } + + &:hover, + &:active { + opacity: 0.1; + transition: none; + } +} + +.corner { + r: calc(4 / var(--scale, 1)); + stroke-width: calc(4 / var(--scale, 1)); + stroke: rgba(225, 225, 225, 0.01); + fill: white; + shape-rendering: geometricprecision; + pointer-events: all; + transition: fill 250ms ease, stroke 250ms ease; + + &:hover, + &:active { + stroke: rgba(225, 225, 225, 0.5); + transition: none; + } + + @media (max-width: 779px) { + r: calc(10 / var(--scale, 1)); + stroke-width: calc(2 / var(--scale, 1)); + } + + &[data-edge='left,top'] { + cursor: nw-resize; + } + &[data-edge='right,top'] { + cursor: ne-resize; + } + &[data-edge='right,bottom'] { + cursor: se-resize; + } + &[data-edge='left,bottom'] { + cursor: sw-resize; + } +} diff --git a/src/client/lazy-app/Compress/Transform/index.tsx b/src/client/lazy-app/Compress/Transform/index.tsx new file mode 100644 index 00000000..0d7c6fc3 --- /dev/null +++ b/src/client/lazy-app/Compress/Transform/index.tsx @@ -0,0 +1,617 @@ +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; + 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); + + console.log('u', JSON.parse(JSON.stringify(newState))); + + if (onSave) onSave({ preprocessorState: newState }); + }; + + private cancel = () => { + const { onCancel, onSave } = this.props; + if (onCancel) onCancel(); + else if (onSave) + onSave({ preprocessorState: this.props.preprocessorState }); + }; + + // private fitToViewport = () => { + // const pinchZoom = this.pinchZoom.current; + // const img = this.props.source?.preprocessed; + // if (!img || !pinchZoom) return; + // const scale = Number(Math.min( + // (window.innerWidth - 20) / img.width, + // (window.innerHeight - 20) / img.height + // ).toFixed(2)); + // pinchZoom.scaleTo(Number(scale.toFixed(2)), { allowChangeEvent: true }); + // this.recenter(); + // }; + + // private recenter = () => { + // const pinchZoom = this.pinchZoom.current; + // const img = this.props.source?.preprocessed; + // if (!img || !pinchZoom) return; + // pinchZoom.setTransform({ + // x: (img.width - img.width * pinchZoom.scale) / 2, + // y: (img.height - img.height * pinchZoom.scale) / 2, + // allowChangeEvent: true + // }); + // }; + + 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; + // @ts-ignore-next + const cropPreset = cropPresets[value]; + this.setState({ + cropPreset, + lockAspect: true, + }); + }; + + 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 update = (event: Event) => { + // const { name, value } = event.target as HTMLInputElement; + // const state = cleanSet(this.state, name, value); + // this.setState(state); + // }; + + 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 }); + }; + + // private onRotateClick = () => { + // const { preprocessorState: inputProcessorState } = this.props; + // if (!inputProcessorState) return; + // const newState = cleanSet( + // inputProcessorState, + // 'rotate.rotate', + // (inputProcessorState.rotate.rotate + 90) % 360, + // ); + // this.props.onPreprocessorChange(newState); + // }; + + 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 ( + + + + +
+ + {/* */} +
+ + {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; +} + +class Backdrop extends Component { + shouldComponentUpdate({ width, height }: BackdropProps) { + return width !== this.props.width || height !== this.props.height; + } + + /** @TODO this could at least use clip-path */ + 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 ( + + + + + + + + ); + } +} diff --git a/src/client/lazy-app/Compress/Transform/style.css b/src/client/lazy-app/Compress/Transform/style.css new file mode 100644 index 00000000..568b51e7 --- /dev/null +++ b/src/client/lazy-app/Compress/Transform/style.css @@ -0,0 +1,351 @@ +.transform { + display: block; +} + +.wrap { + overflow: visible; + + /* + & > canvas { + transition: transform 150ms ease; + } + */ +} + +.backdrop { + position: fixed; + left: 0; + top: 0; + width: 100%; + height: 100%; + transform: none !important; + will-change: initial !important; + contain: strict; + + & * { + contain: strict; + } + /* background: rgba(255, 0, 0, 0.5); */ +} + +.backdropArea { + fill: rgba(0, 0, 0, 0.25); +} + +.pinch-zoom { + composes: abs-fill from global; + outline: none; + display: flex; + justify-content: center; + align-items: center; +} + +.pinch-target { + /* This fixes a severe painting bug in Chrome. + * We should try to remove this once the issue is fixed. + * https://bugs.chromium.org/p/chromium/issues/detail?id=870222#c10 */ + will-change: auto; + /* Prevent the image becoming misshapen due to default flexbox layout. */ + flex-shrink: 0; +} + +.cancel, +.save { + composes: unbutton from global; + position: absolute; + padding: 0; + z-index: 2; +} + +.save { + position: absolute; + right: 0; + bottom: 0; + display: grid; + align-items: center; + justify-items: center; + + & > * { + grid-area: 1/1/1/1; + fill: #fff; + } +} + +/* @TODO use grid */ +.cancel { + fill: rgba(0, 0, 0, 0.4); + + & > svg:not(.icon) { + display: block; + margin: -8px 0; + width: 80px; + height: 80px; + } + + & > .icon { + position: absolute; + left: 28px; + top: 22px; + fill: #fff; + } + + & > span { + display: inline-block; + padding: 4px 10px; + border-radius: 1rem; + background: rgba(0, 0, 0, 0.4); + font-size: 80%; + color: #fff; + } + + &:hover, + &:focus { + fill: rgba(0, 0, 0, 0.9); + + & > span { + background: rgba(0, 0, 0, 0.9); + } + } +} + +.options { + position: fixed; + right: 0; + bottom: 78px; + color: #fff; + font-size: 1.2rem; + display: flex; + flex-flow: column; + max-width: 250px; + margin: 0; + width: calc(100% - 60px); + max-height: 100%; + overflow: hidden; + align-self: end; + border-radius: var(--options-radius) 0 0 var(--options-radius); + animation: slideInFromRight 500ms ease-out forwards 1; + --horizontal-padding: 15px; + --main-theme-color: var(--blue); +} +@keyframes slideInFromRight { + 0% { + transform: translateX(100%); + } +} + +.options-title { + background-color: var(--main-theme-color); + color: var(--dark-text); + margin: 0; + padding: 10px var(--horizontal-padding); + font-weight: bold; + font-size: 1.4rem; + border-bottom: 1px solid var(--off-black); +} + +.options-section { + padding: 5px 0; + background: var(--off-black); +} + +.options-section-title { + font: inherit; + margin: 0; + padding: 5px var(--horizontal-padding); +} + +.option-base { + display: grid; + gap: 0.7em; + align-items: center; + padding: 5px var(--horizontal-padding); +} + +.options-button { + composes: unbutton from global; + width: 40px; + height: 40px; + display: flex; + justify-content: center; + align-items: center; + border-radius: 50%; + border: 1px solid var(--dark-gray); + color: var(--white); + + &:hover, + &:focus { + background-color: var(--off-black); + border-color: var(--med-gray); + } + + &[data-active] { + background-color: var(--dark-gray); + border-color: var(--med-gray); + } +} + +.options-dimensions { + composes: option-base; + grid-template-columns: 1fr 0fr 1fr; + + input { + background: var(--white); + color: var(--black); + font: inherit; + border: none; + width: 100%; + padding: 4px; + box-sizing: border-box; + border-radius: 4px; + } +} + +.option-one-cell { + composes: option-base; + grid-template-columns: 1fr; +} + +.option-button-row { + composes: option-base; + grid-template-columns: 1fr auto auto; +} + +.option-checkbox { + composes: option-base; + grid-template-columns: auto 1fr; +} + +/** Zoom controls */ +.controls { + position: absolute; + display: flex; + justify-content: center; + top: 0; + left: 0; + right: 0; + padding: 9px 84px; + flex-wrap: wrap; + + /* Allow clicks to fall through to the pinch zoom area */ + pointer-events: none; + + & > * { + pointer-events: auto; + } + + @media (min-width: 860px) { + padding: 9px; + top: auto; + left: 320px; + right: 320px; + bottom: 0; + flex-wrap: wrap-reverse; + } +} + +.zoom-controls { + display: flex; + position: relative; + z-index: 100; + + & > :not(:first-child) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + margin-left: 0; + } + + & > :not(:last-child) { + margin-right: 0; + border-right-width: 0; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } +} + +.button, +.zoom { + display: flex; + align-items: center; + box-sizing: border-box; + margin: 4px; + background-color: rgba(29, 29, 29, 0.92); + border: 1px solid rgba(0, 0, 0, 0.67); + border-radius: 6px; + line-height: 1.1; + white-space: nowrap; + height: 39px; + padding: 0 8px; + font-size: 1.2rem; + cursor: pointer; + + /* + @media (min-width: 600px) { + height: 39px; + padding: 0 16px; + } + */ + + &:focus { + /* box-shadow: 0 0 0 2px var(--hot-pink); */ + box-shadow: 0 0 0 2px #fff; + outline: none; + z-index: 1; + } +} + +.button { + color: #fff; + + &:hover { + background: rgba(50, 50, 50, 0.92); + } + + &.active { + background: rgba(72, 72, 72, 0.92); + color: #fff; + } +} + +.zoom { + cursor: text; + width: 7rem; + font: inherit; + text-align: center; + justify-content: center; + + &:focus { + box-shadow: inset 0 1px 4px rgba(0, 0, 0, 0.2), 0 0 0 2px #fff; + } +} +span.zoom { + color: #939393; + font-size: 0.8rem; + line-height: 1.2; + font-weight: 100; +} +input.zoom { + font-size: 1.2rem; + letter-spacing: 0.05rem; + font-weight: 700; + text-indent: 3px; + color: #fff; +} + +.zoom-value { + margin: 0 3px 0 0; + padding: 0 2px; + font-size: 1.2rem; + letter-spacing: 0.05rem; + font-weight: 700; + color: #fff; + border-bottom: 1px dashed #999; +} + +.buttons-no-wrap { + display: flex; + pointer-events: none; + + & > * { + pointer-events: auto; + } +} diff --git a/src/client/lazy-app/Compress/index.tsx b/src/client/lazy-app/Compress/index.tsx index 311fa68a..d74223cc 100644 --- a/src/client/lazy-app/Compress/index.tsx +++ b/src/client/lazy-app/Compress/index.tsx @@ -32,6 +32,7 @@ import WorkerBridge from '../worker-bridge'; import { resize } from 'features/processors/resize/client'; import type SnackBarElement from 'shared/custom-els/snack-bar'; import { Arrow, ExpandIcon } from '../icons'; +import Transform from './Transform'; export type OutputType = EncoderType | 'identity'; @@ -68,6 +69,8 @@ interface State { sides: [Side, Side]; /** Source image load */ loading: boolean; + /** Showing preprocessor transformations modal */ + transform: boolean; error?: string; mobileView: boolean; altBackground: boolean; @@ -126,13 +129,18 @@ async function preprocessImage( ): Promise { assertSignal(signal); let processedData = data; + const { rotate, flip, crop } = preprocessorState; - if (preprocessorState.rotate.rotate !== 0) { - processedData = await workerBridge.rotate( - signal, - processedData, - preprocessorState.rotate, - ); + if (rotate.rotate !== 0) { + processedData = await workerBridge.rotate(signal, processedData, rotate); + } + + if (flip && (flip.horizontal || flip.vertical)) { + processedData = await workerBridge.flip(signal, processedData, flip); + } + + if (crop && (crop.left || crop.top || crop.right || crop.bottom)) { + processedData = await workerBridge.crop(signal, processedData, crop); } return processedData; @@ -274,6 +282,9 @@ export default class Compress extends Component { state: State = { source: undefined, loading: false, + /** @TODO remove this */ + // transform: true, + transform: false, preprocessorState: defaultPreprocessorState, sides: [ { @@ -368,6 +379,20 @@ export default class Compress extends Component { }); }; + private showPreprocessorTransforms = () => { + this.setState({ transform: true }); + }; + + private onTransformUpdated = ({ + preprocessorState, + }: { preprocessorState?: PreprocessorState } = {}) => { + console.log('onTransformUpdated', preprocessorState); + if (preprocessorState) { + this.onPreprocessorChange(preprocessorState); + } + this.setState({ transform: false }); + }; + componentWillReceiveProps(nextProps: Props): void { if (nextProps.file !== this.props.file) { this.sourceFile = nextProps.file; @@ -798,11 +823,21 @@ export default class Compress extends Component { render( { onBack }: Props, - { loading, sides, source, mobileView, altBackground, preprocessorState }: State, + { + loading, + sides, + source, + mobileView, + altBackground, + transform, + preprocessorState, + }: State, ) { const [leftSide, rightSide] = sides; const [leftImageData, rightImageData] = sides.map((i) => i.data); + transform = (source && source.decoded && transform) || false; + const options = sides.map((side, index) => ( { rightDisplaySettings.processorState.resize.fitMethod === 'contain'; return ( -
+
, ] )} + + {transform && ( + + )}
); } diff --git a/src/client/lazy-app/Compress/style.css b/src/client/lazy-app/Compress/style.css index fbd836aa..d3964aa4 100644 --- a/src/client/lazy-app/Compress/style.css +++ b/src/client/lazy-app/Compress/style.css @@ -34,6 +34,24 @@ &.alt-background::before { opacity: 0; } + + /* transformation is modal and we sweep away the comparison UI */ + &.transforming { + & > .options { + transform: translateX(-100%); + } + & > .options + .options { + transform: translateX(100%); + } + + & > .back { + display: none; + } + + & > :first-child { + display: none; + } + } } .options { @@ -50,6 +68,7 @@ grid-template-rows: 1fr max-content; align-content: end; align-self: end; + transition: transform 500ms ease; @media (min-width: 600px) { width: 300px; diff --git a/src/client/lazy-app/icons/index.tsx b/src/client/lazy-app/icons/index.tsx index 9b3a0e6a..41d33f73 100644 --- a/src/client/lazy-app/icons/index.tsx +++ b/src/client/lazy-app/icons/index.tsx @@ -11,6 +11,50 @@ const Icon = (props: preact.JSX.HTMLAttributes) => ( /> ); +export const SwapIcon = (props: preact.JSX.HTMLAttributes) => ( + + + +); + +export const FlipVerticallyIcon = (props: preact.JSX.HTMLAttributes) => ( + + + +); + +export const FlipHorizontallyIcon = (props: preact.JSX.HTMLAttributes) => ( + + + +); + +export const RotateClockwiseIcon = (props: preact.JSX.HTMLAttributes) => ( + + + +); + +export const RotateCounterClockwiseIcon = ( + props: preact.JSX.HTMLAttributes, +) => ( + + + +); + +export const CheckmarkIcon = (props: preact.JSX.HTMLAttributes) => ( + + + +); + +export const CompareIcon = (props: preact.JSX.HTMLAttributes) => ( + + + +); + export const ToggleBackgroundIcon = (props: preact.JSX.HTMLAttributes) => ( @@ -31,6 +75,14 @@ export const RotateIcon = (props: preact.JSX.HTMLAttributes) => ( ); +export const MoreIcon = (props: preact.JSX.HTMLAttributes) => ( + + + + + +); + export const AddIcon = (props: preact.JSX.HTMLAttributes) => (