Explorar o código

Merge pull request #2370 from transloadit/dashboard-image-cropping

@uppy/image-editor: add long-awaited image cropping
Artur Paikin %!s(int64=4) %!d(string=hai) anos
pai
achega
23d2b2e01b

+ 12 - 0
package-lock.json

@@ -7608,6 +7608,13 @@
         "preact": "8.2.9"
       }
     },
+    "@uppy/image-editor": {
+      "version": "file:packages/@uppy/image-editor",
+      "requires": {
+        "cropperjs": "1.5.6",
+        "preact": "8.2.9"
+      }
+    },
     "@uppy/informer": {
       "version": "file:packages/@uppy/informer",
       "requires": {
@@ -14203,6 +14210,11 @@
         "moment-timezone": "^0.5.25"
       }
     },
+    "cropperjs": {
+      "version": "1.5.6",
+      "resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.5.6.tgz",
+      "integrity": "sha512-eAgWf4j7sNJIG329qUHIFi17PSV0VtuWyAu9glZSgu/KlQSrfTQOC2zAz+jHGa5fAB+bJldEnQwvJEaJ8zRf5A=="
+    },
     "cross-spawn": {
       "version": "6.0.5",
       "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",

+ 2 - 1
package.json

@@ -61,6 +61,7 @@
     "@uppy/react-native": "file:packages/@uppy/react-native",
     "@uppy/redux-dev-tools": "file:packages/@uppy/redux-dev-tools",
     "@uppy/robodog": "file:packages/@uppy/robodog",
+    "@uppy/screen-capture": "file:packages/@uppy/screen-capture",
     "@uppy/status-bar": "file:packages/@uppy/status-bar",
     "@uppy/store-default": "file:packages/@uppy/store-default",
     "@uppy/store-redux": "file:packages/@uppy/store-redux",
@@ -70,8 +71,8 @@
     "@uppy/url": "file:packages/@uppy/url",
     "@uppy/utils": "file:packages/@uppy/utils",
     "@uppy/webcam": "file:packages/@uppy/webcam",
-    "@uppy/screen-capture": "file:packages/@uppy/screen-capture",
     "@uppy/xhr-upload": "file:packages/@uppy/xhr-upload",
+    "@uppy/image-editor": "file:packages/@uppy/image-editor",
     "remark-lint-uppy": "file:private/remark-lint-uppy",
     "uppy": "file:packages/uppy",
     "uppy.io": "file:website"

+ 7 - 2
packages/@uppy/dashboard/src/components/Dashboard.js

@@ -2,6 +2,7 @@ const FileList = require('./FileList')
 const AddFiles = require('./AddFiles')
 const AddFilesPanel = require('./AddFilesPanel')
 const PickerPanelContent = require('./PickerPanelContent')
+const EditorPanel = require('./EditorPanel')
 const PanelTopBar = require('./PickerPanelTopBar')
 const FileCard = require('./FileCard')
 const classNames = require('classnames')
@@ -118,7 +119,7 @@ module.exports = function Dashboard (props) {
           )}
 
           <TransitionWrapper>
-            {props.showAddFilesPanel ? <AddFilesPanel key="AddFilesPanel" {...props} isSizeMD={isSizeMD} /> : null}
+            {props.showAddFilesPanel ? <AddFilesPanel key="AddFiles" {...props} isSizeMD={isSizeMD} /> : null}
           </TransitionWrapper>
 
           <TransitionWrapper>
