mirror of
https://github.com/transloadit/uppy.git
synced 2026-07-17 16:50:22 +00:00
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:
parent
09bf6857a1
commit
850c1cb1c5
29 changed files with 1285 additions and 170 deletions
9
.changeset/modern-clouds-fold.md
Normal file
9
.changeset/modern-clouds-fold.md
Normal 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.
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
131
examples/react/src/ImageEditor.tsx
Normal file
131
examples/react/src/ImageEditor.tsx
Normal 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
|
||||
117
examples/sveltekit/src/components/ImageEditor.svelte
Normal file
117
examples/sveltekit/src/components/ImageEditor.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
118
examples/vue/src/ImageEditor.vue
Normal file
118
examples/vue/src/ImageEditor.vue
Normal 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>
|
||||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
283
packages/@uppy/components/src/hooks/image-editor.ts
Normal file
283
packages/@uppy/components/src/hooks/image-editor.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
1
packages/@uppy/components/src/image-editor.css
Symbolic link
1
packages/@uppy/components/src/image-editor.css
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../image-editor/src/cropper.scss
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 it’s correct',
|
||||
failedToUpload: 'Failed to upload %{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",
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
47
packages/@uppy/react/src/useImageEditor.ts
Normal file
47
packages/@uppy/react/src/useImageEditor.ts
Normal 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
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
},
|
||||
"build:css": {
|
||||
"dependsOn": ["@uppy/components#build:css"],
|
||||
"outputs": ["dist/styles.css"]
|
||||
"outputs": ["dist/styles.css", "dist/image-editor.css"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
119
packages/@uppy/svelte/src/lib/useImageEditor.svelte.ts
Normal file
119
packages/@uppy/svelte/src/lib/useImageEditor.svelte.ts
Normal 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 }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
29
packages/@uppy/vue/src/useImageEditor.ts
Normal file
29
packages/@uppy/vue/src/useImageEditor.ts
Normal 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
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
},
|
||||
"build:css": {
|
||||
"dependsOn": ["@uppy/components#build:css"],
|
||||
"outputs": ["dist/styles.css"]
|
||||
"outputs": ["dist/styles.css", "dist/image-editor.css"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue