import type { Body, DefinePluginOpts, Meta, UIPluginOptions, Uppy, UppyFile, } from '@uppy/core' import { UIPlugin } from '@uppy/core' import type { LocaleStrings } from '@uppy/utils' import type Cropper from 'cropperjs' import packageJson from '../package.json' with { type: 'json' } import Editor from './Editor.js' import locale from './locale.js' declare global { namespace preact { interface Component { // This is a workaround for https://github.com/preactjs/preact/issues/1206 refs: Record } } } type ThumbnailGeneratedCallback = ( file: UppyFile, preview: string, ) => void type GenericCallback = ( file: UppyFile, ) => void declare module '@uppy/core' { export interface UppyEventMap { 'thumbnail:request': GenericCallback 'thumbnail:generated': ThumbnailGeneratedCallback 'file-editor:complete': GenericCallback 'file-editor:start': GenericCallback 'file-editor:cancel': GenericCallback } } export interface Opts extends UIPluginOptions { 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 } locale?: LocaleStrings } export type { Opts as ImageEditorOptions } type PluginState = { currentImage: UppyFile | null } const defaultCropperOptions = { viewMode: 0 as const, background: false, autoCropArea: 1, responsive: true, minCropBoxWidth: 70, minCropBoxHeight: 70, croppedCanvasOptions: {}, initialAspectRatio: 0, } satisfies Partial const defaultActions = { revert: true, rotate: true, granularRotate: true, flip: true, zoomIn: true, zoomOut: true, cropSquare: true, cropWidescreen: true, cropWidescreenVertical: true, } satisfies Partial const defaultOptions = { // `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 type InternalImageEditorOpts = Omit< DefinePluginOpts, 'actions' | 'cropperOptions' > & { actions: DefinePluginOpts< NonNullable, keyof typeof defaultActions > cropperOptions: DefinePluginOpts< NonNullable, keyof typeof defaultCropperOptions > } export default class ImageEditor< M extends Meta, B extends Body, > extends UIPlugin> { static VERSION = packageJson.version cropper!: Cropper constructor(uppy: Uppy, 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() } canEditFile(file: UppyFile): 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 ?? this.i18n('unnamed'), { 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) .toBlob(saveBlobCallback, currentImage!.type, this.opts.quality) } storeCropperInstance = (cropper: Cropper): void => { this.cropper = cropper } selectFile = (file: UppyFile): 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() { const { currentImage } = this.getPluginState() if (currentImage === null || currentImage.isRemote) { return null } return ( currentImage={currentImage} storeCropperInstance={this.storeCropperInstance} save={this.save} opts={this.opts} i18n={this.i18n} /> ) } }