@@ -126,7 +127,11 @@ module.exports = function Dashboard (props) {
           </TransitionWrapper>
 
           <TransitionWrapper>
-            {props.activePickerPanel ? <PickerPanelContent key="PickerPanelContent" {...props} /> : null}
+            {props.activePickerPanel ? <PickerPanelContent key="Picker" {...props} /> : null}
+          </TransitionWrapper>
+
+          <TransitionWrapper>
+            {props.showFileEditor ? <EditorPanel key="Editor" {...props} /> : null}
           </TransitionWrapper>
 
           <div class="uppy-Dashboard-progressindicators">

+ 36 - 0
packages/@uppy/dashboard/src/components/EditorPanel.js

@@ -0,0 +1,36 @@
+const { h } = require('preact')
+
+function EditorPanel (props) {
+  const file = this.props.files[this.props.fileCardFor]
+
+  return (
+    <div
+      class="uppy-DashboardContent-panel"
+      role="tabpanel"
+      data-uppy-panelType="FileEditor"
+      id="uppy-DashboardContent-panel--editor"
+    >
+      <div class="uppy-DashboardContent-bar">
+        <div class="uppy-DashboardContent-title" role="heading" aria-level="1">
+          {props.i18nArray('editing', {
+            file: <span class="uppy-DashboardContent-titleFile">{file.meta ? file.meta.name : file.name}</span>
+          })}
+        </div>
+        <button
+          class="uppy-DashboardContent-back"
+          type="button"
+          onclick={props.hideAllPanels}
+        >
+          {props.i18n('done')}
+        </button>
+      </div>
+      <div class="uppy-DashboardContent-panelBody">
+        {props.editors.map((target) => {
+          return props.getPlugin(target.id).render(props.state)
+        })}
+      </div>
+    </div>
+  )
+}
+
+module.exports = EditorPanel

+ 9 - 0
packages/@uppy/dashboard/src/components/FileCard/index.js

@@ -85,6 +85,7 @@ class FileCard extends Component {
 
   render () {
     const file = this.props.files[this.props.fileCardFor]
+    const showEditButton = this.props.canEditFile(file)
 
     return (
       <div
@@ -112,6 +113,14 @@ class FileCard extends Component {
         <div class="uppy-Dashboard-FileCard-inner">
           <div class="uppy-Dashboard-FileCard-preview" style={{ backgroundColor: getFileTypeIcon(file.type).color }}>
             <FilePreview file={file} />
+            {showEditButton &&
+              <button
+                type="button"
+                class="uppy-u-reset uppy-c-btn uppy-Dashboard-FileCard-edit"
+                onClick={() => this.props.openFileEditor(file)}
+              >
+                {this.props.i18n('editFile')}
+              </button>}
           </div>
 
           <div class="uppy-Dashboard-FileCard-info">

+ 17 - 0
packages/@uppy/dashboard/src/components/FileCard/index.scss

@@ -49,6 +49,7 @@
       display: flex;
       align-items: center;
       justify-content: center;
+      position: relative;
 
       [data-uppy-theme="dark"] & {
         background-color: $gray-800;
@@ -68,6 +69,22 @@
       }
     // ...uppy-Dashboard-FileCard-preview|
 
+    .uppy-Dashboard-FileCard-edit {
+      @include blue-border-focus;
+      position: absolute;
+      top: 10px;
+      right: 10px;
+      font-size: 13px;
+      background-color: rgba($black, 0.5);
+      color: $white;
+      padding: 7px 15px;
+      border-radius: 50px;
+
+      &:hover {
+        background-color: rgba($black, 0.8);
+      }
+    }
+
     .uppy-Dashboard-FileCard-info {
       height: 40%;
       flex-grow: 0;

+ 7 - 1
packages/@uppy/dashboard/src/components/FilePreview.js

@@ -5,7 +5,13 @@ module.exports = function FilePreview (props) {
   const file = props.file
 
   if (file.preview) {
-    return <img class="uppy-Dashboard-Item-previewImg" alt={file.name} src={file.preview} />
+    return (
+      <img
+        class="uppy-Dashboard-Item-previewImg"
+        alt={file.name}
+        src={file.preview}
+      />
+    )
   }
 
   const { color, icon } = getFileTypeIcon(file.type)

+ 45 - 3
packages/@uppy/dashboard/src/index.js

@@ -176,8 +176,8 @@ module.exports = class Dashboard extends Plugin {
 
     if (callerPluginType !== 'acquirer' &&
         callerPluginType !== 'progressindicator' &&
-        callerPluginType !== 'presenter') {
-      const msg = 'Dashboard: Modal can only be used by plugins of types: acquirer, progressindicator, presenter'
+        callerPluginType !== 'editor') {
+      const msg = 'Dashboard: can only be targeted by plugins of types: acquirer, progressindicator, editor'
       this.uppy.log(msg, 'error')
       return
     }
@@ -203,17 +203,22 @@ module.exports = class Dashboard extends Plugin {
     const update = {
       activePickerPanel: false,
       showAddFilesPanel: false,
-      activeOverlayType: null
+      activeOverlayType: null,
+      fileCardFor: null,
+      showFileEditor: false
     }
 
     const current = this.getPluginState()
     if (current.activePickerPanel === update.activePickerPanel &&
         current.showAddFilesPanel === update.showAddFilesPanel &&
+        current.showFileEditor === update.showFileEditor &&
         current.activeOverlayType === update.activeOverlayType) {
       // avoid doing a state update if nothing changed
       return
     }
 
+    console.log(update)
+
     this.setPluginState(update)
   }
 
@@ -230,6 +235,29 @@ module.exports = class Dashboard extends Plugin {
     })
   }
 
+  canEditFile = (file) => {
+    const { targets } = this.getPluginState()
+    const editors = this._getEditors(targets)
+
+    return editors.some((target) => (
+      this.uppy.getPlugin(target.id).сanEditFile(file)
+    ))
+  }
+
+  openFileEditor = (file) => {
+    const { targets } = this.getPluginState()
+    const editors = this._getEditors(targets)
+
+    this.setPluginState({
+      showFileEditor: true,
+      activeOverlayType: 'FileEditor'
+    })
+
+    editors.forEach((editor) => {
+      this.uppy.getPlugin(editor.id).selectFile(file)
+    })
+  }
+
   openModal = () => {
     const { promise, resolve } = createPromise()
     // save scroll position
@@ -636,6 +664,7 @@ module.exports = class Dashboard extends Plugin {
     this.uppy.on('plugin-remove', this.removeTarget)
     this.uppy.on('file-added', this.hideAllPanels)
     this.uppy.on('dashboard:modal-closed', this.hideAllPanels)
+    this.uppy.on('file-editor:complete', this.hideAllPanels)
     this.uppy.on('complete', this.handleComplete)
 
     // ___Why fire on capture?
@@ -742,6 +771,12 @@ module.exports = class Dashboard extends Plugin {
       .map(this._attachRenderFunctionToTarget)
   })
 
+  _getEditors = memoize((targets) => {
+    return targets
+      .filter(target => target.type === 'editor')
+      .map(this._attachRenderFunctionToTarget)
+  })
+
   render = (state) => {
     const pluginState = this.getPluginState()
     const { files, capabilities, allowNewUpload } = state
@@ -795,6 +830,7 @@ module.exports = class Dashboard extends Plugin {
 
     const acquirers = this._getAcquirers(pluginState.targets)
     const progressindicators = this._getProgressIndicators(pluginState.targets)
+    const editors = this._getEditors(pluginState.targets)
 
     let theme
     if (this.opts.theme === 'auto') {
@@ -824,10 +860,12 @@ module.exports = class Dashboard extends Plugin {
       acquirers,
       theme,
       activePickerPanel: pluginState.activePickerPanel,
+      showFileEditor: pluginState.showFileEditor,
       animateOpenClose: this.opts.animateOpenClose,
       isClosing: pluginState.isClosing,
       getPlugin: this.uppy.getPlugin,
       progressindicators: progressindicators,
+      editors: editors,
       autoProceed: this.uppy.opts.autoProceed,
       id: this.id,
       closeModal: this.requestCloseModal,
@@ -841,6 +879,7 @@ module.exports = class Dashboard extends Plugin {
       i18n: this.i18n,
       i18nArray: this.i18nArray,
       removeFile: this.uppy.removeFile,
+      uppy: this.uppy,
       info: this.uppy.info,
       note: this.opts.note,
       metaFields: pluginState.metaFields,
@@ -856,6 +895,8 @@ module.exports = class Dashboard extends Plugin {
       toggleAddFilesPanel: this.toggleAddFilesPanel,
       showAddFilesPanel: pluginState.showAddFilesPanel,
       saveFileCard: this.saveFileCard,
+      openFileEditor: this.openFileEditor,
+      canEditFile: this.canEditFile,
       width: this.opts.width,
       height: this.opts.height,
       showLinkToFileUploadResult: this.opts.showLinkToFileUploadResult,
@@ -898,6 +939,7 @@ module.exports = class Dashboard extends Plugin {
       activeOverlayType: null,
       showAddFilesPanel: false,
       activePickerPanel: false,
+      showFileEditor: false,
       metaFields: this.opts.metaFields,
       targets: [],
       // We'll make them visible once .containerWidth is determined

+ 21 - 0
packages/@uppy/image-editor/LICENSE

@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2020 Transloadit
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 47 - 0
packages/@uppy/image-editor/README.md

@@ -0,0 +1,47 @@
+# @uppy/image-editor
+
+<img src="https://uppy.io/images/logos/uppy-dog-head-arrow.svg" width="120" alt="Uppy logo: a superman puppy in a pink suit" align="right">
+
+<a href="https://www.npmjs.com/package/@uppy/image-editor"><img src="https://img.shields.io/npm/v/@uppy/image-editor.svg?style=flat-square"></a>
+<a href="https://travis-ci.org/transloadit/uppy"><img src="https://img.shields.io/travis/transloadit/uppy/master.svg?style=flat-square" alt="Build Status"></a>
+
+Image Editor is an image cropping and editing plugin for Uppy. Designed to be used with the Dashboard UI (can in theory work without it).
+
+⚠ In beta.
+
+**[Read the docs](https://uppy.io/docs/image-editor)** | **[Try it](https://uppy.io/examples/dashboard/)**
+
+Uppy is being developed by the folks at [Transloadit](https://transloadit.com), a versatile file encoding service.
+
+## Example
+
+```js
+const Uppy = require('@uppy/core')
+const Dashboard = require('@uppy/dashboard')
+const ImageEditor = require('@uppy/image-editor')
+
+const uppy = Uppy()
+uppy.use(Dashboard)
+uppy.use(ImageEditor, { 
+  target: Dashboard,
+  quality: 0.7
+})
+```
+
+## Installation
+
+```bash
+$ npm install @uppy/image-editor --save
+```
+
+We recommend installing from npm and then using a module bundler such as [Webpack](https://webpack.js.org/), [Browserify](http://browserify.org/) or [Rollup.js](http://rollupjs.org/).
+
+Alternatively, you can also use this plugin in a pre-built bundle from Transloadit's CDN: Edgly. In that case `Uppy` will attach itself to the global `window.Uppy` object. See the [main Uppy documentation](https://uppy.io/docs/#Installation) for instructions.
+
+## Documentation
+
+Documentation for this plugin can be found on the [Uppy website](https://uppy.io/docs/image-editor).
+
+## License
+
+[The MIT License](./LICENSE).

+ 32 - 0
packages/@uppy/image-editor/package.json

@@ -0,0 +1,32 @@
+{
+  "name": "@uppy/image-editor",
+  "description": "Image editor and cropping UI",
+  "version": "0.1.0",
+  "license": "MIT",
+  "main": "lib/index.js",
+  "style": "dist/style.min.css",
+  "types": "types/index.d.ts",
+  "keywords": [
+    "file uploader",
+    "upload",
+    "uppy",
+    "uppy-plugin",
+    "image-editor",
+    "cropper"
+  ],
+  "homepage": "https://uppy.io",
+  "bugs": {
+    "url": "https://github.com/transloadit/uppy/issues"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/transloadit/uppy.git"
+  },
+  "dependencies": {
+    "preact": "8.2.9",
+    "cropperjs": "1.5.7"
+  },
+  "peerDependencies": {
+    "@uppy/core": "^1.0.0"
+  }
+}

+ 174 - 0
packages/@uppy/image-editor/src/Editor.js

@@ -0,0 +1,174 @@
+const Cropper = require('cropperjs')
+const { h, Component } = require('preact')
+
+module.exports = class Editor extends Component {
+  componentDidMount () {
+    this.cropper = new Cropper(
+      this.imgElement,
+      this.props.opts.cropperOptions
+    )
+  }
+
+  componentWillUnmount () {
+    this.cropper.destroy()
+  }
+
+  save = () => {
+    this.cropper.getCroppedCanvas()
+      .toBlob(
+        (blob) => this.props.save(blob),
+        this.props.currentImage.type,
+        this.props.opts.quality
+      )
+  }
+
+  render () {
+    const { currentImage, i18n } = this.props
+    const imageURL = URL.createObjectURL(currentImage.data)
+
+    return (
+      <div class="uppy-ImageCropper">
+        <div class="uppy-ImageCropper-container">
+          <img
+            class="uppy-ImageCropper-image"
+            alt={currentImage.name}
+            src={imageURL}
+            ref={ref => { this.imgElement = ref }}
+          />
+        </div>
+
+        <div class="uppy-ImageCropper-controls">
+          <button
+            type="button"
+            class="uppy-u-reset uppy-c-btn"
+            aria-label={i18n('save')}
+            data-microtip-position="top"
+            role="tooltip"
+            onClick={() => this.save()}
+          >
+            <svg aria-hidden="true" class="uppy-c-icon" width="24" height="24" viewBox="0 0 24 24">
+              <path d="M0 0h24v24H0z" fill="none" />
+              <path d="M9 16.2L4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4L9 16.2z" />
+            </svg>
+          </button>
+
+          <button
+            type="button"
+            class="uppy-u-reset uppy-c-btn"
+            aria-label={i18n('revert')}
+            data-microtip-position="top"
+            role="tooltip"
+            onClick={() => {
+              this.cropper.reset()
+              this.cropper.setAspectRatio(0)
+            }}
+          >
+            <svg aria-hidden="true" class="uppy-c-icon" width="24" height="24" viewBox="0 0 24 24">
+              <path d="M0 0h24v24H0z" fill="none" />
+              <path d="M13 3c-4.97 0-9 4.03-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42C8.27 19.99 10.51 21 13 21c4.97 0 9-4.03 9-9s-4.03-9-9-9zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z" />
+            </svg>
+          </button>
+
+          <button
+            type="button"
+            class="uppy-u-reset uppy-c-btn"
+            onClick={() => this.cropper.rotate(90)}
+            aria-label={i18n('rotate')}
+            data-microtip-position="top"
+            role="tooltip"
+          >
+            <svg aria-hidden="true" class="uppy-c-icon" width="24" height="24" viewBox="0 0 24 24">
+              <path d="M0 0h24v24H0V0zm0 0h24v24H0V0z" fill="none" />
+              <path d="M7.47 21.49C4.2 19.93 1.86 16.76 1.5 13H0c.51 6.16 5.66 11 11.95 11 .23 0 .44-.02.66-.03L8.8 20.15l-1.33 1.34zM12.05 0c-.23 0-.44.02-.66.04l3.81 3.81 1.33-1.33C19.8 4.07 22.14 7.24 22.5 11H24c-.51-6.16-5.66-11-11.95-11zM16 14h2V8c0-1.11-.9-2-2-2h-6v2h6v6zm-8 2V4H6v2H4v2h2v8c0 1.1.89 2 2 2h8v2h2v-2h2v-2H8z" />
+            </svg>
+          </button>
+
+          <button
+            type="button"
+            class="uppy-u-reset uppy-c-btn"
+            aria-label={i18n('flipHorizontal')}
+            data-microtip-position="top"
+            role="tooltip"
+            onClick={() => this.cropper.scaleX(-this.cropper.getData().scaleX || -1)}
+          >
+            <svg aria-hidden="true" class="uppy-c-icon" width="24" height="24" viewBox="0 0 24 24">
+              <path d="M0 0h24v24H0z" fill="none" />
+              <path d="M15 21h2v-2h-2v2zm4-12h2V7h-2v2zM3 5v14c0 1.1.9 2 2 2h4v-2H5V5h4V3H5c-1.1 0-2 .9-2 2zm16-2v2h2c0-1.1-.9-2-2-2zm-8 20h2V1h-2v22zm8-6h2v-2h-2v2zM15 5h2V3h-2v2zm4 8h2v-2h-2v2zm0 8c1.1 0 2-.9 2-2h-2v2z" />
+            </svg>
+          </button>
+
+          <button
+            type="button"
+            class="uppy-u-reset uppy-c-btn"
+            aria-label={i18n('zoomIn')}
+            data-microtip-position="top"
+            role="tooltip"
+            onClick={() => this.cropper.zoom(0.1)}
+          >
+            <svg aria-hidden="true" class="uppy-c-icon" height="24" viewBox="0 0 24 24" width="24">
+              <path d="M0 0h24v24H0V0z" fill="none" />
+              <path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z" />
+              <path d="M12 10h-2v2H9v-2H7V9h2V7h1v2h2v1z" />
+            </svg>
+          </button>
+
+          <button
+            type="button"
+            class="uppy-u-reset uppy-c-btn"
+            aria-label={i18n('zoomOut')}
+            data-microtip-position="top"
+            role="tooltip"
+            onClick={() => this.cropper.zoom(-0.1)}
+          >
+            <svg aria-hidden="true" class="uppy-c-icon" width="24" height="24" viewBox="0 0 24 24">
+              <path d="M0 0h24v24H0V0z" fill="none" />
+              <path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14zM7 9h5v1H7z" />
+            </svg>
+          </button>
+
+          <button
+            type="button"
+            class="uppy-u-reset uppy-c-btn"
+            aria-label={i18n('aspectRatioSquare')}
+            data-microtip-position="top"
+            role="tooltip"
+            onClick={() => this.cropper.setAspectRatio(1)}
+          >
+            <svg aria-hidden="true" class="uppy-c-icon" width="24" height="24" viewBox="0 0 24 24">
+              <path d="M0 0h24v24H0z" fill="none" />
+              <path d="M19 5v14H5V5h14m0-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z" />
+            </svg>
+          </button>
+
+          <button
+            type="button"
+            class="uppy-u-reset uppy-c-btn"
+            aria-label={i18n('aspectRatioLandscape')}
+            data-microtip-position="top"
+            role="tooltip"
+            onClick={() => this.cropper.setAspectRatio(16 / 9)}
+          >
+            <svg aria-hidden="true" class="uppy-c-icon" width="24" height="24" viewBox="0 0 24 24">
+              <path d="M 19,4.9999992 V 17.000001 H 4.9999998 V 6.9999992 H 19 m 0,-2 H 4.9999998 c -1.0999999,0 -1.9999999,0.9000001 -1.9999999,2 V 17.000001 c 0,1.1 0.9,2 1.9999999,2 H 19 c 1.1,0 2,-0.9 2,-2 V 6.9999992 c 0,-1.0999999 -0.9,-2 -2,-2 z" />
+              <path fill="none" d="M0 0h24v24H0z" />
+            </svg>
+          </button>
+
+          <button
+            type="button"
+            class="uppy-u-reset uppy-c-btn"
+            aria-label={i18n('aspectRatioPortrait')}
+            data-microtip-position="top"
+            role="tooltip"
+            onClick={() => this.cropper.setAspectRatio(9 / 16)}
+          >
+            <svg aria-hidden="true" class="uppy-c-icon" width="24" height="24" viewBox="0 0 24 24">
+              <path d="M 19.000001,19 H 6.999999 V 5 h 10.000002 v 14 m 2,0 V 5 c 0,-1.0999999 -0.9,-1.9999999 -2,-1.9999999 H 6.999999 c -1.1,0 -2,0.9 -2,1.9999999 v 14 c 0,1.1 0.9,2 2,2 h 10.000002 c 1.1,0 2,-0.9 2,-2 z" />
+              <path d="M0 0h24v24H0z" fill="none" />
+            </svg>
+          </button>
+        </div>
+      </div>
+    )
+  }
+}

+ 304 - 0
packages/@uppy/image-editor/src/cropper.scss

@@ -0,0 +1,304 @@
+/*!
+ * Cropper.js v1.5.6
+ * https://fengyuanchen.github.io/cropperjs
+ *
+ * Copyright 2015-present Chen Fengyuan
+ * Released under the MIT license
+ *
+ * Date: 2019-10-04T04:33:44.164Z
+ */
+
+ .cropper-container {
+  direction: ltr;
+  font-size: 0;
+  line-height: 0;
+  position: relative;
+  -ms-touch-action: none;
+  touch-action: none;
+  -webkit-user-select: none;
+  -moz-user-select: none;
+  -ms-user-select: none;
+  user-select: none;
+}
+
+.cropper-container img {
+  display: block;
+  height: 100%;
+  image-orientation: 0deg;
+  max-height: none !important;
+  max-width: none !important;
+  min-height: 0 !important;
+  min-width: 0 !important;
+  width: 100%;
+}
+
+.cropper-wrap-box,
+.cropper-canvas,
+.cropper-drag-box,
+.cropper-crop-box,
+.cropper-modal {
+  bottom: 0;
+  left: 0;
+  position: absolute;
+  right: 0;
+  top: 0;
+}
+
+.cropper-wrap-box,
+.cropper-canvas {
+  overflow: hidden;
+}
+
+.cropper-drag-box {
+  background-color: #fff;
+  opacity: 0;
+}
+
+.cropper-modal {
+  background-color: #000;
+  opacity: 0.5;
+}
+
+.cropper-view-box {
+  display: block;
+  height: 100%;
+  outline: 1px solid #39f;
+  outline-color: rgba(51, 153, 255, 0.75);
+  overflow: hidden;
+  width: 100%;
+}
+
+.cropper-dashed {
+  border: 0 dashed #eee;
+  display: block;
+  opacity: 0.5;
+  position: absolute;
+}
+
+.cropper-dashed.dashed-h {
+  border-bottom-width: 1px;
+  border-top-width: 1px;
+  height: calc(100% / 3);
+  left: 0;
+  top: calc(100% / 3);
+  width: 100%;
+}
+
+.cropper-dashed.dashed-v {
+  border-left-width: 1px;
+  border-right-width: 1px;
+  height: 100%;
+  left: calc(100% / 3);
+  top: 0;
+  width: calc(100% / 3);
+}
+
+.cropper-center {
+  display: block;
+  height: 0;
+  left: 50%;
+  opacity: 0.75;
+  position: absolute;
+  top: 50%;
+  width: 0;
+}
+
+.cropper-center::before,
+.cropper-center::after {
+  background-color: #eee;
+  content: ' ';
+  display: block;
+  position: absolute;
+}
+
+.cropper-center::before {
+  height: 1px;
+  left: -3px;
+  top: 0;
+  width: 7px;
+}
+
+.cropper-center::after {
+  height: 7px;
+  left: 0;
+  top: -3px;
+  width: 1px;
+}
+
+.cropper-face,
+.cropper-line,
+.cropper-point {
+  display: block;
+  height: 100%;
+  opacity: 0.1;
+  position: absolute;
+  width: 100%;
+}
+
+.cropper-face {
+  background-color: #fff;
+  left: 0;
+  top: 0;
+}
+
+.cropper-line {
+  background-color: #39f;
+}
+
+.cropper-line.line-e {
+  cursor: ew-resize;
+  right: -3px;
+  top: 0;
+  width: 5px;
+}
+
+.cropper-line.line-n {
+  cursor: ns-resize;
+  height: 5px;
+  left: 0;
+  top: -3px;
+}
+
+.cropper-line.line-w {
+  cursor: ew-resize;
+  left: -3px;
+  top: 0;
+  width: 5px;
+}
+
+.cropper-line.line-s {
+  bottom: -3px;
+  cursor: ns-resize;
+  height: 5px;
+  left: 0;
+}
+
+.cropper-point {
+  background-color: #39f;
+  height: 5px;
+  opacity: 0.75;
+  width: 5px;
+}
+
+.cropper-point.point-e {
+  cursor: ew-resize;
+  margin-top: -3px;
+  right: -3px;
+  top: 50%;
+}
+
+.cropper-point.point-n {
+  cursor: ns-resize;
+  left: 50%;
+  margin-left: -3px;
+  top: -3px;
+}
+
+.cropper-point.point-w {
+  cursor: ew-resize;
+  left: -3px;
+  margin-top: -3px;
+  top: 50%;
+}
+
+.cropper-point.point-s {
+  bottom: -3px;
+  cursor: s-resize;
+  left: 50%;
+  margin-left: -3px;
+}
+
+.cropper-point.point-ne {
+  cursor: nesw-resize;
+  right: -3px;
+  top: -3px;
+}
+
+.cropper-point.point-nw {
+  cursor: nwse-resize;
+  left: -3px;
+  top: -3px;
+}
+
+.cropper-point.point-sw {
+  bottom: -3px;
+  cursor: nesw-resize;
+  left: -3px;
+}
+
+.cropper-point.point-se {
+  bottom: -3px;
+  cursor: nwse-resize;
+  height: 20px;
+  opacity: 1;
+  right: -3px;
+  width: 20px;
+}
+
+@media (min-width: 768px) {
+  .cropper-point.point-se {
+    height: 15px;
+    width: 15px;
+  }
+}
+
+@media (min-width: 992px) {
+  .cropper-point.point-se {
+    height: 10px;
+    width: 10px;
+  }
+}
+
+@media (min-width: 1200px) {
+  .cropper-point.point-se {
+    height: 5px;
+    opacity: 0.75;
+    width: 5px;
+  }
+}
+
+.cropper-point.point-se::before {
+  background-color: #39f;
+  bottom: -50%;
+  content: ' ';
+  display: block;
+  height: 200%;
+  opacity: 0;
+  position: absolute;
+  right: -50%;
+  width: 200%;
+}
+
+.cropper-invisible {
+  opacity: 0;
+}
+
+.cropper-bg {
+  background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC');
+}
+
+.cropper-hide {
+  display: block;
+  height: 0;
+  position: absolute;
+  width: 0;
+}
+
+.cropper-hidden {
+  display: none !important;
+}
+
+.cropper-move {
+  cursor: move;
+}
+
+.cropper-crop {
+  cursor: crosshair;
+}
+
+.cropper-disabled .cropper-drag-box,
+.cropper-disabled .cropper-face,
+.cropper-disabled .cropper-line,
+.cropper-disabled .cropper-point {
+  cursor: not-allowed;
+}

+ 132 - 0
packages/@uppy/image-editor/src/index.js

@@ -0,0 +1,132 @@
+const { Plugin } = require('@uppy/core')
+const Editor = require('./Editor')
+const Translator = require('@uppy/utils/lib/Translator')
+const { h } = require('preact')
+
+module.exports = class ImageEditor extends Plugin {
+  static VERSION = require('../package.json').version
+
+  constructor (uppy, opts) {
+    super(uppy, opts)
+    this.id = this.opts.id || 'ImageEditor'
+    this.title = 'Image Editor'
+    this.type = 'editor'
+
+    this.defaultLocale = {
+      strings: {
+        save: 'Save',
+        revert: 'Revert',
+        rotate: 'Rotate',
+        zoomIn: 'Zoom in',
+        zoomOut: 'Zoom out',
+        flipHorizontal: 'Flip horizonal',
+        aspectRatioSquare: 'Crop square',
+        aspectRatioLandscape: 'Crop landscape (16:9)',
+        aspectRatioPortrait: 'Crop portrait (9:16)'
+      }
+    }
+
+    const defaultCropperOptions = {
+      viewMode: 1,
+      background: false,
+      autoCropArea: 1,
+      responsive: true
+    }
+
+    const defaultOptions = {
+      quality: 0.8
+    }
+
+    this.opts = {
+      ...defaultOptions,
+      ...opts,
+      cropperOptions: {
+        ...defaultCropperOptions,
+        ...opts.cropperOptions
+      }
+    }
+
+    this.i18nInit()
+  }
+
+  setOptions (newOpts) {
+    super.setOptions(newOpts)
+    this.i18nInit()
+  }
+
+  i18nInit () {
+    this.translator = new Translator([this.defaultLocale, this.uppy.locale, this.opts.locale])
+    this.i18n = this.translator.translate.bind(this.translator)
+    // this.i18nArray = this.translator.translateArray.bind(this.translator)
+    this.setPluginState() // so that UI re-renders and we see the updated locale
+  }
+
+  сanEditFile (file) {
+    if (!file.type) {
+      return false
+    }
+
+    const fileTypeSpecific = file.type.split('/')[1]
+
+    if (/^(jpe?g|gif|png|bmp|webp)$/.test(fileTypeSpecific)) {
+      return true
+    }
+
+    return false
+  }
+
+  save = (blob) => {
+    const { currentImage } = this.getPluginState()
+
+    this.uppy.setFileState(currentImage.id, {
+      data: blob,
+      size: blob.size,
+      preview: null
+    })
+
+    const updatedFile = this.uppy.getFile(currentImage.id)
+    this.uppy.emit('thumbnail:request', updatedFile)
+    this.setPluginState({
+      currentImage: updatedFile
+    })
+    this.uppy.emit('file-editor:complete', updatedFile)
+  }
+
+  selectFile = (file) => {
+    this.uppy.emit('file-editor:start', file)
+    this.setPluginState({
+      currentImage: file
+    })
+  }
+
+  install () {
+    this.setPluginState({
+      currentImage: null
+    })
+
+    const target = this.opts.target
+    if (target) {
+      this.mount(target, this)
+    }
+  }
+
+  uninstall () {
+    this.unmount()
+  }
+
+  render () {
+    const { currentImage } = this.getPluginState()
+    if (currentImage === null) {
+      return
+    }
+
+    return (
+      <Editor
+        currentImage={currentImage}
+        save={this.save}
+        opts={this.opts}
+        i18n={this.i18n}
+      />
+    )
+  }
+}

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

@@ -0,0 +1,44 @@
+@import '@uppy/core/src/_utils.scss';
+@import '@uppy/core/src/_variables.scss';
+@import './cropper.scss';
+
+.uppy-ImageCropper {
+  display: flex;
+  flex-direction: column;
+  width: 100%;
+  height: 100%;
+}
+
+.uppy-ImageCropper-container {
+  flex-grow: 1;
+}
+
+  .uppy-ImageCropper-image {
+    display: block;
+    max-height: 400px;
+  }
+
+.uppy-ImageCropper-controls {
+  position: absolute;
+  bottom: 15px;
+  left: 50%;
+  transform: translateX(-50%);
+  background-color: rgba($black, 0.6);
+  color: $white;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+
+.uppy-ImageCropper-controls button {
+  padding: 5px 8px;
+
+  &:hover {
+    background-color: rgba($blue, 0.8);
+  }
+
+  &:focus {
+    outline: none;
+    background-color: rgba($blue, 0.8);
+  }
+}

+ 15 - 0
packages/@uppy/image-editor/types/index.d.ts

@@ -0,0 +1,15 @@
+import Uppy = require('@uppy/core')
+import ImageEditorLocale = require('./generatedLocale')
+
+declare module ImageEditor {
+  export interface ImageEditorOptions extends Uppy.PluginOptions {
+    cropperOptions?: object
+    quality?: number,
+    target?: Uppy.PluginTarget
+    locale?: ImageEditorLocale
+  }
+}
+
+declare class ImageEditor extends Uppy.Plugin<ImageEditor.ImageEditorOptions> {}
+
+export = ImageEditor

+ 1 - 0
packages/@uppy/image-editor/types/index.test-d.ts

@@ -0,0 +1 @@
+import ImageEditor = require('../')

+ 10 - 1
packages/@uppy/locales/src/en_US.js

@@ -10,6 +10,9 @@ en_US.strings = {
   addingMoreFiles: 'Adding more files',
   allowAccessDescription: 'In order to take pictures or record video with your camera, please allow camera access for this site.',
   allowAccessTitle: 'Please allow access to your camera',
+  aspectRatioLandscape: 'Crop landscape (16:9)',
+  aspectRatioPortrait: 'Crop portrait (9:16)',
+  aspectRatioSquare: 'Crop square',
   authenticateWith: 'Connect to %{pluginName}',
   authenticateWithTitle: 'Please authenticate with %{pluginName} to select files',
   back: 'Back',
@@ -52,6 +55,7 @@ en_US.strings = {
   },
   filter: 'Filter',
   finishEditingFile: 'Finish editing file',
+  flipHorizontal: 'Flip horizonal',
   folderAdded: {
     '0': 'Added %{smart_count} file from %{folder}',
     '1': 'Added %{smart_count} files from %{folder}'
@@ -88,6 +92,9 @@ en_US.strings = {
   resumeUpload: 'Resume upload',
   retry: 'Retry',
   retryUpload: 'Retry upload',
+  revert: 'Revert',
+  rotate: 'Rotate',
+  save: 'Save',
   saveChanges: 'Save changes',
   selectAllFilesFromFolderNamed: 'Select all files from folder %{name}',
   selectFileNamed: 'Select file %{name}',
@@ -141,7 +148,9 @@ en_US.strings = {
   youHaveToAtLeastSelectX: {
     '0': 'You have to select at least %{smart_count} file',
     '1': 'You have to select at least %{smart_count} files'
-  }
+  },
+  zoomIn: 'Zoom in',
+  zoomOut: 'Zoom out'
 }
 
 en_US.pluralize = function (count) {

+ 1 - 0
packages/uppy/index.js

@@ -42,5 +42,6 @@ exports.Form = require('@uppy/form')
 exports.GoldenRetriever = require('@uppy/golden-retriever')
 exports.ReduxDevTools = require('@uppy/redux-dev-tools')
 exports.ThumbnailGenerator = require('@uppy/thumbnail-generator')
+exports.ImageEditor = require('@uppy/image-editor')
 
 exports.locales = {}

+ 1 - 0
packages/uppy/index.mjs

@@ -40,3 +40,4 @@ export { default as Form } from '@uppy/form'
 export { default as GoldenRetriever } from '@uppy/golden-retriever'
 export { default as ReduxDevTools } from '@uppy/redux-dev-tools'
 export { default as ThumbnailGenerator } from '@uppy/thumbnail-generator'
+export { default as ImageEditor } from '@uppy/image-editor'

+ 2 - 1
packages/uppy/src/style.scss

@@ -8,4 +8,5 @@
 @import '@uppy/status-bar/src/style.scss';
 @import '@uppy/url/src/style.scss';
 @import '@uppy/webcam/src/style.scss';
-@import '@uppy/screen-capture/src/style.scss'
+@import '@uppy/screen-capture/src/style.scss';
+@import '@uppy/image-editor/src/style.scss';

+ 1 - 0
website/inject.js

@@ -49,6 +49,7 @@ const packages = [
   '@uppy/google-drive',
   '@uppy/informer',
   '@uppy/instagram',
+  '@uppy/image-editor',
   '@uppy/progress-bar',
   '@uppy/screen-capture',
   '@uppy/status-bar',

+ 97 - 0
website/src/docs/image-editor.md

@@ -0,0 +1,97 @@
+---
+type: docs
+order: 2
+title: "Image Editor"
+module: "@uppy/image-editor"
+permalink: docs/image-editor/
+category: "Miscellaneous"
+tagline: "allows users to crop, rotate, zoom and flip images that are added to Uppy"
+---
+
+`@uppy/image-editor` allows users to crop, rotate, zoom and flip images that are added to Uppy.
+
+Designed to be used with the Dashboard UI (can in theory work without it).
+
+⚠ In beta.
+
+![Screenshor of the image editor in Dashboard](https://user-images.githubusercontent.com/1199054/87208710-654db400-c307-11ea-9471-6e3c6582d2a5.png)
+
+```js
+const Uppy = require('@uppy/core')
+const Dashboard = require('@uppy/dashboard')
+const ImageEditor = require('@uppy/image-editor')
+
+const uppy = Uppy()
+uppy.use(Dashboard)
+uppy.use(ImageEditor, { 
+  target: Dashboard,
+  quality: 0.7
+})
+```
+
+## Installation
+
+This plugin is published as the `@uppy/image-editor` package.
+
+Install from NPM:
+
+```shell
+npm install @uppy/image-editor
+```
+
+In the [CDN package](/docs/#With-a-script-tag), it is available on the `Uppy` global object:
+
+```js
+const ImageEditor = Uppy.ImageEditor
+```
+
+## Options
+
+The `@uppy/image-editor` plugin has the following configurable options:
+
+```js
+uppy.use(ImageEditor, {
+  id: 'ImageEditor',
+  quality: 0.8,
+  cropperOptions: { 
+    viewMode: 1,
+    background: false,
+    autoCropArea: 1,
+    responsive: true
+  }
+})
+```
+
+### `id: 'ImageEditor'`
+
+A unique identifier for this plugin. It defaults to `'ThumbnailGenerator'`.
+
+### `quality: 0.8`
+
+Quality of the resulting blob that will be saved in Uppy after editing/cropping.
+
+### `cropperOptions`
+
+Image Editor is using the excellent [Cropper.js](https://fengyuanchen.github.io/cropperjs/), and if you’d like to fine tune the Cropper.js instance, you can pass options to it.
+
+## Events
+
+### file-editor:start
+
+Emitted when `selectFile(file)` is called.
+
+```js
+uppy.on('file-editor:start', (file) => {
+  console.log(file)
+})
+```
+
+### file-editor:complete
+
+Emitted after `save(blob)` is called.
+
+```js
+uppy.on('file-editor:complete', (updatedFile) => {
+  console.log(updatedFile)
+})
+```

+ 8 - 0
website/src/examples/dashboard/app.es6

@@ -7,6 +7,7 @@ const Dropbox = require('@uppy/dropbox')
 const Instagram = require('@uppy/instagram')
 const Facebook = require('@uppy/facebook')
 const OneDrive = require('@uppy/onedrive')
+const ImageEditor = require('@uppy/image-editor')
 const Url = require('@uppy/url')
 const Webcam = require('@uppy/webcam')
 const ScreenCapture = require('@uppy/screen-capture')
@@ -146,6 +147,13 @@ function uppySetOptions () {
   } else {
     window.uppy.removePlugin(screenCaptureInstance)
   }
+
+  const imageEditorInstance = window.uppy.getPlugin('ImageEditor')
+  if (opts.imageEditor && !imageEditorInstance) {
+    window.uppy.use(ImageEditor, { target: Dashboard })
+  } else {
+    window.uppy.removePlugin(imageEditorInstance)
+  }
 }
 
 function whenLocaleAvailable (localeName, callback) {

+ 6 - 3
website/src/examples/dashboard/app.html

@@ -6,7 +6,8 @@
     <li><label for="opts-DashboardInline"><input type="checkbox" id="opts-DashboardInline" checked/> Display inline</label></li>
     <li><label for="opts-autoProceed"><input type="checkbox" id="opts-autoProceed" checked/> Autoproceed</label></li>
     <li><label for="opts-restrictions"><input type="checkbox" id="opts-restrictions" checked/> Restrictions</label></li>
-    <li><label for="opts-darkMode"><input type="checkbox" id="opts-darkMode" checked/> Dark Mode</label></li>
+    <li><label for="opts-darkMode"><input type="checkbox" id="opts-darkMode" /> Dark Mode</label></li>
+    <li><label for="opts-imageEditor"><input type="checkbox" id="opts-imageEditor" checked/> Image Editor</label></li>
   </ul>
   <ul>
     <li><label for="opts-Webcam"><input type="checkbox" id="opts-Webcam" checked/> Webcam</label></li>
@@ -47,7 +48,8 @@
     Url: document.querySelector('#opts-Url'),
     autoProceed: document.querySelector('#opts-autoProceed'),
     restrictions: document.querySelector('#opts-restrictions'),
-    darkMode: document.querySelector('#opts-darkMode')
+    darkMode: document.querySelector('#opts-darkMode'),
+    imageEditor: document.querySelector('#opts-imageEditor')
   }
 
   var defaultOpts = {
@@ -62,7 +64,8 @@
     Url: true,
     autoProceed: false,
     restrictions: false,
-    darkMode: true
+    darkMode: false,
+    imageEditor: true
   }
 
   // try to get options from localStorage, if its empty, set to defaultOpts