Skip to content

Conversation

@Murderlon
Copy link
Member

  • 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
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
CleanShot.2026-01-07.at.16.10.18.mp4

@Murderlon Murderlon requested review from mifi and qxprakash January 7, 2026 15:16
@Murderlon Murderlon self-assigned this Jan 7, 2026
@changeset-bot
Copy link

changeset-bot bot commented Jan 7, 2026

⚠️ No Changeset found

Latest commit: 6ceb729

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@socket-security

This comment was marked as resolved.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds a headless image editor hook (useImageEditor) that allows framework users to build custom image editor UIs while leveraging the ImageEditor plugin's functionality. The implementation refactors the existing ImageEditor plugin to separate UI concerns from core editing logic.

Key changes:

  • Created a framework-agnostic image editor controller in @uppy/components with getter functions for image props, button props, and slider props
  • Refactored the ImageEditor plugin to expose editing methods (rotateBy, flipHorizontal, zoom, setAspectRatio, etc.) and lifecycle methods (start, stop, initCropper) that can be used by the headless hook
  • Implemented framework-specific hooks for React, Vue, and Svelte with proper event handler transformations

Reviewed changes

Copilot reviewed 27 out of 28 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
packages/@uppy/image-editor/src/ImageEditor.tsx Refactored to expose editing methods and lifecycle functions; added state management for angle, flip, aspect ratio, and cropper readiness
packages/@uppy/image-editor/src/Editor.tsx Simplified to a presentational component that delegates all logic to the parent ImageEditor plugin
packages/@uppy/image-editor/src/index.ts Added exports for AspectRatio type and utility functions used by the headless hook
packages/@uppy/components/src/hooks/image-editor.ts New headless controller that provides props getters and manages plugin interaction
packages/@uppy/components/src/image-editor.css Symlink to cropper.scss for distributing required CSS to consumers
packages/@uppy/components/package.json Added postcss dependencies and build configuration to process the cropper CSS
packages/@uppy/react/src/useImageEditor.ts React hook implementation using useSyncExternalStore and useMemo
packages/@uppy/vue/src/useImageEditor.ts Vue hook implementation returning a ShallowRef
packages/@uppy/svelte/src/lib/useImageEditor.svelte.ts Svelte hook with event handler name transformations (onClick → onclick)
packages/@uppy/locales/src/en_US.ts Removed unused chooseFiles string; added failedToAddFiles string that's used in GooglePicker
examples/react/src/ImageEditor.tsx New example showcasing the React hook usage with custom UI
examples/vue/src/ImageEditor.vue New example showcasing the Vue hook usage with custom UI
examples/sveltekit/src/components/ImageEditor.svelte New example showcasing the Svelte hook usage with custom UI
yarn.lock, packages/@uppy/**/turbo.json, packages/@uppy/**/package.json Build configuration updates for CSS processing and distribution

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

browseFolders: 'browse folders',
cancel: 'Cancel',
cancelUpload: 'Cancel upload',
chooseFiles: 'Choose files',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this intentional?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems something was missing because I only ran yarn build 🤷‍♂️

enterUrlToImport: 'Enter URL to import a file',
error: 'Error',
exceedsSize: '%{file} exceeds maximum allowed size of %{size}',
failedToAddFiles: 'Failed to add files',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and this?

)

useEffect(() => {
controller.start()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
controller.start()

as copilot mentioned above, if the file object changes, this would lead to createImageEditorController to be called again, but without first unmounting the previous one (because useEffect doesn't depend on file). maybe start should be called internally in createImageEditorController immediately, and if there is an existing "edit" running inside createImageEditorController it should be automatically stop'ed (internally)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think that's correct and start/stop should happen in the frameworks, not the abstraction. We do start/stop in the same effect so they both always run when (un)mounting?

Copy link
Collaborator

@qxprakash qxprakash left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's big PR so it took me some time to go through and understand, apart from mikael's comments ( which I agree with ) I have couple of questions and few bug reports

this.cropper.setAspectRatio(
this.opts.cropperOptions.initialAspectRatio || 0,
)
this.resetEditorState()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here resetEditorState() sets cropperReady: false despite this.cropper
still being active, which is probably causing the buttons to disable. it should preserve
cropperReady state when cropper instance is exists.


const { angle, angleGranular, isFlippedHorizontally } =
this.getPluginState()
const base90 = angle - angleGranular
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tell me if this is also a bug, if I rotate an image using the slider (let’s say +7 degrees) and then rotate it by +90 degrees the final angle should be +97 degrees right? But during testing I noticed it completely ignores the previous rotation and only rotates the image by 90 degrees.

@qxprakash
Copy link
Collaborator

qxprakash commented Jan 17, 2026

BUG : when I open the image editor for an image but don't actually make any edits and just save it why does it still increases the size of the image ?

@qxprakash
Copy link
Collaborator

qxprakash commented Jan 17, 2026

BUG : The edit button appears for all files regardless of type when a non-image file (e.g PDF) is selected the ImageEditor modal opens and attempts to initialize with an incompatible file type. Ideally this should not happen right ? we should only allow images.

image

// Props getters
const getImageProps = (): ImageProps<ImageEventType> => ({
id: imgElementId,
src: plugin.getObjectUrl() ?? '',
Copy link
Collaborator

@qxprakash qxprakash Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is causing the warning "An empty string ("") was passed to the src attribute" whenever you'll open the editor , because the inital render happens before the controller.start() creates the objectUrl , using undefined as a fallback will omit the src attribute

Image
Suggested change
src: plugin.getObjectUrl() ?? '',
src: plugin.getObjectUrl() ?? undefined,


type ImageProps<EventType extends Event = Event> = {
id: string
src: string
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

related to https://github.com/transloadit/uppy/pull/6122/changes#r2701350885

Suggested change
src: string
src: string | undefined

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants