From 09bf6857a178d2a3448fcd3f605f0171143af51f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:01:18 +0100 Subject: [PATCH 01/61] build(deps): bump lodash from 4.17.21 to 4.17.23 (#6152) Bumps [lodash](https://github.com/lodash/lodash) from 4.17.21 to 4.17.23.
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=lodash&package-manager=npm_and_yarn&previous-version=4.17.21&new-version=4.17.23)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/transloadit/uppy/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- packages/@uppy/companion/package.json | 2 +- packages/@uppy/core/package.json | 2 +- packages/@uppy/dashboard/package.json | 2 +- packages/@uppy/golden-retriever/package.json | 2 +- packages/@uppy/provider-views/package.json | 2 +- packages/@uppy/utils/package.json | 2 +- yarn.lock | 19 +++++++++++++------ 7 files changed, 19 insertions(+), 12 deletions(-) diff --git a/packages/@uppy/companion/package.json b/packages/@uppy/companion/package.json index 330499d10..97465c514 100644 --- a/packages/@uppy/companion/package.json +++ b/packages/@uppy/companion/package.json @@ -59,7 +59,7 @@ "ioredis": "^5.3.2", "ipaddr.js": "^2.0.1", "jsonwebtoken": "9.0.2", - "lodash": "^4.17.21", + "lodash": "^4.17.23", "mime-types": "2.1.35", "moment": "^2.29.2", "moment-timezone": "^0.5.31", diff --git a/packages/@uppy/core/package.json b/packages/@uppy/core/package.json index 245642eb7..1a16f5548 100644 --- a/packages/@uppy/core/package.json +++ b/packages/@uppy/core/package.json @@ -44,7 +44,7 @@ "@transloadit/prettier-bytes": "^0.3.4", "@uppy/store-default": "workspace:^", "@uppy/utils": "workspace:^", - "lodash": "^4.17.21", + "lodash": "^4.17.23", "mime-match": "^1.0.2", "namespace-emitter": "^2.0.1", "nanoid": "^5.0.9", diff --git a/packages/@uppy/dashboard/package.json b/packages/@uppy/dashboard/package.json index 241bc3795..ce3c0a1ab 100644 --- a/packages/@uppy/dashboard/package.json +++ b/packages/@uppy/dashboard/package.json @@ -49,7 +49,7 @@ "@uppy/thumbnail-generator": "workspace:^", "@uppy/utils": "workspace:^", "classnames": "^2.2.6", - "lodash": "^4.17.21", + "lodash": "^4.17.23", "nanoid": "^5.0.9", "preact": "^10.26.10", "shallow-equal": "^3.0.0" diff --git a/packages/@uppy/golden-retriever/package.json b/packages/@uppy/golden-retriever/package.json index 38cc97e04..6ab3c7eab 100644 --- a/packages/@uppy/golden-retriever/package.json +++ b/packages/@uppy/golden-retriever/package.json @@ -43,7 +43,7 @@ }, "dependencies": { "@uppy/utils": "workspace:^", - "lodash": "^4.17.21" + "lodash": "^4.17.23" }, "peerDependencies": { "@uppy/core": "workspace:^" diff --git a/packages/@uppy/provider-views/package.json b/packages/@uppy/provider-views/package.json index df5470140..4d3c3abee 100644 --- a/packages/@uppy/provider-views/package.json +++ b/packages/@uppy/provider-views/package.json @@ -42,7 +42,7 @@ "dependencies": { "@uppy/utils": "workspace:^", "classnames": "^2.2.6", - "lodash": "^4.17.21", + "lodash": "^4.17.23", "nanoid": "^5.0.9", "p-queue": "^8.0.0", "preact": "^10.26.10" diff --git a/packages/@uppy/utils/package.json b/packages/@uppy/utils/package.json index 8480f029c..77ead056b 100644 --- a/packages/@uppy/utils/package.json +++ b/packages/@uppy/utils/package.json @@ -33,7 +33,7 @@ "./package.json": "./package.json" }, "dependencies": { - "lodash": "^4.17.21", + "lodash": "^4.17.23", "preact": "^10.26.10" }, "devDependencies": { diff --git a/yarn.lock b/yarn.lock index bb1b78ec0..3c5b2b641 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8843,7 +8843,7 @@ __metadata: ioredis: "npm:^5.3.2" ipaddr.js: "npm:^2.0.1" jsonwebtoken: "npm:9.0.2" - lodash: "npm:^4.17.21" + lodash: "npm:^4.17.23" mime-types: "npm:2.1.35" moment: "npm:^2.29.2" moment-timezone: "npm:^0.5.31" @@ -8923,7 +8923,7 @@ __metadata: cssnano: "npm:^7.0.7" deep-freeze: "npm:^0.0.1" jsdom: "npm:^26.1.0" - lodash: "npm:^4.17.21" + lodash: "npm:^4.17.23" mime-match: "npm:^1.0.2" namespace-emitter: "npm:^2.0.1" nanoid: "npm:^5.0.9" @@ -8953,7 +8953,7 @@ __metadata: classnames: "npm:^2.2.6" cssnano: "npm:^7.0.7" jsdom: "npm:^26.1.0" - lodash: "npm:^4.17.21" + lodash: "npm:^4.17.23" nanoid: "npm:^5.0.9" playwright: "npm:1.57.0" postcss: "npm:^8.5.6" @@ -9051,7 +9051,7 @@ __metadata: "@uppy/utils": "workspace:^" "@uppy/xhr-upload": "workspace:^" "@vitest/browser": "npm:^3.2.4" - lodash: "npm:^4.17.21" + lodash: "npm:^4.17.23" typescript: "npm:^5.8.3" vitest: "npm:^3.2.4" peerDependencies: @@ -9192,7 +9192,7 @@ __metadata: classnames: "npm:^2.2.6" cssnano: "npm:^7.0.7" jsdom: "npm:^26.1.0" - lodash: "npm:^4.17.21" + lodash: "npm:^4.17.23" nanoid: "npm:^5.0.9" p-queue: "npm:^8.0.0" postcss: "npm:^8.5.6" @@ -9440,7 +9440,7 @@ __metadata: dependencies: "@types/lodash": "npm:^4.14.199" jsdom: "npm:^26.1.0" - lodash: "npm:^4.17.21" + lodash: "npm:^4.17.23" preact: "npm:^10.26.10" typescript: "npm:^5.8.3" vitest: "npm:^3.2.4" @@ -16699,6 +16699,13 @@ __metadata: languageName: node linkType: hard +"lodash@npm:^4.17.23": + version: 4.17.23 + resolution: "lodash@npm:4.17.23" + checksum: 10/82504c88250f58da7a5a4289f57a4f759c44946c005dd232821c7688b5fcfbf4a6268f6a6cdde4b792c91edd2f3b5398c1d2a0998274432cff76def48735e233 + languageName: node + linkType: hard + "log-symbols@npm:^4.1.0": version: 4.1.0 resolution: "log-symbols@npm:4.1.0" From 850c1cb1c5250b6d3dd6f0c7536851d489d17dbb Mon Sep 17 00:00:00 2001 From: Merlijn Vos Date: Thu, 29 Jan 2026 11:13:12 +0100 Subject: [PATCH 02/61] Add useImageEditor (#6122) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 close: () => void } export function ImageEditor({ file, close }: ImageEditorProps) { const { state, getImageProps, getSaveButtonProps, getCancelButtonProps, getRotateButtonProps, getFlipHorizontalButtonProps, getZoomButtonProps, getCropSquareButtonProps, getCropLandscapeButtonProps, getCropPortraitButtonProps, getResetButtonProps, getRotationSliderProps, } = useImageEditor({ file }) return (

Edit Image

{/* biome-ignore lint/a11y/useAltText: alt is provided by getImageProps() */}
) } export default ImageEditor ``` https://github.com/user-attachments/assets/4b0a99cc-8489-41a2-aa3b-ce88110025bf --------- Co-authored-by: Prakash --- .changeset/modern-clouds-fold.md | 9 + examples/react/src/App.tsx | 28 +- examples/react/src/ImageEditor.tsx | 131 ++++++++ .../src/components/ImageEditor.svelte | 117 +++++++ examples/sveltekit/src/routes/+page.svelte | 27 +- examples/vue/src/App.vue | 29 +- examples/vue/src/ImageEditor.vue | 118 +++++++ packages/@uppy/components/package.json | 6 +- .../components/src/hooks/image-editor.ts | 283 ++++++++++++++++ .../@uppy/components/src/image-editor.css | 1 + packages/@uppy/components/src/index.ts | 7 + packages/@uppy/components/turbo.json | 3 +- packages/@uppy/image-editor/src/Editor.tsx | 157 ++------- .../@uppy/image-editor/src/ImageEditor.tsx | 313 +++++++++++++++++- packages/@uppy/image-editor/src/index.ts | 8 +- packages/@uppy/locales/src/en_US.ts | 2 +- packages/@uppy/react/package.json | 3 +- packages/@uppy/react/src/index.ts | 1 + packages/@uppy/react/src/useImageEditor.ts | 47 +++ packages/@uppy/react/turbo.json | 2 +- packages/@uppy/svelte/package.json | 3 +- packages/@uppy/svelte/src/lib/index.ts | 1 + .../svelte/src/lib/useImageEditor.svelte.ts | 119 +++++++ packages/@uppy/svelte/turbo.json | 2 +- packages/@uppy/vue/package.json | 3 +- packages/@uppy/vue/src/index.ts | 1 + packages/@uppy/vue/src/useImageEditor.ts | 29 ++ packages/@uppy/vue/turbo.json | 2 +- yarn.lock | 3 + 29 files changed, 1285 insertions(+), 170 deletions(-) create mode 100644 .changeset/modern-clouds-fold.md create mode 100644 examples/react/src/ImageEditor.tsx create mode 100644 examples/sveltekit/src/components/ImageEditor.svelte create mode 100644 examples/vue/src/ImageEditor.vue create mode 100644 packages/@uppy/components/src/hooks/image-editor.ts create mode 120000 packages/@uppy/components/src/image-editor.css create mode 100644 packages/@uppy/react/src/useImageEditor.ts create mode 100644 packages/@uppy/svelte/src/lib/useImageEditor.svelte.ts create mode 100644 packages/@uppy/vue/src/useImageEditor.ts diff --git a/.changeset/modern-clouds-fold.md b/.changeset/modern-clouds-fold.md new file mode 100644 index 000000000..587f0ea8c --- /dev/null +++ b/.changeset/modern-clouds-fold.md @@ -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. diff --git a/examples/react/src/App.tsx b/examples/react/src/App.tsx index d9d639d2c..f5744c290 100644 --- a/examples/react/src/App.tsx +++ b/examples/react/src/App.tsx @@ -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(null) const [modalPlugin, setModalPlugin] = useState< - 'webcam' | 'dropbox' | 'screen-capture' | null + 'webcam' | 'dropbox' | 'screen-capture' | 'image-editor' | null >(null) + const [selectedFile, setSelectedFile] = useState | null>( + null, + ) function openModal(plugin: 'webcam' | 'dropbox' | 'screen-capture') { setModalPlugin(plugin) dialogRef.current?.showModal() } + function openImageEditorModal(file: UppyFile) { + // 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 closeModal()} /> case 'screen-capture': return closeModal()} /> + case 'image-editor': + return selectedFile ? ( + closeModal()} /> + ) : null default: return null } @@ -74,13 +94,13 @@ function App() {

With list

- +

With grid

- +
diff --git a/examples/react/src/ImageEditor.tsx b/examples/react/src/ImageEditor.tsx new file mode 100644 index 000000000..9d13478e7 --- /dev/null +++ b/examples/react/src/ImageEditor.tsx @@ -0,0 +1,131 @@ +import type { UppyFile } from '@uppy/core' +import { useImageEditor } from '@uppy/react' + +interface ImageEditorProps { + file: UppyFile + close: () => void +} + +export function ImageEditor({ file, close }: ImageEditorProps) { + const { + state, + getImageProps, + getSaveButtonProps, + getCancelButtonProps, + getRotateButtonProps, + getFlipHorizontalButtonProps, + getZoomButtonProps, + getCropSquareButtonProps, + getCropLandscapeButtonProps, + getCropPortraitButtonProps, + getResetButtonProps, + getRotationSliderProps, + } = useImageEditor({ file }) + + return ( +
+
+

Edit Image

+ +
+ +
+ {/* biome-ignore lint/a11y/useAltText: alt is provided by getImageProps() */} + +
+ +
+ +
+ +
+ + + + + +
+ +
+ + + + +
+ +
+ + +
+
+ ) +} + +export default ImageEditor diff --git a/examples/sveltekit/src/components/ImageEditor.svelte b/examples/sveltekit/src/components/ImageEditor.svelte new file mode 100644 index 000000000..6d9bfef55 --- /dev/null +++ b/examples/sveltekit/src/components/ImageEditor.svelte @@ -0,0 +1,117 @@ + + +
+
+

Edit Image

+ +
+ +
+ +
+ +
+ + +
+ +
+ + + + + +
+ +
+ + + + +
+ +
+ + +
+
diff --git a/examples/sveltekit/src/routes/+page.svelte b/examples/sveltekit/src/routes/+page.svelte index a134ca9ed..c9832a059 100644 --- a/examples/sveltekit/src/routes/+page.svelte +++ b/examples/sveltekit/src/routes/+page.svelte @@ -1,5 +1,6 @@ @@ -59,18 +75,21 @@ function closeModal() { {#if modalPlugin === 'screen-capture'} {/if} + {#if modalPlugin === 'image-editor' && selectedFile} + + {/if}

With list

- +

With grid

- +
diff --git a/examples/vue/src/App.vue b/examples/vue/src/App.vue index 6aa4c8167..ad77e76b3 100644 --- a/examples/vue/src/App.vue +++ b/examples/vue/src/App.vue @@ -19,18 +19,23 @@ v-if="modalPlugin === 'screen-capture'" :close="closeModal" /> +

With list

- +

With grid

- +
@@ -42,7 +47,8 @@ + diff --git a/examples/vue/src/ImageEditor.vue b/examples/vue/src/ImageEditor.vue new file mode 100644 index 000000000..e285826f5 --- /dev/null +++ b/examples/vue/src/ImageEditor.vue @@ -0,0 +1,118 @@ + + + diff --git a/packages/@uppy/components/package.json b/packages/@uppy/components/package.json index 54754202a..13e4226f7 100644 --- a/packages/@uppy/components/package.json +++ b/packages/@uppy/components/package.json @@ -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" }, diff --git a/packages/@uppy/components/src/hooks/image-editor.ts b/packages/@uppy/components/src/hooks/image-editor.ts new file mode 100644 index 000000000..25d7da483 --- /dev/null +++ b/packages/@uppy/components/src/hooks/image-editor.ts @@ -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 = { + type: 'range' + min: number + max: number + value: number + onChange: (e: EventType) => void + 'aria-label': string +} + +type ImageProps = { + 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 + 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 type ImageEditorStore< + ImageEventType extends Event = Event, + SliderEventType extends Event = Event, +> = { + subscribe: (listener: () => void) => () => void + getSnapshot: () => ImageEditorSnapshot + start: () => void + stop: () => void +} + +const imgElementId = 'uppy-image-editor-image' + +export function createImageEditorController< + ImageEventType extends Event = Event, + SliderEventType extends Event = Event, +>( + uppy: Uppy, + options: { file: UppyFile }, +): ImageEditorStore { + const plugin = uppy.getPlugin>('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['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 => ({ + 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 => ({ + 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 => ({ + 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 = + 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, + } +} diff --git a/packages/@uppy/components/src/image-editor.css b/packages/@uppy/components/src/image-editor.css new file mode 120000 index 000000000..93f82ed41 --- /dev/null +++ b/packages/@uppy/components/src/image-editor.css @@ -0,0 +1 @@ +../../image-editor/src/cropper.scss \ No newline at end of file diff --git a/packages/@uppy/components/src/index.ts b/packages/@uppy/components/src/index.ts index 0fdc2aa83..d7ba94dbc 100644 --- a/packages/@uppy/components/src/index.ts +++ b/packages/@uppy/components/src/index.ts @@ -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, diff --git a/packages/@uppy/components/turbo.json b/packages/@uppy/components/turbo.json index 67bd28e84..82715fe1c 100644 --- a/packages/@uppy/components/turbo.json +++ b/packages/@uppy/components/turbo.json @@ -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"] } } } diff --git a/packages/@uppy/image-editor/src/Editor.tsx b/packages/@uppy/image-editor/src/Editor.tsx index 7ad6fdcbb..fd449b2e9 100644 --- a/packages/@uppy/image-editor/src/Editor.tsx +++ b/packages/@uppy/image-editor/src/Editor.tsx @@ -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 = { currentImage: LocalUppyFile - storeCropperInstance: (cropper: Cropper) => void + objectUrl: string + initCropper: (imgElement: HTMLImageElement) => void opts: ImageEditor['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 extends Component< - Props, - State + Props > { imgElement!: HTMLImageElement - cropper!: Cropper - - constructor(props: Props) { - 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 (