Add useImageEditor (#6122)

- Create image editor abstraction in `@uppy/components`
- Refactor `Editor` (Preact) and `ImageEditor` (Uppy plugin) to put all
methods in the Uppy plugin class so they can be reused by the headless
abstraction
- Create framework hooks for React, Vue, Svelte
- Update all examples to showcase the new hook
- Symlink the `cropper.scss` (just regular CSS) from
`@uppy/image-editor` into `@uppy/components`, update `build:css` in all
framework packages to copy that file too into their own `dist/` output.
- cropper-js needs basic CSS to do the rotations and stuff which would
be very tedious to write yourself so we ship that CSS file to consumers
too so they can just import it.
- Fix memory leak where we created the image on each render inside the
original Preact plugin UI

```ts
import type { UppyFile } from '@uppy/core'
import { useImageEditor } from '@uppy/react'

interface ImageEditorProps {
  file: UppyFile<any, any>
  close: () => void
}

export function ImageEditor({ file, close }: ImageEditorProps) {
  const {
    state,
    getImageProps,
    getSaveButtonProps,
    getCancelButtonProps,
    getRotateButtonProps,
    getFlipHorizontalButtonProps,
    getZoomButtonProps,
    getCropSquareButtonProps,
    getCropLandscapeButtonProps,
    getCropPortraitButtonProps,
    getResetButtonProps,
    getRotationSliderProps,
  } = useImageEditor({ file })

  return (
    <div className="p-4 max-w-2xl w-full">
      <div className="flex justify-between items-center mb-4">
        <h2 className="text-xl font-bold">Edit Image</h2>
        <button
          type="button"
          onClick={close}
          className="text-gray-500 hover:text-gray-700"
        >
          ✕
        </button>
      </div>

      <div className="mb-4">
        {/* biome-ignore lint/a11y/useAltText: alt is provided by getImageProps() */}
        <img
          className="w-full max-h-[400px] rounded-lg border-2"
          {...getImageProps()}
        />
      </div>

      <div className="mb-4">
        <label className="block text-sm font-medium mb-2">
          Fine Rotation: {state.angle}°
          <input className="w-full mt-1" {...getRotationSliderProps()} />
        </label>
      </div>

      <div className="flex gap-2 flex-wrap mb-4">
        <button
          className="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
          {...getRotateButtonProps(-90)}
        >
          ↶ -90°
        </button>
        <button
          className="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
          {...getRotateButtonProps(90)}
        >
          ↷ +90°
        </button>
        <button
          className="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
          {...getFlipHorizontalButtonProps()}
        >
          ⇆ Flip
        </button>
        <button
          className="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
          {...getZoomButtonProps(0.1)}
        >
          + Zoom
        </button>
        <button
          className="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
          {...getZoomButtonProps(-0.1)}
        >
          - Zoom
        </button>
      </div>

      <div className="flex gap-2 flex-wrap mb-4">
        <button
          className="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
          {...getCropSquareButtonProps()}
        >
          1:1
        </button>
        <button
          className="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
          {...getCropLandscapeButtonProps()}
        >
          16:9
        </button>
        <button
          className="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
          {...getCropPortraitButtonProps()}
        >
          9:16
        </button>
        <button
          className="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
          {...getResetButtonProps()}
        >
          Reset
        </button>
      </div>

      <div className="flex gap-4 justify-end">
        <button
          className="bg-gray-500 text-white px-4 py-2 rounded-md"
          {...getCancelButtonProps({ onClick: close })}
        >
          Cancel
        </button>
        <button
          className="bg-green-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-green-300"
          {...getSaveButtonProps({ onClick: close })}
        >
          Save
        </button>
      </div>
    </div>
  )
}

export default ImageEditor

```


https://github.com/user-attachments/assets/4b0a99cc-8489-41a2-aa3b-ce88110025bf

---------

Co-authored-by: Prakash <qxprakash@gmail.com>
This commit is contained in:
Merlijn Vos 2026-01-29 11:13:12 +01:00 committed by GitHub
parent 09bf6857a1
commit 850c1cb1c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1285 additions and 170 deletions

View file

@ -0,0 +1,9 @@
---
"@uppy/image-editor": minor
"@uppy/components": minor
"@uppy/svelte": minor
"@uppy/react": minor
"@uppy/vue": minor
---
Introduce `useImageEditor` to build your own UI for using our image editor in React, Vue, and Svelte.

View file

@ -1,5 +1,6 @@
/** biome-ignore-all lint/nursery/useUniqueElementIds: it's fine */
import Uppy from '@uppy/core'
import Uppy, { type UppyFile } from '@uppy/core'
import UppyImageEditor from '@uppy/image-editor'
import {
Dropzone,
FilesGrid,
@ -13,12 +14,14 @@ import Tus from '@uppy/tus'
import UppyWebcam from '@uppy/webcam'
import { useRef, useState } from 'react'
import CustomDropzone from './CustomDropzone'
import ImageEditor from './ImageEditor'
import { RemoteSource } from './RemoteSource'
import ScreenCapture from './ScreenCapture'
import Webcam from './Webcam'
import './app.css'
import '@uppy/react/css/style.css'
import '@uppy/react/css/image-editor.css'
function App() {
const [uppy] = useState(() =>
@ -28,21 +31,34 @@ function App() {
})
.use(UppyWebcam)
.use(UppyScreenCapture)
.use(UppyImageEditor)
.use(UppyRemoteSources, { companionUrl: 'http://localhost:3020' }),
)
const dialogRef = useRef<HTMLDialogElement>(null)
const [modalPlugin, setModalPlugin] = useState<
'webcam' | 'dropbox' | 'screen-capture' | null
'webcam' | 'dropbox' | 'screen-capture' | 'image-editor' | null
>(null)
const [selectedFile, setSelectedFile] = useState<UppyFile<any, any> | null>(
null,
)
function openModal(plugin: 'webcam' | 'dropbox' | 'screen-capture') {
setModalPlugin(plugin)
dialogRef.current?.showModal()
}
function openImageEditorModal(file: UppyFile<any, any>) {
// https://github.com/transloadit/uppy/issues/6148
if (!file.type.startsWith('image/')) return
setSelectedFile(file)
setModalPlugin('image-editor')
dialogRef.current?.showModal()
}
function closeModal() {
setModalPlugin(null)
setSelectedFile(null)
dialogRef.current?.close()
}
@ -65,6 +81,10 @@ function App() {
return <RemoteSource id="Dropbox" close={() => closeModal()} />
case 'screen-capture':
return <ScreenCapture close={() => closeModal()} />
case 'image-editor':
return selectedFile ? (
<ImageEditor file={selectedFile} close={() => closeModal()} />
) : null
default:
return null
}
@ -74,13 +94,13 @@ function App() {
<article>
<h2 className="text-2xl my-4">With list</h2>
<Dropzone />
<FilesList />
<FilesList editFile={openImageEditorModal} />
</article>
<article>
<h2 className="text-2xl my-4">With grid</h2>
<Dropzone />
<FilesGrid columns={2} />
<FilesGrid columns={2} editFile={openImageEditorModal} />
</article>
<article>

View file

@ -0,0 +1,131 @@
import type { UppyFile } from '@uppy/core'
import { useImageEditor } from '@uppy/react'
interface ImageEditorProps {
file: UppyFile<any, any>
close: () => void
}
export function ImageEditor({ file, close }: ImageEditorProps) {
const {
state,
getImageProps,
getSaveButtonProps,
getCancelButtonProps,
getRotateButtonProps,
getFlipHorizontalButtonProps,
getZoomButtonProps,
getCropSquareButtonProps,
getCropLandscapeButtonProps,
getCropPortraitButtonProps,
getResetButtonProps,
getRotationSliderProps,
} = useImageEditor({ file })
return (
<div className="p-4 max-w-2xl w-full">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">Edit Image</h2>
<button
type="button"
onClick={close}
className="text-gray-500 hover:text-gray-700"
>
</button>
</div>
<div className="mb-4">
{/* biome-ignore lint/a11y/useAltText: alt is provided by getImageProps() */}
<img
className="w-full max-h-[400px] rounded-lg border-2"
{...getImageProps()}
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium mb-2">
Fine Rotation: {state.angle}°
<input className="w-full mt-1" {...getRotationSliderProps()} />
</label>
</div>
<div className="flex gap-2 flex-wrap mb-4">
<button
className="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
{...getRotateButtonProps(-90)}
>
-90°
</button>
<button
className="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
{...getRotateButtonProps(90)}
>
+90°
</button>
<button
className="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
{...getFlipHorizontalButtonProps()}
>
Flip
</button>
<button
className="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
{...getZoomButtonProps(0.1)}
>
+ Zoom
</button>
<button
className="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
{...getZoomButtonProps(-0.1)}
>
- Zoom
</button>
</div>
<div className="flex gap-2 flex-wrap mb-4">
<button
className="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
{...getCropSquareButtonProps()}
>
1:1
</button>
<button
className="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
{...getCropLandscapeButtonProps()}
>
16:9
</button>
<button
className="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
{...getCropPortraitButtonProps()}
>
9:16
</button>
<button
className="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
{...getResetButtonProps()}
>
Reset
</button>
</div>
<div className="flex gap-4 justify-end">
<button
className="bg-gray-500 text-white px-4 py-2 rounded-md"
{...getCancelButtonProps({ onClick: close })}
>
Cancel
</button>
<button
className="bg-green-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-green-300"
{...getSaveButtonProps({ onClick: close })}
>
Save
</button>
</div>
</div>
)
}
export default ImageEditor

View file

@ -0,0 +1,117 @@
<script lang="ts">
import type { UppyFile } from '@uppy/core'
import { useImageEditor } from '@uppy/svelte'
interface Props {
file: UppyFile<any, any>
close: () => void
}
const { file, close }: Props = $props()
const editor = useImageEditor({ file })
</script>
<div class="p-4 max-w-2xl w-full">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold">Edit Image</h2>
<button
type="button"
onclick={close}
class="text-gray-500 hover:text-gray-700"
>
</button>
</div>
<div class="mb-4">
<img
class="w-full max-h-[400px] rounded-lg border-2"
{...editor.getImageProps()}
/>
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-2">
Fine Rotation: {editor.state.angle}°
</label>
<input
class="w-full"
{...editor.getRotationSliderProps()}
/>
</div>
<div class="flex gap-2 flex-wrap mb-4">
<button
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
{...editor.getRotateButtonProps(-90)}
>
↶ -90°
</button>
<button
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
{...editor.getRotateButtonProps(90)}
>
↷ +90°
</button>
<button
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
{...editor.getFlipHorizontalButtonProps()}
>
⇆ Flip
</button>
<button
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
{...editor.getZoomButtonProps(0.1)}
>
+ Zoom
</button>
<button
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
{...editor.getZoomButtonProps(-0.1)}
>
- Zoom
</button>
</div>
<div class="flex gap-2 flex-wrap mb-4">
<button
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
{...editor.getCropSquareButtonProps()}
>
1:1
</button>
<button
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
{...editor.getCropLandscapeButtonProps()}
>
16:9
</button>
<button
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
{...editor.getCropPortraitButtonProps()}
>
9:16
</button>
<button
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
{...editor.getResetButtonProps()}
>
Reset
</button>
</div>
<div class="flex gap-4 justify-end">
<button
class="bg-gray-500 text-white px-4 py-2 rounded-md"
{...editor.getCancelButtonProps({ onClick: close })}
>
Cancel
</button>
<button
class="bg-green-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-green-300"
{...editor.getSaveButtonProps({ onClick: close })}
>
Save
</button>
</div>
</div>

View file

@ -1,5 +1,6 @@
<script lang="ts">
import Uppy from '@uppy/core'
import Uppy, { type UppyFile } from '@uppy/core'
import UppyImageEditor from '@uppy/image-editor'
import UppyRemoteSources from '@uppy/remote-sources'
import UppyScreenCapture from '@uppy/screen-capture'
import {
@ -12,8 +13,10 @@ import {
import Tus from '@uppy/tus'
import UppyWebcam from '@uppy/webcam'
import '@uppy/svelte/css/style.css'
import '@uppy/svelte/css/image-editor.css'
import CustomDropzone from '../components/CustomDropzone.svelte'
import ImageEditor from '../components/ImageEditor.svelte'
import RemoteSource from '../components/RemoteSource.svelte'
import ScreenCapture from '../components/ScreenCapture.svelte'
import Webcam from '../components/Webcam.svelte'
@ -24,18 +27,31 @@ const uppy = new Uppy()
})
.use(UppyWebcam)
.use(UppyScreenCapture)
.use(UppyImageEditor)
.use(UppyRemoteSources, { companionUrl: 'http://localhost:3020' })
let dialogRef: HTMLDialogElement
let modalPlugin = $state<'webcam' | 'dropbox' | 'screen-capture' | null>(null)
let modalPlugin = $state<
'webcam' | 'dropbox' | 'screen-capture' | 'image-editor' | null
>(null)
let selectedFile = $state<UppyFile<any, any> | null>(null)
function openModal(plugin: 'webcam' | 'dropbox' | 'screen-capture') {
modalPlugin = plugin
dialogRef?.showModal()
}
function openImageEditorModal(file: UppyFile<any, any>) {
// https://github.com/transloadit/uppy/issues/6148
if (!file.type.startsWith('image/')) return
selectedFile = file
modalPlugin = 'image-editor'
dialogRef?.showModal()
}
function closeModal() {
modalPlugin = null
selectedFile = null
dialogRef?.close()
}
</script>
@ -59,18 +75,21 @@ function closeModal() {
{#if modalPlugin === 'screen-capture'}
<ScreenCapture close={closeModal} />
{/if}
{#if modalPlugin === 'image-editor' && selectedFile}
<ImageEditor file={selectedFile} close={closeModal} />
{/if}
</dialog>
<article>
<h2 class="text-2xl my-4">With list</h2>
<Dropzone />
<FilesList />
<FilesList editFile={openImageEditorModal} />
</article>
<article>
<h2 class="text-2xl my-4">With grid</h2>
<Dropzone />
<FilesGrid columns={2} />
<FilesGrid columns={2} editFile={openImageEditorModal} />
</article>
<article>

View file

@ -19,18 +19,23 @@
v-if="modalPlugin === 'screen-capture'"
:close="closeModal"
/>
<ImageEditor
v-if="modalPlugin === 'image-editor' && selectedFile"
:file="selectedFile"
:close="closeModal"
/>
</dialog>
<article>
<h2 class="text-2xl my-4">With list</h2>
<Dropzone />
<FilesList />
<FilesList :edit-file="openImageEditorModal" />
</article>
<article>
<h2 class="text-2xl my-4">With grid</h2>
<Dropzone />
<FilesGrid :columns="2" />
<FilesGrid :columns="2" :edit-file="openImageEditorModal" />
</article>
<article>
@ -42,7 +47,8 @@
</template>
<script setup lang="ts">
import Uppy from '@uppy/core'
import Uppy, { type UppyFile } from '@uppy/core'
import UppyImageEditor from '@uppy/image-editor'
import UppyRemoteSources from '@uppy/remote-sources'
import UppyScreenCapture from '@uppy/screen-capture'
import Tus from '@uppy/tus'
@ -56,20 +62,33 @@ import {
import UppyWebcam from '@uppy/webcam'
import { computed, ref } from 'vue'
import CustomDropzone from './Dropzone.vue'
import ImageEditor from './ImageEditor.vue'
import RemoteSource from './RemoteSource.vue'
import ScreenCapture from './ScreenCapture.vue'
import Webcam from './Webcam.vue'
const dialogRef = ref<HTMLDialogElement | null>(null)
const modalPlugin = ref<'webcam' | 'dropbox' | 'screen-capture' | null>(null)
const modalPlugin = ref<
'webcam' | 'dropbox' | 'screen-capture' | 'image-editor' | null
>(null)
const selectedFile = ref<UppyFile<any, any> | null>(null)
function openModal(plugin: 'webcam' | 'dropbox' | 'screen-capture') {
modalPlugin.value = plugin
dialogRef.value?.showModal()
}
function openImageEditorModal(file: UppyFile<any, any>) {
// https://github.com/transloadit/uppy/issues/6148
if (!file.type.startsWith('image/')) return
selectedFile.value = file
modalPlugin.value = 'image-editor'
dialogRef.value?.showModal()
}
function closeModal() {
modalPlugin.value = null
selectedFile.value = null
dialogRef.value?.close()
}
@ -80,8 +99,10 @@ const uppy = computed(() =>
})
.use(UppyWebcam)
.use(UppyScreenCapture)
.use(UppyImageEditor)
.use(UppyRemoteSources, { companionUrl: 'http://localhost:3020' }),
)
</script>
<style src="@uppy/vue/css/style.css"></style>
<style src="@uppy/vue/css/image-editor.css"></style>

View file

@ -0,0 +1,118 @@
<template>
<div class="p-4 max-w-2xl w-full">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold">Edit Image</h2>
<button
type="button"
@click="close"
class="text-gray-500 hover:text-gray-700"
>
</button>
</div>
<div class="mb-4">
<img
class="w-full max-h-[400px] rounded-lg border-2"
v-bind="editor.getImageProps()"
/>
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-2">
Fine Rotation: {{ editor.state.angle }}°
</label>
<input
class="w-full"
v-bind="editor.getRotationSliderProps()"
/>
</div>
<div class="flex gap-2 flex-wrap mb-4">
<button
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
v-bind="editor.getRotateButtonProps(-90)"
>
-90°
</button>
<button
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
v-bind="editor.getRotateButtonProps(90)"
>
+90°
</button>
<button
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
v-bind="editor.getFlipHorizontalButtonProps()"
>
Flip
</button>
<button
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
v-bind="editor.getZoomButtonProps(0.1)"
>
+ Zoom
</button>
<button
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
v-bind="editor.getZoomButtonProps(-0.1)"
>
- Zoom
</button>
</div>
<div class="flex gap-2 flex-wrap mb-4">
<button
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
v-bind="editor.getCropSquareButtonProps()"
>
1:1
</button>
<button
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
v-bind="editor.getCropLandscapeButtonProps()"
>
16:9
</button>
<button
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
v-bind="editor.getCropPortraitButtonProps()"
>
9:16
</button>
<button
class="bg-gray-200 px-3 py-1 rounded disabled:opacity-50"
v-bind="editor.getResetButtonProps()"
>
Reset
</button>
</div>
<div class="flex gap-4 justify-end">
<button
class="bg-gray-500 text-white px-4 py-2 rounded-md"
v-bind="editor.getCancelButtonProps({ onClick: close })"
>
Cancel
</button>
<button
class="bg-green-500 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:bg-green-300"
v-bind="editor.getSaveButtonProps({ onClick: close })"
>
Save
</button>
</div>
</div>
</template>
<script setup lang="ts">
import type { UppyFile } from '@uppy/core'
import { useImageEditor } from '@uppy/vue'
const props = defineProps<{
file: UppyFile<any, any>
close: () => void
}>()
const editor = useImageEditor({ file: props.file })
</script>

View file

@ -30,13 +30,14 @@
],
"scripts": {
"build": "tsc --build tsconfig.build.json",
"build:css": "tailwindcss -m -i ./src/input.css -o ./dist/styles.css",
"build:css": "tailwindcss -m -i ./src/input.css -o ./dist/styles.css && postcss ./src/image-editor.css -u cssnano -o ./dist/image-editor.css",
"migrate": "node migrate.mjs",
"typecheck": "tsc --build"
},
"exports": {
".": "./lib/index.js",
"./css/style.css": "./dist/styles.css",
"./css/image-editor.css": "./dist/image-editor.css",
"./package.json": "./package.json"
},
"dependencies": {
@ -48,6 +49,9 @@
"devDependencies": {
"@tailwindcss/cli": "^4.0.6",
"@uppy/core": "workspace:^",
"cssnano": "^7.0.7",
"postcss": "^8.5.6",
"postcss-cli": "^11.0.1",
"tailwindcss": "^4.0.6",
"typescript": "^5.8.3"
},

View file

@ -0,0 +1,283 @@
import type { Uppy, UppyEventMap, UppyFile } from '@uppy/core'
import type ImageEditor from '@uppy/image-editor'
import type { AspectRatio } from '@uppy/image-editor'
import { Subscribers } from './utils.js'
export type { AspectRatio } from '@uppy/image-editor'
export type ImageEditorState = {
angle: number
isFlippedHorizontally: boolean
aspectRatio: AspectRatio
}
type ButtonProps = {
type: 'button'
onClick: () => void
disabled: boolean
'aria-label': string
}
type ButtonClickOptions = {
onClick?: () => void
}
type SliderProps<EventType extends Event = Event> = {
type: 'range'
min: number
max: number
value: number
onChange: (e: EventType) => void
'aria-label': string
}
type ImageProps<EventType extends Event = Event> = {
id: string
src: string | undefined
alt: string
onLoad: (e: EventType) => void
}
export type ImageEditorSnapshot<
ImageEventType extends Event = Event,
SliderEventType extends Event = Event,
> = {
state: ImageEditorState
getImageProps: () => ImageProps<ImageEventType>
getSaveButtonProps: (options?: ButtonClickOptions) => ButtonProps
getCancelButtonProps: (options?: ButtonClickOptions) => ButtonProps
getRotateButtonProps: (degrees: number) => ButtonProps
getFlipHorizontalButtonProps: () => ButtonProps
getZoomButtonProps: (ratio: number) => ButtonProps
getCropSquareButtonProps: () => ButtonProps
getCropLandscapeButtonProps: () => ButtonProps
getCropPortraitButtonProps: () => ButtonProps
getResetButtonProps: () => ButtonProps
getRotationSliderProps: () => SliderProps<SliderEventType>
}
export type ImageEditorStore<
ImageEventType extends Event = Event,
SliderEventType extends Event = Event,
> = {
subscribe: (listener: () => void) => () => void
getSnapshot: () => ImageEditorSnapshot<ImageEventType, SliderEventType>
start: () => void
stop: () => void
}
const imgElementId = 'uppy-image-editor-image'
export function createImageEditorController<
ImageEventType extends Event = Event,
SliderEventType extends Event = Event,
>(
uppy: Uppy<any, any>,
options: { file: UppyFile<any, any> },
): ImageEditorStore<ImageEventType, SliderEventType> {
const plugin = uppy.getPlugin<ImageEditor<any, any>>('ImageEditor')
if (!plugin) {
throw new Error(
'ImageEditor plugin is not installed. Install @uppy/image-editor and add it to the Uppy instance with `uppy.use(ImageEditor)`.',
)
}
const { file } = options
const subscribers = new Subscribers()
const onStateUpdate: UppyEventMap<any, any>['state-update'] = (
_prev,
_next,
patch,
) => {
const editorPatch = patch?.plugins?.ImageEditor
if (editorPatch) {
subscribers.emit()
}
}
const start = () => {
uppy.on('state-update', onStateUpdate)
plugin.start(file)
}
const stop = () => {
uppy.off('state-update', onStateUpdate)
plugin.stop()
}
const isCropperReady = () => plugin.getPluginState().cropperReady
// Actions
const save = (): void => {
plugin.save()
}
const cancel = (): void => {
uppy.emit('file-editor:cancel', file)
}
const rotateBy = (degrees: number): void => {
plugin.rotateBy(degrees)
}
const rotateGranular = (degrees: number): void => {
plugin.rotateGranular(degrees)
}
const flipHorizontal = (): void => {
plugin.flipHorizontal()
}
const zoom = (ratio: number): void => {
plugin.zoom(ratio)
}
const setAspectRatio = (newRatio: AspectRatio): void => {
plugin.setAspectRatio(newRatio)
}
const reset = (): void => {
plugin.reset()
}
// Props getters
const getImageProps = (): ImageProps<ImageEventType> => ({
id: imgElementId,
src: plugin.getObjectUrl() ?? undefined,
alt: file.name ?? '',
onLoad: (e: ImageEventType) => {
plugin.initCropper(e.currentTarget as HTMLImageElement)
},
})
const getSaveButtonProps = (
options: ButtonClickOptions = {},
): ButtonProps => ({
type: 'button',
onClick: () => {
save()
options.onClick?.()
},
disabled: !isCropperReady(),
'aria-label': 'Save changes',
})
const getCancelButtonProps = (
options: ButtonClickOptions = {},
): ButtonProps => ({
type: 'button',
onClick: () => {
cancel()
options.onClick?.()
},
disabled: false,
'aria-label': 'Cancel editing',
})
const getRotateButtonProps = (degrees: number): ButtonProps => ({
type: 'button',
onClick: () => rotateBy(degrees),
disabled: !isCropperReady(),
'aria-label': `Rotate ${degrees} degrees`,
})
const getFlipHorizontalButtonProps = (): ButtonProps => ({
type: 'button',
onClick: flipHorizontal,
disabled: !isCropperReady(),
'aria-label': 'Flip horizontally',
})
const getZoomButtonProps = (ratio: number): ButtonProps => ({
type: 'button',
onClick: () => zoom(ratio),
disabled: !isCropperReady(),
'aria-label': ratio > 0 ? 'Zoom in' : 'Zoom out',
})
const getCropSquareButtonProps = (): ButtonProps => ({
type: 'button',
onClick: () => setAspectRatio('1:1'),
disabled: !isCropperReady(),
'aria-label': 'Crop square (1:1)',
})
const getCropLandscapeButtonProps = (): ButtonProps => ({
type: 'button',
onClick: () => setAspectRatio('16:9'),
disabled: !isCropperReady(),
'aria-label': 'Crop landscape (16:9)',
})
const getCropPortraitButtonProps = (): ButtonProps => ({
type: 'button',
onClick: () => setAspectRatio('9:16'),
disabled: !isCropperReady(),
'aria-label': 'Crop portrait (9:16)',
})
const getResetButtonProps = (): ButtonProps => ({
type: 'button',
onClick: reset,
disabled: !isCropperReady(),
'aria-label': 'Reset all changes',
})
const getRotationSliderProps = (): SliderProps<SliderEventType> => ({
type: 'range',
min: -45,
max: 45,
value: plugin.getPluginState().angleGranular,
onChange: (e: SliderEventType) => {
const granularAngle = Number((e.target as HTMLInputElement).value)
rotateGranular(granularAngle)
},
'aria-label': 'Fine rotation adjustment',
})
const getSnapshot = (
pluginState = plugin.getPluginState(),
): ImageEditorSnapshot<ImageEventType, SliderEventType> => ({
state: {
angle: pluginState.angle,
isFlippedHorizontally: pluginState.isFlippedHorizontally,
aspectRatio: pluginState.aspectRatio,
},
getImageProps,
getSaveButtonProps,
getCancelButtonProps,
getRotateButtonProps,
getFlipHorizontalButtonProps,
getZoomButtonProps,
getCropSquareButtonProps,
getCropLandscapeButtonProps,
getCropPortraitButtonProps,
getResetButtonProps,
getRotationSliderProps,
})
let cachedPluginState = plugin.getPluginState()
let cachedSnapshot: ImageEditorSnapshot<ImageEventType, SliderEventType> =
getSnapshot(cachedPluginState)
const getCachedSnapshot = (): ImageEditorSnapshot<
ImageEventType,
SliderEventType
> => {
const pluginState = plugin.getPluginState()
if (pluginState === cachedPluginState) return cachedSnapshot
cachedPluginState = pluginState
cachedSnapshot = getSnapshot(pluginState)
return cachedSnapshot
}
return {
subscribe: subscribers.add,
getSnapshot: getCachedSnapshot,
start,
stop,
}
}

View file

@ -0,0 +1 @@
../../image-editor/src/cropper.scss

View file

@ -14,6 +14,13 @@ export {
type FileInputFunctions,
type FileInputProps,
} from './hooks/file-input.js'
export {
type AspectRatio,
createImageEditorController,
type ImageEditorSnapshot,
type ImageEditorState,
type ImageEditorStore,
} from './hooks/image-editor.js'
export {
createRemoteSourceController,
type RemoteSourceKeys,

View file

@ -14,10 +14,11 @@
"build:css": {
"inputs": [
"src/input.css",
"src/image-editor.css",
"src/**/*.{js,ts,jsx,tsx}",
"tailwind.config.js"
],
"outputs": ["dist/styles.css"]
"outputs": ["dist/styles.css", "dist/image-editor.css"]
}
}
}

View file

@ -1,146 +1,49 @@
import type { Body, Meta } from '@uppy/core'
import type { I18n, LocalUppyFile } from '@uppy/utils'
import Cropper from 'cropperjs'
import { Component } from 'preact'
import type ImageEditor from './ImageEditor.js'
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 { AspectRatio } from './ImageEditor.js'
type Props<M extends Meta, B extends Body> = {
currentImage: LocalUppyFile<M, B>
storeCropperInstance: (cropper: Cropper) => void
objectUrl: string
initCropper: (imgElement: HTMLImageElement) => void
opts: ImageEditor<M, B>['opts']
i18n: I18n
save: () => void
}
type State = {
angle90Deg: number
angleGranular: number
prevCropboxData: Cropper.CropBoxData | null
rotateBy: (degrees: number) => void
rotateGranular: (degrees: number) => void
flipHorizontal: () => void
zoom: (ratio: number) => void
setAspectRatio: (ratio: AspectRatio) => void
reset: () => void
}
export default class Editor<M extends Meta, B extends Body> extends Component<
Props<M, B>,
State
Props<M, B>
> {
imgElement!: HTMLImageElement
cropper!: Cropper
constructor(props: Props<M, B>) {
super(props)
this.state = {
angle90Deg: 0,
angleGranular: 0,
prevCropboxData: null,
}
this.storePrevCropboxData = this.storePrevCropboxData.bind(this)
this.limitCropboxMovement = this.limitCropboxMovement.bind(this)
}
componentDidMount(): void {
const { opts, storeCropperInstance } = this.props
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(): 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)
}
storePrevCropboxData(): void {
this.setState({ prevCropboxData: this.cropper.getCropBoxData() })
}
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!,
)
if (newCropboxData) this.cropper.setCropBoxData(newCropboxData)
// 2. When we stretch the cropbox by one of its sides
} else {
const newCropboxData = limitCropboxMovementOnResize(
canvasData,
cropboxData,
prevCropboxData!,
)
if (newCropboxData) this.cropper.setCropBoxData(newCropboxData)
const { initCropper } = this.props
if (this.imgElement) {
initCropper(this.imgElement)
}
}
onRotate90Deg = (): void => {
// 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)
this.props.rotateBy(-90)
}
onRotateGranular = (ev: Event): void => {
// 1. Set state
const newGranularAngle = Number((ev.target as HTMLInputElement).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 image = this.cropper.getImageData()
const scaleFactor = getScaleFactorThatRemovesDarkCorners(
image.naturalWidth,
image.naturalHeight,
newGranularAngle,
)
// Preserve flip
const scaleFactorX =
this.cropper.getImageData().scaleX < 0 ? -scaleFactor : scaleFactor
this.cropper.scale(scaleFactorX, scaleFactor)
this.props.rotateGranular(newGranularAngle)
}
renderGranularRotate() {
const { i18n } = this.props
const { angleGranular } = this.state
const { angleGranular } = this.props
return (
<label
@ -164,7 +67,7 @@ export default class Editor<M extends Meta, B extends Body> extends Component<
}
renderRevert() {
const { i18n, opts } = this.props
const { i18n } = this.props
return (
<button
@ -173,11 +76,7 @@ export default class Editor<M extends Meta, B extends Body> extends Component<
className="uppy-u-reset uppy-c-btn"
aria-label={i18n('revert')}
onClick={() => {
this.cropper.reset()
this.cropper.setAspectRatio(
opts.cropperOptions.initialAspectRatio as number,
)
this.setState({ angle90Deg: 0, angleGranular: 0 })
this.props.reset()
}}
>
<svg
@ -228,9 +127,7 @@ export default class Editor<M extends Meta, B extends Body> extends Component<
type="button"
className="uppy-u-reset uppy-c-btn"
aria-label={i18n('flipHorizontal')}
onClick={() =>
this.cropper.scaleX(-this.cropper.getData().scaleX || -1)
}
onClick={() => this.props.flipHorizontal()}
>
<svg
aria-hidden="true"
@ -255,7 +152,7 @@ export default class Editor<M extends Meta, B extends Body> extends Component<
type="button"
className="uppy-u-reset uppy-c-btn"
aria-label={i18n('zoomIn')}
onClick={() => this.cropper.zoom(0.1)}
onClick={() => this.props.zoom(0.1)}
>
<svg
aria-hidden="true"
@ -281,7 +178,7 @@ export default class Editor<M extends Meta, B extends Body> extends Component<
type="button"
className="uppy-u-reset uppy-c-btn"
aria-label={i18n('zoomOut')}
onClick={() => this.cropper.zoom(-0.1)}
onClick={() => this.props.zoom(-0.1)}
>
<svg
aria-hidden="true"
@ -306,7 +203,7 @@ export default class Editor<M extends Meta, B extends Body> extends Component<
type="button"
className="uppy-u-reset uppy-c-btn"
aria-label={i18n('aspectRatioSquare')}
onClick={() => this.cropper.setAspectRatio(1)}
onClick={() => this.props.setAspectRatio('1:1')}
>
<svg
aria-hidden="true"
@ -331,7 +228,7 @@ export default class Editor<M extends Meta, B extends Body> extends Component<
type="button"
className="uppy-u-reset uppy-c-btn"
aria-label={i18n('aspectRatioLandscape')}
onClick={() => this.cropper.setAspectRatio(16 / 9)}
onClick={() => this.props.setAspectRatio('16:9')}
>
<svg
aria-hidden="true"
@ -356,7 +253,7 @@ export default class Editor<M extends Meta, B extends Body> extends Component<
type="button"
aria-label={i18n('aspectRatioPortrait')}
className="uppy-u-reset uppy-c-btn"
onClick={() => this.cropper.setAspectRatio(9 / 16)}
onClick={() => this.props.setAspectRatio('9:16')}
>
<svg
aria-hidden="true"
@ -373,10 +270,8 @@ export default class Editor<M extends Meta, B extends Body> extends Component<
}
render() {
const { currentImage, opts } = this.props
const { currentImage, objectUrl, opts } = this.props
const { actions } = opts
if (currentImage.data == null) throw new Error('File data is empty')
const imageURL = URL.createObjectURL(currentImage.data)
return (
<div className="uppy-ImageCropper">
@ -384,7 +279,7 @@ export default class Editor<M extends Meta, B extends Body> extends Component<
<img
className="uppy-ImageCropper-image"
alt={currentImage.name}
src={imageURL}
src={objectUrl}
ref={(ref) => {
this.imgElement = ref as HTMLImageElement
}}

View file

@ -8,10 +8,14 @@ import type {
} from '@uppy/core'
import { UIPlugin } from '@uppy/core'
import type { LocaleStrings } from '@uppy/utils'
import type Cropper from 'cropperjs'
import Cropper from 'cropperjs'
import packageJson from '../package.json' with { type: 'json' }
import Editor from './Editor.js'
import locale from './locale.js'
import getCanvasDataThatFitsPerfectlyIntoContainer from './utils/getCanvasDataThatFitsPerfectlyIntoContainer.js'
import getScaleFactorThatRemovesDarkCorners from './utils/getScaleFactorThatRemovesDarkCorners.js'
import limitCropboxMovementOnMove from './utils/limitCropboxMovementOnMove.js'
import limitCropboxMovementOnResize from './utils/limitCropboxMovementOnResize.js'
declare module '@uppy/core' {
export interface PluginTypeRegistry<M extends Meta, B extends Body> {
@ -67,8 +71,23 @@ export type { Opts as ImageEditorOptions }
type PluginState<M extends Meta, B extends Body> = {
currentImage: UppyFile<M, B> | null
angle: number
angleGranular: number
isFlippedHorizontally: boolean
aspectRatio: AspectRatio
cropperReady: boolean
}
export type AspectRatio = 'free' | '1:1' | '16:9' | '9:16'
const defaultEditorState = {
angle: 0,
angleGranular: 0,
isFlippedHorizontally: false,
aspectRatio: 'free',
cropperReady: false,
} satisfies Omit<PluginState<any, any>, 'currentImage'>
const defaultCropperOptions = {
viewMode: 0 as const,
background: false,
@ -120,7 +139,19 @@ export default class ImageEditor<
> extends UIPlugin<InternalImageEditorOpts, M, B, PluginState<M, B>> {
static VERSION = packageJson.version
cropper!: Cropper
cropper: Cropper | null = null
objectUrl: string | null = null
prevCropboxData: Cropper.CropBoxData | null = null
private imgElement: HTMLImageElement | null = null
private cropstartHandler: (() => void) | null = null
private cropendHandler: EventListener | null = null
private cropperReadyHandler: (() => void) | null = null
constructor(uppy: Uppy<M, B>, opts?: Opts) {
super(uppy, {
@ -159,19 +190,27 @@ export default class ImageEditor<
}
save = (): void => {
const saveBlobCallback: BlobCallback = (blob) => {
const { currentImage } = this.getPluginState()
const { currentImage } = this.getPluginState()
if (!currentImage) return
if (!this.cropper) return
this.uppy.setFileState(currentImage!.id, {
const saveBlobCallback: BlobCallback = (blob) => {
if (!blob) return
const fileId = currentImage.id
if (!this.uppy.getFile(fileId)) return
this.uppy.setFileState(fileId, {
// Reinserting image's name and type, because .toBlob loses both.
data: new File([blob!], currentImage!.name ?? this.i18n('unnamed'), {
type: blob!.type,
data: new File([blob], currentImage.name ?? this.i18n('unnamed'), {
type: blob.type,
}),
size: blob!.size,
size: blob.size,
preview: undefined,
})
const updatedFile = this.uppy.getFile(currentImage!.id)
const updatedFile = this.uppy.getFile(fileId)
if (!updatedFile) return
this.uppy.emit('thumbnail:request', updatedFile)
this.setPluginState({
currentImage: updatedFile,
@ -179,8 +218,6 @@ export default class ImageEditor<
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).
@ -202,16 +239,249 @@ export default class ImageEditor<
}
selectFile = (file: UppyFile<M, B>): void => {
this.uppy.emit('file-editor:start', file)
this.start(file)
}
resetEditorState = (
currentImage: UppyFile<M, B> | null = this.getPluginState().currentImage,
): void => {
this.setPluginState({
currentImage: file,
currentImage,
...defaultEditorState,
// Preserve cropperReady if cropper instance exists
cropperReady: !!this.cropper,
})
}
install(): void {
rotateBy = (degrees: number): void => {
if (!this.cropper) return
const { angle, angleGranular, isFlippedHorizontally } =
this.getPluginState()
const base90 = angle - angleGranular
const newAngle = base90 + degrees
this.cropper.scale(isFlippedHorizontally ? -1 : 1)
this.cropper.rotateTo(newAngle)
const canvasData = this.cropper.getCanvasData()
const containerData = this.cropper.getContainerData()
const newCanvasData = getCanvasDataThatFitsPerfectlyIntoContainer(
containerData,
canvasData,
)
this.cropper.setCanvasData(newCanvasData)
this.cropper.setCropBoxData(newCanvasData)
this.setPluginState({
currentImage: null,
angle: newAngle,
angleGranular: 0,
})
}
rotateGranular = (granularAngle: number): void => {
if (!this.cropper) return
const { angle, angleGranular, isFlippedHorizontally } =
this.getPluginState()
const base90 = angle - angleGranular
const newAngle = base90 + granularAngle
this.cropper.rotateTo(newAngle)
const image = this.cropper.getImageData()
const scaleFactor = getScaleFactorThatRemovesDarkCorners(
image.naturalWidth,
image.naturalHeight,
granularAngle,
)
const scaleFactorX = isFlippedHorizontally ? -scaleFactor : scaleFactor
this.cropper.scale(scaleFactorX, scaleFactor)
this.setPluginState({
angle: newAngle,
angleGranular: granularAngle,
})
}
flipHorizontal = (): void => {
if (!this.cropper) return
const { isFlippedHorizontally } = this.getPluginState()
this.cropper.scaleX(-this.cropper.getData().scaleX || -1)
this.setPluginState({
isFlippedHorizontally: !isFlippedHorizontally,
})
}
zoom = (ratio: number): void => {
if (!this.cropper) return
this.cropper.zoom(ratio)
}
setAspectRatio = (newRatio: AspectRatio): void => {
if (!this.cropper) return
const ratioMap: Record<AspectRatio, number> = {
free: 0,
'1:1': 1,
'16:9': 16 / 9,
'9:16': 9 / 16,
}
this.cropper.setAspectRatio(ratioMap[newRatio])
this.setPluginState({
aspectRatio: newRatio,
})
}
reset = (): void => {
if (!this.cropper) return
this.cropper.reset()
this.cropper.setAspectRatio(
this.opts.cropperOptions.initialAspectRatio || 0,
)
this.resetEditorState()
}
/**
* Start editing a file - creates object URL and prepares state.
* Called by hook's start() or when user opens editor.
*/
start = (file: UppyFile<M, B>): void => {
// Clean up any previous editing session
if (this.objectUrl) {
URL.revokeObjectURL(this.objectUrl)
this.objectUrl = null
}
// Get file data - first try the passed file, then try fetching from Uppy state
let fileData = file.data
if (!(fileData instanceof Blob)) {
const uppyFile = this.uppy.getFile(file.id)
fileData = uppyFile?.data
}
if (fileData instanceof Blob) {
this.objectUrl = URL.createObjectURL(fileData)
} else {
// eslint-disable-next-line no-console
console.warn(
'[Uppy ImageEditor] Cannot edit file: file.data is not a Blob.',
'File:',
file,
'file.data:',
file.data,
'typeof file.data:',
typeof file.data,
)
}
this.uppy.emit('file-editor:start', file)
this.resetEditorState(file)
}
/**
* Stop editing - destroys cropper, revokes object URL, cleans up listeners.
*/
stop = (): void => {
this.destroyCropper()
if (this.objectUrl) {
URL.revokeObjectURL(this.objectUrl)
this.objectUrl = null
}
this.resetEditorState(null)
}
/**
* Initialize cropper on the image element. Called lazily when first edit action is triggered.
* For headless use, the hook provides the image element.
*/
initCropper = (imgElement: HTMLImageElement): void => {
if (this.cropper) return // Already initialized
this.imgElement = imgElement
this.cropper = new Cropper(imgElement, this.opts.cropperOptions)
// Store handlers so we can remove them later
this.cropstartHandler = () => {
if (this.cropper) {
this.prevCropboxData = this.cropper.getCropBoxData()
}
}
this.cropendHandler = ((event: CustomEvent<{ action: string }>) => {
if (!this.cropper || !this.prevCropboxData) return
const canvasData = this.cropper.getCanvasData()
const cropboxData = this.cropper.getCropBoxData()
if (event.detail.action === 'all') {
const newCropboxData = limitCropboxMovementOnMove(
canvasData,
cropboxData,
this.prevCropboxData,
)
if (newCropboxData) this.cropper.setCropBoxData(newCropboxData)
} else {
const newCropboxData = limitCropboxMovementOnResize(
canvasData,
cropboxData,
this.prevCropboxData,
)
if (newCropboxData) this.cropper.setCropBoxData(newCropboxData)
}
}) as EventListener
this.cropperReadyHandler = () => {
this.setPluginState({ cropperReady: true })
}
imgElement.addEventListener('cropstart', this.cropstartHandler)
imgElement.addEventListener('cropend', this.cropendHandler)
imgElement.addEventListener('ready', this.cropperReadyHandler, {
once: true,
})
}
/**
* Destroy cropper and clean up event listeners.
*/
destroyCropper = (): void => {
if (!this.cropper) return
this.setPluginState({ cropperReady: false })
if (this.cropstartHandler && this.imgElement) {
this.imgElement.removeEventListener('cropstart', this.cropstartHandler)
}
if (this.cropendHandler && this.imgElement) {
this.imgElement.removeEventListener('cropend', this.cropendHandler)
}
if (this.cropperReadyHandler && this.imgElement) {
this.imgElement.removeEventListener('ready', this.cropperReadyHandler)
}
this.cropper.destroy()
this.cropper = null
this.imgElement = null
this.cropstartHandler = null
this.cropendHandler = null
this.cropperReadyHandler = null
this.prevCropboxData = null
}
/**
* Get object URL for the current image (used by headless hook).
*/
getObjectUrl = (): string | null => {
return this.objectUrl
}
install(): void {
this.resetEditorState(null)
const { target } = this.opts
if (target) {
@ -226,11 +496,12 @@ export default class ImageEditor<
const file = this.uppy.getFile(currentImage.id)
this.uppy.emit('file-editor:cancel', file)
}
this.stop()
this.unmount()
}
render() {
const { currentImage } = this.getPluginState()
const { currentImage, angleGranular } = this.getPluginState()
if (currentImage === null || currentImage.isRemote) {
return null
@ -239,10 +510,18 @@ export default class ImageEditor<
return (
<Editor<M, B>
currentImage={currentImage}
storeCropperInstance={this.storeCropperInstance}
objectUrl={this.objectUrl ?? ''}
initCropper={this.initCropper}
save={this.save}
opts={this.opts}
i18n={this.i18n}
angleGranular={angleGranular}
rotateBy={this.rotateBy}
rotateGranular={this.rotateGranular}
flipHorizontal={this.flipHorizontal}
zoom={this.zoom}
setAspectRatio={this.setAspectRatio}
reset={this.reset}
/>
)
}

View file

@ -1,2 +1,8 @@
export type { Opts as ImageEditorOptions } from './ImageEditor.js'
export type { AspectRatio, Opts as ImageEditorOptions } from './ImageEditor.js'
export { default } from './ImageEditor.js'
// Utility functions for headless image editor hook
export { default as getCanvasDataThatFitsPerfectlyIntoContainer } from './utils/getCanvasDataThatFitsPerfectlyIntoContainer.js'
export { default as getScaleFactorThatRemovesDarkCorners } from './utils/getScaleFactorThatRemovesDarkCorners.js'
export { default as limitCropboxMovementOnMove } from './utils/limitCropboxMovementOnMove.js'
export { default as limitCropboxMovementOnResize } from './utils/limitCropboxMovementOnResize.js'

View file

@ -44,7 +44,6 @@ en_US.strings = {
browseFolders: 'browse folders',
cancel: 'Cancel',
cancelUpload: 'Cancel upload',
chooseFiles: 'Choose files',
closeModal: 'Close Modal',
companionError: 'Connection with Companion failed',
companionUnauthorizeHint:
@ -86,6 +85,7 @@ en_US.strings = {
enterUrlToImport: 'Enter URL to import a file',
error: 'Error',
exceedsSize: '%{file} exceeds maximum allowed size of %{size}',
failedToAddFiles: 'Failed to add files',
failedToFetch:
'Companion failed to fetch this URL, please make sure its correct',
failedToUpload: 'Failed to upload %{file}',

View file

@ -9,7 +9,7 @@
],
"scripts": {
"build": "tsc --build tsconfig.build.json",
"build:css": "mkdir -p dist && cp ../components/dist/styles.css dist/styles.css",
"build:css": "mkdir -p dist && cp ../components/dist/styles.css dist/styles.css && cp ../components/dist/image-editor.css dist/image-editor.css",
"typecheck": "tsc --build",
"test": "vitest run --environment=jsdom --silent='passed-only'"
},
@ -55,6 +55,7 @@
"exports": {
".": "./lib/index.js",
"./css/style.css": "./dist/styles.css",
"./css/image-editor.css": "./dist/image-editor.css",
"./dashboard": "./lib/Dashboard.js",
"./status-bar": "./lib/StatusBar.js",
"./dashboard-modal": "./lib/DashboardModal.js",

View file

@ -7,6 +7,7 @@ export {
} from './headless/UppyContextProvider.js'
export { useDropzone } from './useDropzone.js'
export { useFileInput } from './useFileInput.js'
export { useImageEditor } from './useImageEditor.js'
export { useRemoteSource } from './useRemoteSource.js'
export { useScreenCapture } from './useScreenCapture.js'
export { default as useUppyEvent } from './useUppyEvent.js'

View file

@ -0,0 +1,47 @@
import {
createImageEditorController,
type ImageEditorSnapshot,
} from '@uppy/components'
import type { UppyFile } from '@uppy/core'
import {
type ChangeEvent,
type SyntheticEvent,
useEffect,
useMemo,
useSyncExternalStore,
} from 'react'
import { useUppyContext } from './headless/UppyContextProvider.js'
type ImageEditorProps = {
file: UppyFile<any, any>
}
type ImageLoadEvent = Event & SyntheticEvent<HTMLImageElement>
type SliderChangeEvent = Event & ChangeEvent<HTMLInputElement>
export function useImageEditor(
props: ImageEditorProps,
): ImageEditorSnapshot<ImageLoadEvent, SliderChangeEvent> {
const { uppy } = useUppyContext()
const controller = useMemo(
() =>
createImageEditorController<ImageLoadEvent, SliderChangeEvent>(uppy, {
file: props.file,
}),
[uppy, props.file],
)
useEffect(() => {
controller.start()
return () => controller.stop()
}, [controller])
const store = useSyncExternalStore(
controller.subscribe,
controller.getSnapshot,
controller.getSnapshot,
)
return store
}

View file

@ -7,7 +7,7 @@
},
"build:css": {
"dependsOn": ["@uppy/components#build:css"],
"outputs": ["dist/styles.css"]
"outputs": ["dist/styles.css", "dist/image-editor.css"]
}
}
}

View file

@ -17,6 +17,7 @@
"svelte": "./dist/index.js"
},
"./css/style.css": "./dist/styles.css",
"./css/image-editor.css": "./dist/image-editor.css",
"./dashboard": {
"types": "./dist/components/Dashboard.svelte.d.ts",
"svelte": "./dist/components/Dashboard.svelte"
@ -47,7 +48,7 @@
],
"scripts": {
"build": "svelte-kit sync && svelte-package",
"build:css": "cp ../components/dist/styles.css dist/styles.css",
"build:css": "mkdir -p dist && cp ../components/dist/styles.css dist/styles.css && cp ../components/dist/image-editor.css dist/image-editor.css",
"prepublishOnly": "yarn run package",
"typecheck": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
},

View file

@ -8,3 +8,4 @@ export * from "./useFileInput.js";
export * from "./useRemoteSource.js";
export * from "./useScreenCapture.svelte.js";
export * from "./useWebcam.svelte.js";
export * from "./useImageEditor.svelte.js";

View file

@ -0,0 +1,119 @@
import {
createImageEditorController,
type ImageEditorSnapshot,
} from '@uppy/components'
import type { UppyFile } from '@uppy/core'
import { onDestroy, onMount } from 'svelte'
import { getUppyContext } from './components/headless/uppyContext.js'
import { useExternalStore } from './useSyncExternalStore.svelte.js'
type ImageEditorProps = {
file: UppyFile<any, any>
}
type ButtonProps = {
type: 'button'
onclick: () => void
disabled: boolean
'aria-label': string
}
type ButtonClickOptions = {
onClick?: () => void
}
type ImageProps = ReturnType<ImageEditorSnapshot['getImageProps']>
type SvelteImageProps = Omit<ImageProps, 'onLoad'> & {
onload: ImageProps['onLoad']
}
type SliderProps = {
type: 'range'
min: number
max: number
value: number
onchange: (e: Event) => void
'aria-label': string
}
type SvelteImageEditorSnapshot = {
state: ImageEditorSnapshot['state']
getImageProps: () => SvelteImageProps
getSaveButtonProps: (options?: ButtonClickOptions) => ButtonProps
getCancelButtonProps: (options?: ButtonClickOptions) => ButtonProps
getRotateButtonProps: (degrees: number) => ButtonProps
getFlipHorizontalButtonProps: () => ButtonProps
getZoomButtonProps: (ratio: number) => ButtonProps
getCropSquareButtonProps: () => ButtonProps
getCropLandscapeButtonProps: () => ButtonProps
getCropPortraitButtonProps: () => ButtonProps
getResetButtonProps: () => ButtonProps
getRotationSliderProps: () => SliderProps
}
export function useImageEditor(
props: ImageEditorProps,
): SvelteImageEditorSnapshot {
const ctx = getUppyContext()
const controller = createImageEditorController(ctx.uppy, { file: props.file })
const store = useExternalStore<ImageEditorSnapshot>(
controller.getSnapshot,
controller.subscribe,
)
onMount(() => controller.start())
onDestroy(() => controller.stop())
return {
get state() {
return store.value.state
},
get getImageProps() {
return () => {
const { onLoad, ...rest } = store.value.getImageProps()
return { ...rest, onload: onLoad }
}
},
getSaveButtonProps: (options?: ButtonClickOptions) => {
const { onClick, ...rest } = store.value.getSaveButtonProps(options)
return { ...rest, onclick: onClick }
},
getCancelButtonProps: (options?: ButtonClickOptions) => {
const { onClick, ...rest } = store.value.getCancelButtonProps(options)
return { ...rest, onclick: onClick }
},
getRotateButtonProps: (degrees: number) => {
const { onClick, ...rest } = store.value.getRotateButtonProps(degrees)
return { ...rest, onclick: onClick }
},
getFlipHorizontalButtonProps: () => {
const { onClick, ...rest } = store.value.getFlipHorizontalButtonProps()
return { ...rest, onclick: onClick }
},
getZoomButtonProps: (ratio: number) => {
const { onClick, ...rest } = store.value.getZoomButtonProps(ratio)
return { ...rest, onclick: onClick }
},
getCropSquareButtonProps: () => {
const { onClick, ...rest } = store.value.getCropSquareButtonProps()
return { ...rest, onclick: onClick }
},
getCropLandscapeButtonProps: () => {
const { onClick, ...rest } = store.value.getCropLandscapeButtonProps()
return { ...rest, onclick: onClick }
},
getCropPortraitButtonProps: () => {
const { onClick, ...rest } = store.value.getCropPortraitButtonProps()
return { ...rest, onclick: onClick }
},
getResetButtonProps: () => {
const { onClick, ...rest } = store.value.getResetButtonProps()
return { ...rest, onclick: onClick }
},
getRotationSliderProps: () => {
const { onChange, ...rest } = store.value.getRotationSliderProps()
return { ...rest, onchange: onChange }
},
}
}

View file

@ -14,7 +14,7 @@
},
"build:css": {
"dependsOn": ["@uppy/svelte#build", "@uppy/components#build:css"],
"outputs": ["dist/styles.css"]
"outputs": ["dist/styles.css", "dist/image-editor.css"]
}
}
}

View file

@ -8,7 +8,7 @@
],
"scripts": {
"build": "tsc --build tsconfig.build.json",
"build:css": "mkdir -p dist && cp ../components/dist/styles.css dist/styles.css",
"build:css": "mkdir -p dist && cp ../components/dist/styles.css dist/styles.css && cp ../components/dist/image-editor.css dist/image-editor.css",
"typecheck": "tsc --build"
},
"files": [
@ -30,6 +30,7 @@
"exports": {
".": "./lib/index.js",
"./css/style.css": "./dist/styles.css",
"./css/image-editor.css": "./dist/image-editor.css",
"./dashboard": "./lib/dashboard.js",
"./status-bar": "./lib/status-bar.js",
"./dashboard-modal": "./lib/dashboard-modal.js",

View file

@ -7,6 +7,7 @@ export * from './headless/generated/index.js'
export * from './useDropzone.js'
export * from './useFileInput.js'
export * from './useImageEditor.js'
export * from './useRemoteSource.js'
export * from './useScreenCapture.js'
export * from './useWebcam.js'

View file

@ -0,0 +1,29 @@
import {
createImageEditorController,
type ImageEditorSnapshot,
} from '@uppy/components'
import type { UppyFile } from '@uppy/core'
import { onMounted, onUnmounted, type ShallowRef } from 'vue'
import { injectUppyContext } from './headless/context-provider.js'
import { useExternalStore } from './useSyncExternalStore.js'
type ImageEditorProps = {
file: UppyFile<any, any>
}
export function useImageEditor(
props: ImageEditorProps,
): ShallowRef<ImageEditorSnapshot> {
const ctx = injectUppyContext()
const controller = createImageEditorController(ctx.uppy, { file: props.file })
const store = useExternalStore<ImageEditorSnapshot>(
controller.getSnapshot,
controller.subscribe,
)
onMounted(() => controller.start())
onUnmounted(() => controller.stop())
return store
}

View file

@ -7,7 +7,7 @@
},
"build:css": {
"dependsOn": ["@uppy/components#build:css"],
"outputs": ["dist/styles.css"]
"outputs": ["dist/styles.css", "dist/image-editor.css"]
}
}
}

View file

@ -8875,7 +8875,10 @@ __metadata:
"@tailwindcss/cli": "npm:^4.0.6"
"@uppy/core": "workspace:^"
clsx: "npm:^2.1.1"
cssnano: "npm:^7.0.7"
dequal: "npm:^2.0.3"
postcss: "npm:^8.5.6"
postcss-cli: "npm:^11.0.1"
preact: "npm:^10.26.10"
pretty-bytes: "npm:^6.1.1"
tailwindcss: "npm:^4.0.6"