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