瀏覽代碼

Improve image rotation (#4639)

* ImageEditor - fix 90deg rotations

* ImageEditor - make 90deg rotations work like on Android

Fixes https://github.com/transloadit/uppy/issues/4380

* ImageEditor - refactor

ImageEditor - naturalWidth -> width
ImageEditor - rename imageData to canvasData (bc it's canavasData!)
ImageEditor - add comments

* ImageEditor - make tall & narrow images fit properly into the container too

* ImageEditor - remove dark corners on rotation

* ImageEditor - make both `{ angleGranular, angle90Deg }` objective

* ImageEditor - working "90deg + granular rotation" combo

* ImageEditor - get rid of normalized angles

* getScaleFactorThatRemovesDarkCorners.js - refactor

* ImageEditor - preserve flip
Evgenia Karunus 1 年之前
父節點
當前提交
57336a1781

+ 53 - 28
packages/@uppy/image-editor/src/Editor.jsx

@@ -1,10 +1,15 @@
 import Cropper from 'cropperjs'
 import { h, Component } from 'preact'
+import getCanvasDataThatFitsPerfectlyIntoContainer from './utils/getCanvasDataThatFitsPerfectlyIntoContainer.js'
+import getScaleFactorThatRemovesDarkCorners from './utils/getScaleFactorThatRemovesDarkCorners.js'
 
 export default class Editor extends Component {
   constructor (props) {
     super(props)
-    this.state = { rotationAngle: 0, rotationDelta: 0 }
+    this.state = {
+      angle90Deg: 0,
+      angleGranular: 0,
+    }
   }
 
   componentDidMount () {
@@ -14,53 +19,72 @@ export default class Editor extends Component {
       opts.cropperOptions,
     )
     storeCropperInstance(this.cropper)
-
-    if (opts.actions.granularRotate) {
-      this.imgElement.addEventListener('crop', (ev) => {
-        const rotationAngle = ev.detail.rotate
-        this.setState({
-          rotationAngle,
-          // 405 == 360 + 45
-          rotationDelta: ((rotationAngle + 405) % 90) - 45,
-        })
-      })
-    }
   }
 
   componentWillUnmount () {
     this.cropper.destroy()
   }
 
-  granularRotateOnChange = (ev) => {
-    const { rotationAngle, rotationDelta } = this.state
-    const pendingRotationDelta = Number(ev.target.value) - rotationDelta
-    cancelAnimationFrame(this.granularRotateOnInputNextFrame)
-    if (pendingRotationDelta !== 0) {
-      const pendingRotationAngle = rotationAngle + pendingRotationDelta
-      this.granularRotateOnInputNextFrame = requestAnimationFrame(() => {
-        this.cropper.rotateTo(pendingRotationAngle)
-      })
-    }
+  onRotate90Deg = () => {
+    // 1. Set state
+    const { angle90Deg } = this.state
+    const newAngle = angle90Deg - 90
+    this.setState({
+      angle90Deg: newAngle,
+      angleGranular: 0,
+    })
+
+    // 2. Rotate the image
+    // Important to reset scale here, or cropper will get confused on further rotations
+    this.cropper.scale(1)
+    this.cropper.rotateTo(newAngle)
+
+    // 3. Fit the rotated image into the view
+    const canvasData = this.cropper.getCanvasData()
+    const containerData = this.cropper.getContainerData()
+    const newCanvasData = getCanvasDataThatFitsPerfectlyIntoContainer(containerData, canvasData)
+    this.cropper.setCanvasData(newCanvasData)
+
+    // 4. Make cropbox fully wrap the image
+    this.cropper.setCropBoxData(newCanvasData)
+  }
+
+  onRotateGranular = (ev) => {
+    //  1. Set stsate
+    const newGranularAngle = Number(ev.target.value)
+    this.setState({ angleGranular: newGranularAngle })
+
+    // 2. Rotate the image
+    const { angle90Deg } = this.state
+    const newAngle = angle90Deg + newGranularAngle
+    this.cropper.rotateTo(newAngle)
+
+    // 3. Scale the image so that it fits into the cropbox
+    const cropboxData = this.cropper.getCropBoxData()
+    const scaleFactor = getScaleFactorThatRemovesDarkCorners(cropboxData, newGranularAngle)
+    // Preserve flip
+    const scaleFactorX = this.cropper.getImageData().scaleX < 0 ? -scaleFactor : scaleFactor
+    this.cropper.scale(scaleFactorX, scaleFactor)
   }
 
   renderGranularRotate () {
     const { i18n } = this.props
-    const { rotationDelta, rotationAngle } = this.state
+    const { angleGranular } = this.state
 
     return (
       // eslint-disable-next-line jsx-a11y/label-has-associated-control
       <label
         data-microtip-position="top"
         role="tooltip"
-        aria-label={`${rotationAngle}º`}
+        aria-label={`${angleGranular}º`}
         className="uppy-ImageCropper-rangeWrapper uppy-u-reset"
       >
         <input
           className="uppy-ImageCropper-range uppy-u-reset"
           type="range"
-          onInput={this.granularRotateOnChange}
-          onChange={this.granularRotateOnChange}
-          value={rotationDelta}
+          onInput={this.onRotateGranular}
+          onChange={this.onRotateGranular}
+          value={angleGranular}
           min="-45"
           max="44"
           aria-label={i18n('rotate')}
@@ -81,6 +105,7 @@ export default class Editor extends Component {
         onClick={() => {
           this.cropper.reset()
           this.cropper.setAspectRatio(0)
+          this.setState({ angle90Deg: 0, angleGranular: 0 })
         }}
       >
         <svg aria-hidden="true" className="uppy-c-icon" width="24" height="24" viewBox="0 0 24 24">
@@ -98,7 +123,7 @@ export default class Editor extends Component {
       <button
         type="button"
         className="uppy-u-reset uppy-c-btn"
-        onClick={() => this.cropper.rotate(-90)}
+        onClick={this.onRotate90Deg}
         aria-label={i18n('rotate')}
         data-microtip-position="top"
       >

+ 1 - 1
packages/@uppy/image-editor/src/ImageEditor.jsx

@@ -17,7 +17,7 @@ export default class ImageEditor extends UIPlugin {
     this.defaultLocale = locale
 
     const defaultCropperOptions = {
-      viewMode: 1,
+      viewMode: 0,
       background: false,
       autoCropArea: 1,
       responsive: true,

+ 4 - 0
packages/@uppy/image-editor/src/style.scss

@@ -15,7 +15,11 @@
 }
 
 .uppy-ImageCropper-image {
+  // Note that exactly these styles are demanded by cropperjs
+  // (https: //github.com/fengyuanchen/cropperjs#example)
   display: block;
+  max-width: 100%;
+  // And this style is required for rendering tall and narrow images
   max-height: 400px;
 }
 

+ 24 - 0
packages/@uppy/image-editor/src/utils/getCanvasDataThatFitsPerfectlyIntoContainer.js

@@ -0,0 +1,24 @@
+// See this cropperjs image to understand how container/image/canavas/cropbox relate to each other.
+// (https://github.com/fengyuanchen/cropperjs/blob/9b528a8baeaae876dc090085e37992a1683c6f34/docs/images/layers.jpg)
+function getCanvasDataThatFitsPerfectlyIntoContainer (containerData, canvasData) {
+  // 1. Scale our canvas as much as possible
+  const widthRatio = containerData.width / canvasData.width
+  const heightRatio = containerData.height / canvasData.height
+  const scaleFactor = Math.min(widthRatio, heightRatio)
+
+  const newWidth = canvasData.width * scaleFactor
+  const newHeight = canvasData.height * scaleFactor
+
+  // 2. Center our canvas
+  const newLeft = (containerData.width - newWidth) / 2
+  const newTop = (containerData.height - newHeight) / 2
+
+  return {
+    width: newWidth,
+    height: newHeight,
+    left: newLeft,
+    top: newTop,
+  }
+}
+
+export default getCanvasDataThatFitsPerfectlyIntoContainer

+ 19 - 0
packages/@uppy/image-editor/src/utils/getScaleFactorThatRemovesDarkCorners.js

@@ -0,0 +1,19 @@
+function toRadians (angle) {
+  return angle * (Math.PI / 180)
+}
+
+function getScaleFactorThatRemovesDarkCorners (cropboxData, granularAngle) {
+  const α = Math.abs(toRadians(granularAngle))
+
+  const w = cropboxData.width
+  const h = cropboxData.height
+
+  const scaleFactor = Math.max(
+    (Math.sin(α) * w + Math.cos(α) * h) / h,
+    (Math.sin(α) * h + Math.cos(α) * w) / w,
+  )
+
+  return scaleFactor
+}
+
+export default getScaleFactorThatRemovesDarkCorners