Prechádzať zdrojové kódy

@uppy/image-editor: migrate to TS (#4880)

Merlijn Vos 1 rok pred
rodič
commit
d18bb0c468

+ 6 - 2
packages/@uppy/core/src/BasePlugin.ts

@@ -11,7 +11,11 @@
  */
 
 import Translator from '@uppy/utils/lib/Translator'
-import type { I18n, Locale } from '@uppy/utils/lib/Translator'
+import type {
+  I18n,
+  Locale,
+  OptionalPluralizeLocale,
+} from '@uppy/utils/lib/Translator'
 import type { Body, Meta } from '@uppy/utils/lib/UppyFile'
 import type { State, Uppy } from './Uppy'
 
@@ -38,7 +42,7 @@ export default class BasePlugin<
 
   id: string
 
-  defaultLocale: Locale
+  defaultLocale: OptionalPluralizeLocale
 
   i18n: I18n
 

+ 1 - 1
packages/@uppy/core/src/index.ts

@@ -1,5 +1,5 @@
 export { default } from './Uppy.ts'
-export { default as Uppy } from './Uppy.ts'
+export { default as Uppy, type UppyEventMap } from './Uppy.ts'
 export { default as UIPlugin } from './UIPlugin.ts'
 export { default as BasePlugin } from './BasePlugin.ts'
 export { debugLogger } from './loggers.ts'

+ 140 - 44
packages/@uppy/image-editor/src/Editor.jsx → packages/@uppy/image-editor/src/Editor.tsx

@@ -1,13 +1,39 @@
 /* eslint-disable jsx-a11y/label-has-associated-control */
 import Cropper from 'cropperjs'
 import { h, Component } from 'preact'
-import getCanvasDataThatFitsPerfectlyIntoContainer from './utils/getCanvasDataThatFitsPerfectlyIntoContainer.js'
-import getScaleFactorThatRemovesDarkCorners from './utils/getScaleFactorThatRemovesDarkCorners.js'
-import limitCropboxMovementOnMove from './utils/limitCropboxMovementOnMove.js'
-import limitCropboxMovementOnResize from './utils/limitCropboxMovementOnResize.js'
+import type { ChangeEvent } from 'react'
+import type { Meta, Body, UppyFile } from '@uppy/utils/lib/UppyFile'
+import type { I18n } from '@uppy/utils/lib/Translator'
+import getCanvasDataThatFitsPerfectlyIntoContainer from './utils/getCanvasDataThatFitsPerfectlyIntoContainer.ts'
+import getScaleFactorThatRemovesDarkCorners from './utils/getScaleFactorThatRemovesDarkCorners.ts'
+import limitCropboxMovementOnMove from './utils/limitCropboxMovementOnMove.ts'
+import limitCropboxMovementOnResize from './utils/limitCropboxMovementOnResize.ts'
+import type { ImageEditorOpts } from './ImageEditor.tsx'
+
+type Props<M extends Meta, B extends Body> = {
+  currentImage: UppyFile<M, B>
+  storeCropperInstance: (cropper: Cropper) => void
+  opts: ImageEditorOpts
+  i18n: I18n
+  // eslint-disable-next-line react/no-unused-prop-types
+  save: () => void // eslint confused
+}
+
+type State = {
+  angle90Deg: number
+  angleGranular: number
+  prevCropboxData: Cropper.CropBoxData | null
+}
+
+export default class Editor<M extends Meta, B extends Body> extends Component<
+  Props<M, B>,
+  State
+> {
+  imgElement: HTMLImageElement
 
-export default class Editor extends Component {
-  constructor (props) {
+  cropper: Cropper
+
+  constructor(props: Props<M, B>) {
     super(props)
     this.state = {
       angle90Deg: 0,
@@ -18,48 +44,55 @@ export default class Editor extends Component {
     this.limitCropboxMovement = this.limitCropboxMovement.bind(this)
   }
 
-  componentDidMount () {
+  componentDidMount(): void {
     const { opts, storeCropperInstance } = this.props
-    this.cropper = new Cropper(
-      this.imgElement,
-      opts.cropperOptions,
-    )
+    this.cropper = new Cropper(this.imgElement, opts.cropperOptions)
 
     this.imgElement.addEventListener('cropstart', this.storePrevCropboxData)
+    // @ts-expect-error custom cropper event but DOM API does not understand
     this.imgElement.addEventListener('cropend', this.limitCropboxMovement)
 
     storeCropperInstance(this.cropper)
   }
 
-  componentWillUnmount () {
+  componentWillUnmount(): void {
     this.cropper.destroy()
 
     this.imgElement.removeEventListener('cropstart', this.storePrevCropboxData)
+    // @ts-expect-error custom cropper event but DOM API does not understand
     this.imgElement.removeEventListener('cropend', this.limitCropboxMovement)
   }
 
   // eslint-disable-next-line react/sort-comp
-  storePrevCropboxData () {
+  storePrevCropboxData(): void {
     this.setState({ prevCropboxData: this.cropper.getCropBoxData() })
   }
 
-  limitCropboxMovement (event) {
+  limitCropboxMovement(event: { detail: { action: string } }): void {
     const canvasData = this.cropper.getCanvasData()
     const cropboxData = this.cropper.getCropBoxData()
     const { prevCropboxData } = this.state
 
     // 1. When we grab the cropbox in the middle and move it
     if (event.detail.action === 'all') {
-      const newCropboxData = limitCropboxMovementOnMove(canvasData, cropboxData, prevCropboxData)
+      const newCropboxData = limitCropboxMovementOnMove(
+        canvasData,
+        cropboxData,
+        prevCropboxData,
+      )
       if (newCropboxData) this.cropper.setCropBoxData(newCropboxData)
-    // When we stretch the cropbox by one of its sides
+      // When we stretch the cropbox by one of its sides
     } else {
-      const newCropboxData = limitCropboxMovementOnResize(canvasData, cropboxData, prevCropboxData)
+      const newCropboxData = limitCropboxMovementOnResize(
+        canvasData,
+        cropboxData,
+        prevCropboxData,
+      )
       if (newCropboxData) this.cropper.setCropBoxData(newCropboxData)
     }
   }
 
-  onRotate90Deg = () => {
+  onRotate90Deg = (): void => {
     // 1. Set state
     const { angle90Deg } = this.state
     const newAngle = angle90Deg - 90
@@ -76,14 +109,17 @@ export default class Editor extends Component {
     // 3. Fit the rotated image into the view
     const canvasData = this.cropper.getCanvasData()
     const containerData = this.cropper.getContainerData()
-    const newCanvasData = getCanvasDataThatFitsPerfectlyIntoContainer(containerData, canvasData)
+    const newCanvasData = getCanvasDataThatFitsPerfectlyIntoContainer(
+      containerData,
+      canvasData,
+    )
     this.cropper.setCanvasData(newCanvasData)
 
     // 4. Make cropbox fully wrap the image
     this.cropper.setCropBoxData(newCanvasData)
   }
 
-  onRotateGranular = (ev) => {
+  onRotateGranular = (ev: ChangeEvent<HTMLInputElement>): void => {
     //  1. Set state
     const newGranularAngle = Number(ev.target.value)
     this.setState({ angleGranular: newGranularAngle })
@@ -95,13 +131,18 @@ export default class Editor extends Component {
 
     // 3. Scale the image so that it fits into the cropbox
     const image = this.cropper.getImageData()
-    const scaleFactor = getScaleFactorThatRemovesDarkCorners(image.naturalWidth, image.naturalHeight, newGranularAngle)
+    const scaleFactor = getScaleFactorThatRemovesDarkCorners(
+      image.naturalWidth,
+      image.naturalHeight,
+      newGranularAngle,
+    )
     // Preserve flip
-    const scaleFactorX = this.cropper.getImageData().scaleX < 0 ? -scaleFactor : scaleFactor
+    const scaleFactorX =
+      this.cropper.getImageData().scaleX < 0 ? -scaleFactor : scaleFactor
     this.cropper.scale(scaleFactorX, scaleFactor)
   }
 
-  renderGranularRotate () {
+  renderGranularRotate(): JSX.Element {
     const { i18n } = this.props
     const { angleGranular } = this.state
 
@@ -126,7 +167,7 @@ export default class Editor extends Component {
     )
   }
 
-  renderRevert () {
+  renderRevert(): JSX.Element {
     const { i18n, opts } = this.props
 
     return (
@@ -140,11 +181,19 @@ export default class Editor extends Component {
           className="uppy-u-reset uppy-c-btn"
           onClick={() => {
             this.cropper.reset()
-            this.cropper.setAspectRatio(opts.cropperOptions.initialAspectRatio)
+            this.cropper.setAspectRatio(
+              opts.cropperOptions.initialAspectRatio as number,
+            )
             this.setState({ angle90Deg: 0, angleGranular: 0 })
           }}
         >
-          <svg aria-hidden="true" className="uppy-c-icon" width="24" height="24" viewBox="0 0 24 24">
+          <svg
+            aria-hidden="true"
+            className="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>
@@ -153,7 +202,7 @@ export default class Editor extends Component {
     )
   }
 
-  renderRotate () {
+  renderRotate(): JSX.Element {
     const { i18n } = this.props
 
     return (
@@ -167,7 +216,13 @@ export default class Editor extends Component {
           className="uppy-u-reset uppy-c-btn"
           onClick={this.onRotate90Deg}
         >
-          <svg aria-hidden="true" className="uppy-c-icon" width="24" height="24" viewBox="0 0 24 24">
+          <svg
+            aria-hidden="true"
+            className="uppy-c-icon"
+            width="24"
+            height="24"
+            viewBox="0 0 24 24"
+          >
             <path d="M0 0h24v24H0V0zm0 0h24v24H0V0z" fill="none" />
             <path d="M14 10a2 2 0 012 2v7a2 2 0 01-2 2H6a2 2 0 01-2-2v-7a2 2 0 012-2h8zm0 1.75H6a.25.25 0 00-.243.193L5.75 12v7a.25.25 0 00.193.243L6 19.25h8a.25.25 0 00.243-.193L14.25 19v-7a.25.25 0 00-.193-.243L14 11.75zM12 .76V4c2.3 0 4.61.88 6.36 2.64a8.95 8.95 0 012.634 6.025L21 13a1 1 0 01-1.993.117L19 13h-.003a6.979 6.979 0 00-2.047-4.95 6.97 6.97 0 00-4.652-2.044L12 6v3.24L7.76 5 12 .76z" />
           </svg>
@@ -176,7 +231,7 @@ export default class Editor extends Component {
     )
   }
 
-  renderFlip () {
+  renderFlip(): JSX.Element {
     const { i18n } = this.props
 
     return (
@@ -188,9 +243,17 @@ export default class Editor extends Component {
         <button
           type="button"
           className="uppy-u-reset uppy-c-btn"
-          onClick={() => this.cropper.scaleX(-this.cropper.getData().scaleX || -1)}
+          onClick={() =>
+            this.cropper.scaleX(-this.cropper.getData().scaleX || -1)
+          }
         >
-          <svg aria-hidden="true" className="uppy-c-icon" width="24" height="24" viewBox="0 0 24 24">
+          <svg
+            aria-hidden="true"
+            className="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>
@@ -199,7 +262,7 @@ export default class Editor extends Component {
     )
   }
 
-  renderZoomIn () {
+  renderZoomIn(): JSX.Element {
     const { i18n } = this.props
 
     return (
@@ -213,7 +276,13 @@ export default class Editor extends Component {
           className="uppy-u-reset uppy-c-btn"
           onClick={() => this.cropper.zoom(0.1)}
         >
-          <svg aria-hidden="true" className="uppy-c-icon" height="24" viewBox="0 0 24 24" width="24">
+          <svg
+            aria-hidden="true"
+            className="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" />
@@ -223,7 +292,7 @@ export default class Editor extends Component {
     )
   }
 
-  renderZoomOut () {
+  renderZoomOut(): JSX.Element {
     const { i18n } = this.props
 
     return (
@@ -237,7 +306,13 @@ export default class Editor extends Component {
           className="uppy-u-reset uppy-c-btn"
           onClick={() => this.cropper.zoom(-0.1)}
         >
-          <svg aria-hidden="true" className="uppy-c-icon" width="24" height="24" viewBox="0 0 24 24">
+          <svg
+            aria-hidden="true"
+            className="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>
@@ -246,7 +321,7 @@ export default class Editor extends Component {
     )
   }
 
-  renderCropSquare () {
+  renderCropSquare(): JSX.Element {
     const { i18n } = this.props
 
     return (
@@ -260,7 +335,13 @@ export default class Editor extends Component {
           className="uppy-u-reset uppy-c-btn"
           onClick={() => this.cropper.setAspectRatio(1)}
         >
-          <svg aria-hidden="true" className="uppy-c-icon" width="24" height="24" viewBox="0 0 24 24">
+          <svg
+            aria-hidden="true"
+            className="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>
@@ -269,7 +350,7 @@ export default class Editor extends Component {
     )
   }
 
-  renderCropWidescreen () {
+  renderCropWidescreen(): JSX.Element {
     const { i18n } = this.props
 
     return (
@@ -283,7 +364,13 @@ export default class Editor extends Component {
           className="uppy-u-reset uppy-c-btn"
           onClick={() => this.cropper.setAspectRatio(16 / 9)}
         >
-          <svg aria-hidden="true" className="uppy-c-icon" width="24" height="24" viewBox="0 0 24 24">
+          <svg
+            aria-hidden="true"
+            className="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>
@@ -292,7 +379,7 @@ export default class Editor extends Component {
     )
   }
 
-  renderCropWidescreenVertical () {
+  renderCropWidescreenVertical(): JSX.Element {
     const { i18n } = this.props
 
     return (
@@ -306,7 +393,13 @@ export default class Editor extends Component {
           className="uppy-u-reset uppy-c-btn"
           onClick={() => this.cropper.setAspectRatio(9 / 16)}
         >
-          <svg aria-hidden="true" className="uppy-c-icon" width="24" height="24" viewBox="0 0 24 24">
+          <svg
+            aria-hidden="true"
+            className="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>
@@ -315,7 +408,7 @@ export default class Editor extends Component {
     )
   }
 
-  render () {
+  render(): JSX.Element {
     const { currentImage, opts } = this.props
     const { actions } = opts
     const imageURL = URL.createObjectURL(currentImage.data)
@@ -327,7 +420,9 @@ export default class Editor extends Component {
             className="uppy-ImageCropper-image"
             alt={currentImage.name}
             src={imageURL}
-            ref={ref => { this.imgElement = ref }}
+            ref={(ref) => {
+              this.imgElement = ref as HTMLImageElement
+            }}
           />
         </div>
 
@@ -340,7 +435,8 @@ export default class Editor extends Component {
           {actions.zoomOut && this.renderZoomOut()}
           {actions.cropSquare && this.renderCropSquare()}
           {actions.cropWidescreen && this.renderCropWidescreen()}
-          {actions.cropWidescreenVertical && this.renderCropWidescreenVertical()}
+          {actions.cropWidescreenVertical &&
+            this.renderCropWidescreenVertical()}
         </div>
       </div>
     )

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

@@ -1,167 +0,0 @@
-import { UIPlugin } from '@uppy/core'
-import { h } from 'preact'
-
-import Editor from './Editor.jsx'
-import packageJson from '../package.json'
-import locale from './locale.js'
-
-export default class ImageEditor extends UIPlugin {
-  static VERSION = packageJson.version
-
-  constructor (uppy, opts) {
-    super(uppy, opts)
-    this.id = this.opts.id || 'ImageEditor'
-    this.title = 'Image Editor'
-    this.type = 'editor'
-
-    this.defaultLocale = locale
-
-    const defaultCropperOptions = {
-      viewMode: 0,
-      background: false,
-      autoCropArea: 1,
-      responsive: true,
-      minCropBoxWidth: 70,
-      minCropBoxHeight: 70,
-      croppedCanvasOptions: {},
-      initialAspectRatio: 0,
-    }
-
-    const defaultActions = {
-      revert: true,
-      rotate: true,
-      granularRotate: true,
-      flip: true,
-      zoomIn: true,
-      zoomOut: true,
-      cropSquare: true,
-      cropWidescreen: true,
-      cropWidescreenVertical: true,
-    }
-
-    // Why is the default quality smaller than 1?
-    // Because `quality: 1` increases the image size by orders of magnitude - 0.8 seems to be the sweet spot.
-    // (see https://github.com/fengyuanchen/cropperjs/issues/538#issuecomment-1776279427)
-    const defaultOptions = {
-      quality: 0.8,
-    }
-
-    this.opts = {
-      ...defaultOptions,
-      ...opts,
-      actions: {
-        ...defaultActions,
-        ...opts?.actions,
-      },
-      cropperOptions: {
-        ...defaultCropperOptions,
-        ...opts?.cropperOptions,
-      },
-    }
-
-    this.i18nInit()
-  }
-
-  // eslint-disable-next-line class-methods-use-this
-  canEditFile (file) {
-    if (!file.type || file.isRemote) {
-      return false
-    }
-
-    const fileTypeSpecific = file.type.split('/')[1]
-
-    if (/^(jpe?g|gif|png|bmp|webp)$/.test(fileTypeSpecific)) {
-      return true
-    }
-
-    return false
-  }
-
-  save = () => {
-    const saveBlobCallback = (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)
-    }
-
-    const { currentImage } = this.getPluginState()
-
-    // Fixes black 1px lines on odd-width images.
-    // This should be removed when cropperjs fixes this issue.
-    // (See https://github.com/transloadit/uppy/issues/4305 and https://github.com/fengyuanchen/cropperjs/issues/551).
-    const croppedCanvas = this.cropper.getCroppedCanvas({})
-    if (croppedCanvas.width % 2 !== 0) {
-      this.cropper.setData({ width: croppedCanvas.width - 1 })
-    }
-    if (croppedCanvas.height % 2 !== 0) {
-      this.cropper.setData({ height: croppedCanvas.height - 1 })
-    }
-
-    this.cropper.getCroppedCanvas(this.opts.cropperOptions.croppedCanvasOptions).toBlob(
-      saveBlobCallback,
-      currentImage.type,
-      this.opts.quality,
-    )
-  }
-
-  storeCropperInstance = (cropper) => {
-    this.cropper = cropper
-  }
-
-  selectFile = (file) => {
-    this.uppy.emit('file-editor:start', file)
-    this.setPluginState({
-      currentImage: file,
-    })
-  }
-
-  install () {
-    this.setPluginState({
-      currentImage: null,
-    })
-
-    const { target } = this.opts
-    if (target) {
-      this.mount(target, this)
-    }
-  }
-
-  uninstall () {
-    const { currentImage } = this.getPluginState()
-
-    if (currentImage) {
-      const file = this.uppy.getFile(currentImage.id)
-      this.uppy.emit('file-editor:cancel', file)
-    }
-    this.unmount()
-  }
-
-  render () {
-    const { currentImage } = this.getPluginState()
-
-    if (currentImage === null || currentImage.isRemote) {
-      return null
-    }
-
-    return (
-      <Editor
-        currentImage={currentImage}
-        storeCropperInstance={this.storeCropperInstance}
-        save={this.save}
-        opts={this.opts}
-        i18n={this.i18n}
-      />
-    )
-  }
-}

+ 230 - 0
packages/@uppy/image-editor/src/ImageEditor.tsx

@@ -0,0 +1,230 @@
+import { UIPlugin, type UIPluginOptions, type Uppy } from '@uppy/core'
+import type { DefinePluginOpts } from '@uppy/core/lib/BasePlugin.js'
+import type Cropper from 'cropperjs'
+import { h } from 'preact'
+
+import type { Meta, Body, UppyFile } from '@uppy/utils/lib/UppyFile'
+import Editor from './Editor.tsx'
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore We don't want TS to generate types for the package.json
+import packageJson from '../package.json'
+import locale from './locale.ts'
+
+declare global {
+  namespace preact {
+    interface Component {
+      // This is a workaround for https://github.com/preactjs/preact/issues/1206
+      refs: Record<string, any>
+    }
+  }
+}
+
+type ThumbnailGeneratedCallback<M extends Meta, B extends Body> = (
+  file: UppyFile<M, B>,
+  preview: string,
+) => void
+type GenericCallback<M extends Meta, B extends Body> = (
+  file: UppyFile<M, B>,
+) => void
+declare module '@uppy/core' {
+  export interface UppyEventMap<M extends Meta, B extends Body> {
+    'thumbnail:request': GenericCallback<M, B>
+    'thumbnail:generated': ThumbnailGeneratedCallback<M, B>
+    'file-editor:complete': GenericCallback<M, B>
+    'file-editor:start': GenericCallback<M, B>
+    'file-editor:cancel': GenericCallback<M, B>
+  }
+}
+
+export interface Opts extends UIPluginOptions {
+  target: string | HTMLElement
+  quality?: number
+  cropperOptions?: Cropper.Options & {
+    croppedCanvasOptions?: Cropper.GetCroppedCanvasOptions
+  }
+  actions?: {
+    revert?: boolean
+    rotate?: boolean
+    granularRotate?: boolean
+    flip?: boolean
+    zoomIn?: boolean
+    zoomOut?: boolean
+    cropSquare?: boolean
+    cropWidescreen?: boolean
+    cropWidescreenVertical?: boolean
+  }
+}
+
+type PluginState<M extends Meta, B extends Body> = {
+  currentImage: UppyFile<M, B> | null
+}
+
+const defaultCropperOptions = {
+  viewMode: 0,
+  background: false,
+  autoCropArea: 1,
+  responsive: true,
+  minCropBoxWidth: 70,
+  minCropBoxHeight: 70,
+  croppedCanvasOptions: {},
+  initialAspectRatio: 0,
+} satisfies Opts['cropperOptions']
+
+const defaultActions = {
+  revert: true,
+  rotate: true,
+  granularRotate: true,
+  flip: true,
+  zoomIn: true,
+  zoomOut: true,
+  cropSquare: true,
+  cropWidescreen: true,
+  cropWidescreenVertical: true,
+} satisfies Opts['actions']
+
+const defaultOptions = {
+  target: 'body',
+  // `quality: 1` increases the image size by orders of magnitude - 0.8 seems to be the sweet spot.
+  // see https://github.com/fengyuanchen/cropperjs/issues/538#issuecomment-1776279427
+  quality: 0.8,
+  actions: defaultActions,
+  cropperOptions: defaultCropperOptions,
+} satisfies Opts
+
+export type ImageEditorOpts = DefinePluginOpts<
+  Opts,
+  keyof typeof defaultOptions
+>
+
+export default class ImageEditor<
+  M extends Meta,
+  B extends Body,
+> extends UIPlugin<ImageEditorOpts, M, B, PluginState<M, B>> {
+  static VERSION = packageJson.version
+
+  cropper: Cropper
+
+  constructor(uppy: Uppy<M, B>, opts?: Opts) {
+    super(uppy, {
+      ...defaultOptions,
+      ...opts,
+      actions: {
+        ...defaultActions,
+        ...opts?.actions,
+      },
+      cropperOptions: {
+        ...defaultCropperOptions,
+        ...opts?.cropperOptions,
+      },
+    })
+    this.id = this.opts.id || 'ImageEditor'
+    this.title = 'Image Editor'
+    this.type = 'editor'
+
+    this.defaultLocale = locale
+
+    this.i18nInit()
+  }
+
+  // eslint-disable-next-line class-methods-use-this
+  canEditFile(file: UppyFile<M, B>): boolean {
+    if (!file.type || file.isRemote) {
+      return false
+    }
+
+    const fileTypeSpecific = file.type.split('/')[1]
+
+    if (/^(jpe?g|gif|png|bmp|webp)$/.test(fileTypeSpecific)) {
+      return true
+    }
+
+    return false
+  }
+
+  save = (): void => {
+    const saveBlobCallback: BlobCallback = (blob) => {
+      const { currentImage } = this.getPluginState()
+
+      this.uppy.setFileState(currentImage!.id, {
+        data: blob!,
+        size: blob!.size,
+        preview: undefined,
+      })
+
+      const updatedFile = this.uppy.getFile(currentImage!.id)
+      this.uppy.emit('thumbnail:request', updatedFile)
+      this.setPluginState({
+        currentImage: updatedFile,
+      })
+      this.uppy.emit('file-editor:complete', updatedFile)
+    }
+
+    const { currentImage } = this.getPluginState()
+
+    // Fixes black 1px lines on odd-width images.
+    // This should be removed when cropperjs fixes this issue.
+    // (See https://github.com/transloadit/uppy/issues/4305 and https://github.com/fengyuanchen/cropperjs/issues/551).
+    const croppedCanvas = this.cropper.getCroppedCanvas({})
+    if (croppedCanvas.width % 2 !== 0) {
+      this.cropper.setData({ width: croppedCanvas.width - 1 })
+    }
+    if (croppedCanvas.height % 2 !== 0) {
+      this.cropper.setData({ height: croppedCanvas.height - 1 })
+    }
+
+    this.cropper
+      .getCroppedCanvas(this.opts.cropperOptions.croppedCanvasOptions)
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+      .toBlob(saveBlobCallback, currentImage!.type, this.opts.quality)
+  }
+
+  storeCropperInstance = (cropper: Cropper): void => {
+    this.cropper = cropper
+  }
+
+  selectFile = (file: UppyFile<M, B>): void => {
+    this.uppy.emit('file-editor:start', file)
+    this.setPluginState({
+      currentImage: file,
+    })
+  }
+
+  install(): void {
+    this.setPluginState({
+      currentImage: null,
+    })
+
+    const { target } = this.opts
+    if (target) {
+      this.mount(target, this)
+    }
+  }
+
+  uninstall(): void {
+    const { currentImage } = this.getPluginState()
+
+    if (currentImage) {
+      const file = this.uppy.getFile(currentImage.id)
+      this.uppy.emit('file-editor:cancel', file)
+    }
+    this.unmount()
+  }
+
+  render(): JSX.Element | null {
+    const { currentImage } = this.getPluginState()
+
+    if (currentImage === null || currentImage.isRemote) {
+      return null
+    }
+
+    return (
+      <Editor<M, B>
+        currentImage={currentImage}
+        storeCropperInstance={this.storeCropperInstance}
+        save={this.save}
+        opts={this.opts}
+        i18n={this.i18n}
+      />
+    )
+  }
+}

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

@@ -1 +0,0 @@
-export { default } from './ImageEditor.jsx'

+ 1 - 0
packages/@uppy/image-editor/src/index.ts

@@ -0,0 +1 @@
+export { default } from './ImageEditor.tsx'

+ 0 - 0
packages/@uppy/image-editor/src/locale.js → packages/@uppy/image-editor/src/locale.ts


+ 6 - 1
packages/@uppy/image-editor/src/utils/getCanvasDataThatFitsPerfectlyIntoContainer.js → packages/@uppy/image-editor/src/utils/getCanvasDataThatFitsPerfectlyIntoContainer.ts

@@ -1,6 +1,11 @@
+import type Cropper from 'cropperjs'
 // 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) {
+//
+function getCanvasDataThatFitsPerfectlyIntoContainer(
+  containerData: Cropper.ContainerData,
+  canvasData: Cropper.CanvasData,
+): { width: number; height: number; left: number; top: number } {
   // 1. Scale our canvas as much as possible
   const widthRatio = containerData.width / canvasData.width
   const heightRatio = containerData.height / canvasData.height

+ 6 - 2
packages/@uppy/image-editor/src/utils/getScaleFactorThatRemovesDarkCorners.js → packages/@uppy/image-editor/src/utils/getScaleFactorThatRemovesDarkCorners.ts

@@ -1,8 +1,12 @@
-function toRadians (angle) {
+function toRadians(angle: number) {
   return angle * (Math.PI / 180)
 }
 
-function getScaleFactorThatRemovesDarkCorners (w, h, granularAngle) {
+function getScaleFactorThatRemovesDarkCorners(
+  w: number,
+  h: number,
+  granularAngle: number,
+): number {
   const α = Math.abs(toRadians(granularAngle))
 
   const scaleFactor = Math.max(

+ 0 - 37
packages/@uppy/image-editor/src/utils/limitCropboxMovementOnMove.js

@@ -1,37 +0,0 @@
-function limitCropboxMovementOnMove (canvas, cropbox, prevCropbox) {
-  // For the left boundary
-  if (cropbox.left < canvas.left) {
-    return ({
-      left: canvas.left,
-      width: prevCropbox.width,
-    })
-  }
-
-  // For the top boundary
-  if (cropbox.top < canvas.top) {
-    return ({
-      top: canvas.top,
-      height: prevCropbox.height,
-    })
-  }
-
-  // For the right boundary
-  if (cropbox.left + cropbox.width > canvas.left + canvas.width) {
-    return ({
-      left: (canvas.left + canvas.width) - prevCropbox.width,
-      width: prevCropbox.width,
-    })
-  }
-
-  // For the bottom boundary
-  if (cropbox.top + cropbox.height > canvas.top + canvas.height) {
-    return ({
-      top: (canvas.top + canvas.height) - prevCropbox.height,
-      height: prevCropbox.height,
-    })
-  }
-
-  return null
-}
-
-export default limitCropboxMovementOnMove

+ 43 - 0
packages/@uppy/image-editor/src/utils/limitCropboxMovementOnMove.ts

@@ -0,0 +1,43 @@
+import type Cropper from 'cropperjs'
+
+function limitCropboxMovementOnMove(
+  canvas: Cropper.CanvasData,
+  cropbox: Cropper.CropBoxData,
+  prevCropbox: Cropper.CropBoxData | null,
+): { left?: number; top?: number; width?: number; height?: number } | null {
+  // For the left boundary
+  if (cropbox.left < canvas.left) {
+    return {
+      left: canvas.left,
+      width: prevCropbox!.width,
+    }
+  }
+
+  // For the top boundary
+  if (cropbox.top < canvas.top) {
+    return {
+      top: canvas.top,
+      height: prevCropbox!.height,
+    }
+  }
+
+  // For the right boundary
+  if (cropbox.left + cropbox.width > canvas.left + canvas.width) {
+    return {
+      left: canvas.left + canvas.width - prevCropbox!.width,
+      width: prevCropbox!.width,
+    }
+  }
+
+  // For the bottom boundary
+  if (cropbox.top + cropbox.height > canvas.top + canvas.height) {
+    return {
+      top: canvas.top + canvas.height - prevCropbox!.height,
+      height: prevCropbox!.height,
+    }
+  }
+
+  return null
+}
+
+export default limitCropboxMovementOnMove

+ 0 - 37
packages/@uppy/image-editor/src/utils/limitCropboxMovementOnResize.js

@@ -1,37 +0,0 @@
-function limitCropboxMovementOnResize (canvas, cropboxData, prevCropbox) {
-  // For the left boundary
-  if (cropboxData.left < canvas.left) {
-    return ({
-      left: canvas.left,
-      width: (prevCropbox.left + prevCropbox.width) - canvas.left,
-    })
-  }
-
-  // For the top boundary
-  if (cropboxData.top < canvas.top) {
-    return ({
-      top: canvas.top,
-      height: (prevCropbox.top + prevCropbox.height) - canvas.top,
-    })
-  }
-
-  // For the right boundary
-  if (cropboxData.left + cropboxData.width > canvas.left + canvas.width) {
-    return ({
-      left: prevCropbox.left,
-      width: (canvas.left + canvas.width) - prevCropbox.left,
-    })
-  }
-
-  // For the bottom boundary
-  if (cropboxData.top + cropboxData.height > canvas.top + canvas.height) {
-    return ({
-      top: prevCropbox.top,
-      height: (canvas.top + canvas.height) - prevCropbox.top,
-    })
-  }
-
-  return null
-}
-
-export default limitCropboxMovementOnResize

+ 43 - 0
packages/@uppy/image-editor/src/utils/limitCropboxMovementOnResize.ts

@@ -0,0 +1,43 @@
+import type Cropper from 'cropperjs'
+
+function limitCropboxMovementOnResize(
+  canvas: Cropper.CanvasData,
+  cropboxData: Cropper.CropBoxData,
+  prevCropbox: Cropper.CropBoxData | null,
+): { left?: number; top?: number; width?: number; height?: number } | null {
+  // For the left boundary
+  if (cropboxData.left < canvas.left) {
+    return {
+      left: canvas.left,
+      width: prevCropbox!.left + prevCropbox!.width - canvas.left,
+    }
+  }
+
+  // For the top boundary
+  if (cropboxData.top < canvas.top) {
+    return {
+      top: canvas.top,
+      height: prevCropbox!.top + prevCropbox!.height - canvas.top,
+    }
+  }
+
+  // For the right boundary
+  if (cropboxData.left + cropboxData.width > canvas.left + canvas.width) {
+    return {
+      left: prevCropbox!.left,
+      width: canvas.left + canvas.width - prevCropbox!.left,
+    }
+  }
+
+  // For the bottom boundary
+  if (cropboxData.top + cropboxData.height > canvas.top + canvas.height) {
+    return {
+      top: prevCropbox!.top,
+      height: canvas.top + canvas.height - prevCropbox!.top,
+    }
+  }
+
+  return null
+}
+
+export default limitCropboxMovementOnResize

+ 25 - 0
packages/@uppy/image-editor/tsconfig.build.json

@@ -0,0 +1,25 @@
+{
+  "extends": "../../../tsconfig.shared",
+  "compilerOptions": {
+    "noImplicitAny": false,
+    "outDir": "./lib",
+    "paths": {
+      "@uppy/utils/lib/*": ["../utils/src/*"],
+      "@uppy/core": ["../core/src/index.js"],
+      "@uppy/core/lib/*": ["../core/src/*"]
+    },
+    "resolveJsonModule": false,
+    "rootDir": "./src",
+    "skipLibCheck": true
+  },
+  "include": ["./src/**/*.*"],
+  "exclude": ["./src/**/*.test.ts"],
+  "references": [
+    {
+      "path": "../utils/tsconfig.build.json"
+    },
+    {
+      "path": "../core/tsconfig.build.json"
+    }
+  ]
+}

+ 21 - 0
packages/@uppy/image-editor/tsconfig.json

@@ -0,0 +1,21 @@
+{
+  "extends": "../../../tsconfig.shared",
+  "compilerOptions": {
+    "emitDeclarationOnly": false,
+    "noEmit": true,
+    "paths": {
+      "@uppy/utils/lib/*": ["../utils/src/*"],
+      "@uppy/core": ["../core/src/"],
+      "@uppy/core/lib/*": ["../core/src/*"]
+    }
+  },
+  "include": ["./package.json", "./src/**/*.*"],
+  "references": [
+    {
+      "path": "../utils/tsconfig.build.json"
+    },
+    {
+      "path": "../core/tsconfig.build.json"
+    }
+  ]
+}