From 72f4f47e8f1992d434318489b8b9e66677e33d75 Mon Sep 17 00:00:00 2001 From: or-else Date: Sat, 10 Jul 2021 12:40:18 -0700 Subject: [PATCH] adding zoom/crop widget for avatar uploads --- src/widgets/cropper.jsx | 266 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 266 insertions(+) create mode 100644 src/widgets/cropper.jsx diff --git a/src/widgets/cropper.jsx b/src/widgets/cropper.jsx new file mode 100644 index 00000000..1a86cb73 --- /dev/null +++ b/src/widgets/cropper.jsx @@ -0,0 +1,266 @@ +// Image zoom and crop widget. + +import React from 'react'; + +const DEFAULT_MAX_ZOOM = 2.5; + +export default class Cropper extends React.Component { + constructor(props) { + super(props); + + this.state = { + // Coordinates of the top left corner of the image in container coordinates. + panX: 0, + panY: 0, + // Origin of the zoom in image coordinates. + originX: 0, + originY: 0, + // Zoom scale. + zoom: 1, + // Zoom min and max. + minZoom: 0, + maxZoom: DEFAULT_MAX_ZOOM, + }; + + // References to DOM elements for hooking up events and getting screen dimensions. + // The transparent element which captures mouse moves (moves with the image). + this.overlay = React.createRef(); + // The image cutout window or view port (static). + this.cutout = React.createRef(); + // Scaled and translated image preview. + this.preview = React.createRef(); + // The main container (static). + this.boundingBox = React.createRef(); + + // The original image width and height before any scaling. + this.imageWidth = 0; + this.imageHeight = 0; + + // Mouse position when dragging. + this.mouseX = 0 + this.mouseY = 0; + // Length of the previous mouse move when dragging. + this.prevDistance = 0; + + // Bounding rectangles of static elements. + this.cutoutRect = {}; + this.bBoxRect = {}; + // Center of the bounding box. + this.originX = 0; + this.originY = 0; + + this.initScaling = this.initScaling.bind(this); + this.handleZoom = this.handleZoom.bind(this); + this.mouseDown = this.mouseDown.bind(this); + this.mouseUp = this.mouseUp.bind(this); + this.mouseMove = this.mouseMove.bind(this); + this.mouseTouch = this.mouseTouch.bind(this); + this.positionAll = this.positionAll.bind(this); + this.translate = this.translate.bind(this); + } + + componentDidMount() { + this.overlay.current.addEventListener('mousedown', this.mouseDown, { passive: true }); + this.overlay.current.addEventListener('touchstart', this.mouseDown, { passive: true }); + + // The rectangle position is in viewport coordinates. + this.bBoxRect = this.boundingBox.current.getBoundingClientRect(); + // Center of the bounding box in container coordinates. + this.originX = this.bBoxRect.width / 2; + this.originY = this.bBoxRect.height / 2; + + // The rectangle position is in viewport coordinates. + this.cutoutRect = this.cutout.current.getBoundingClientRect(); + } + + componentWillUnmount() { + this.overlay.current.removeEventListener('mousedown', this.mouseDown); + this.overlay.current.removeEventListener('touchstart', this.mouseDown); + } + + // Position all elements. + positionAll(panX, panY, zoom) { + // Zoom origin in image preview coordinates. + this.setState({ + panX: panX, + panY: panY, + zoom: zoom, + originX: this.originX - panX, + originY: this.originY - panY, + }); + } + + // Check if new location is within the limits. + static checkBound(currPan, img, cutout, delta) { + let nextDiff = Math.min(0, cutout[0] - img[0] - delta, img[1] - cutout[1] + delta); + if (nextDiff == 0) { + // Cutout is completely within the image. + currPan += delta; + } else if (Math.min(0, cutout[0] - img[0], img[1] - cutout[1]) < nextDiff) { + // Cutout is outside of the range but closer to the bound. + currPan += delta; + } + return currPan; + } + + initScaling() { + // Initialize components. + const imgRect = this.preview.current.getBoundingClientRect(); + + this.imageWidth = imgRect.width; + this.imageHeight = imgRect.height; + + // Set zoom slider's min and max values. Make sure the scaled image cannot be smaller than the cutout. + const minZoom = Math.max(this.cutoutRect.width / imgRect.width, this.cutoutRect.height / imgRect.height); + this.setState({ + minZoom: minZoom, + maxZoom: Math.max(DEFAULT_MAX_ZOOM, minZoom + 1) + }); + + // Initial zoom level fills the bounding box at the smallest image dimension and overflows the largest, i.e. "fill" not "fit". + let zoom = Math.max((this.bBoxRect.width / imgRect.width), (this.bBoxRect.height / imgRect.height)); + // Converting from viewport coordinates to container, then panning. + let panX = (this.cutoutRect.left - this.bBoxRect.left) - ((imgRect.width - this.cutoutRect.width) / 2); + let panY = (this.cutoutRect.top - this.bBoxRect.top) - ((imgRect.height - this.cutoutRect.height) / 2); + + this.positionAll(panX, panY, zoom); + } + + handleZoom(e) { + let zoom = e.target.value; + + let panX = this.state.panX; + let panY = this.state.panY; + + // Ensure that the image at the new zoom is not outside of the cutout boundaries. + // Calculate image coordinates at the new zoom. + const imgLeft = this.originX - (this.originX - panX) * zoom; + const imgRight = imgLeft + this.imageWidth * zoom; + // Cutout in the same coordinates as the image. + const coLeft = this.cutoutRect.left - this.bBoxRect.left; + const coRight = coLeft + this.cutoutRect.width; + if (coLeft < imgLeft) { + panX -= imgLeft - coLeft; + } else if (coRight > imgRight) { + panX += coRight - imgRight; + } + const imgTop = this.originY - (this.originY - panY) * zoom; + const imgBottom = imgTop + this.imageHeight * zoom; + const coTop = this.cutoutRect.top - this.bBoxRect.top; + const coBottom = coTop + this.cutoutRect.height; + if (coTop < imgTop) { + panY -= imgTop - coTop; + } else if (coBottom > imgBottom) { + panY += coBottom - imgBottom; + } + + this.positionAll(panX, panY, zoom); + } + + mouseDown(e) { + if (e.touches) { + this.mouseX = e.touches[0].pageX; + this.mouseY = e.touches[0].pageY; + } else { + this.mouseX = e.pageX; + this.mouseY = e.pageY; + } + + // this.cutoutRect = this.cutout.current.getBoundingClientRect(); + + window.addEventListener('mousemove', this.mouseMove, { passive: false }); + window.addEventListener('touchmove', this.mouseTouch, { passive: false }); + window.addEventListener('mouseup', this.mouseUp, { passive: true }); + window.addEventListener('touchend', this.mouseUp, { passive: true }); + // Disable text selection in the entire document. + document.body.style['userSelect'] = 'none'; + } + + // Perform image panning. + translate(pageX, pageY) { + const dX = pageX - this.mouseX; + const dY = pageY - this.mouseY; + + this.mouseX = pageX; + this.mouseY = pageY; + + // Make sure the image is within the cutout window. + const imgRect = this.preview.current.getBoundingClientRect(); + + // Check if the new position is within the boundaries, and if not if it's closer to them. + let panX = Cropper.checkBound(this.state.panX, [imgRect.left, imgRect.right], + [this.cutoutRect.left, this.cutoutRect.right], dX); + let panY = Cropper.checkBound(this.state.panY, [imgRect.top, imgRect.bottom], + [this.cutoutRect.top, this.cutoutRect.bottom], dY); + + this.positionAll(panX, panY, this.state.zoom); + } + + // Image panning. + mouseMove(e) { + e.preventDefault(); + this.translate(e.pageX, e.pageY); + } + + mouseTouch(e) { + e.preventDefault(); + + if (e.touches.length == 1) { + this.translate(e.touches[0].pageX, e.touches[0].pageY); + return; + } + + // Image zooming by pinching. + const [touch0, touch1] = e.touches; + const distance = Math.sqrt((touch0.pageX - touch1.pageX) * (touch0.pageX - touch1.pageX) + + (touch0.pageY - touch1.pageY) * (touch0.pageY - touch1.pageY)); + + if (!this.prevDistance) { + this.prevDistance = distance / this.state.zoom; + } + + let scale = (distance / this.prevDistance).toFixed(3); + this.setState({zoom: Math.max(this.minZoom, Math.min(this.maxZoom, scale))}); + } + + mouseUp(e) { + window.removeEventListener('mousemove', this.mouseMove); + window.removeEventListener('touchmove', this.mouseTouch); + window.removeEventListener('mouseup', this.mouseUp); + window.removeEventListener('touchend', this.mouseUp); + // Re-enable text selection. + document.body.style['userSelect'] = ''; + + this.positionAll(this.state.panX, this.state.panY, this.state.zoom); + } + + render() { + // transform3d: position and scale. + const t3d = `translate3d(${this.state.panX}px, ${this.state.panY}px, 0) scale(${this.state.zoom})`; + // transformOrigin: zoom origin. + const orig = `${this.state.originX}px ${this.state.originY}px`; + + // Overlay position and size are exactly equal to the position and size of the transformed image + // except it uses different coordinates. + const overlay = { + top: `${this.originY - this.state.originY * this.state.zoom}px`, + left: `${this.originX - this.state.originX * this.state.zoom}px`, + width: `${this.imageWidth * this.state.zoom}px`, + height: `${this.imageHeight * this.state.zoom}px` + }; + return ( +
+
+ +
+
+
+
+ +
+
+ ); + } +}