ImageEditor.tsx 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. import { UIPlugin, type UIPluginOptions, type Uppy } from '@uppy/core'
  2. import type { DefinePluginOpts } from '@uppy/core/lib/BasePlugin.js'
  3. import type Cropper from 'cropperjs'
  4. import { h } from 'preact'
  5. import type { Meta, Body, UppyFile } from '@uppy/utils/lib/UppyFile'
  6. import Editor from './Editor.tsx'
  7. // eslint-disable-next-line @typescript-eslint/ban-ts-comment
  8. // @ts-ignore We don't want TS to generate types for the package.json
  9. import packageJson from '../package.json'
  10. import locale from './locale.ts'
  11. declare global {
  12. namespace preact {
  13. interface Component {
  14. // This is a workaround for https://github.com/preactjs/preact/issues/1206
  15. refs: Record<string, any>
  16. }
  17. }
  18. }
  19. type ThumbnailGeneratedCallback<M extends Meta, B extends Body> = (
  20. file: UppyFile<M, B>,
  21. preview: string,
  22. ) => void
  23. type GenericCallback<M extends Meta, B extends Body> = (
  24. file: UppyFile<M, B>,
  25. ) => void
  26. declare module '@uppy/core' {
  27. export interface UppyEventMap<M extends Meta, B extends Body> {
  28. 'thumbnail:request': GenericCallback<M, B>
  29. 'thumbnail:generated': ThumbnailGeneratedCallback<M, B>
  30. 'file-editor:complete': GenericCallback<M, B>
  31. 'file-editor:start': GenericCallback<M, B>
  32. 'file-editor:cancel': GenericCallback<M, B>
  33. }
  34. }
  35. interface Opts extends UIPluginOptions {
  36. target?: string | HTMLElement
  37. quality?: number
  38. cropperOptions?: Cropper.Options & {
  39. croppedCanvasOptions?: Cropper.GetCroppedCanvasOptions
  40. }
  41. actions?: {
  42. revert?: boolean
  43. rotate?: boolean
  44. granularRotate?: boolean
  45. flip?: boolean
  46. zoomIn?: boolean
  47. zoomOut?: boolean
  48. cropSquare?: boolean
  49. cropWidescreen?: boolean
  50. cropWidescreenVertical?: boolean
  51. }
  52. }
  53. export type { Opts as ImageEditorOptions }
  54. type PluginState<M extends Meta, B extends Body> = {
  55. currentImage: UppyFile<M, B> | null
  56. }
  57. const defaultCropperOptions = {
  58. viewMode: 0 as const,
  59. background: false,
  60. autoCropArea: 1,
  61. responsive: true,
  62. minCropBoxWidth: 70,
  63. minCropBoxHeight: 70,
  64. croppedCanvasOptions: {},
  65. initialAspectRatio: 0,
  66. } satisfies Partial<Opts['cropperOptions']>
  67. const defaultActions = {
  68. revert: true,
  69. rotate: true,
  70. granularRotate: true,
  71. flip: true,
  72. zoomIn: true,
  73. zoomOut: true,
  74. cropSquare: true,
  75. cropWidescreen: true,
  76. cropWidescreenVertical: true,
  77. } satisfies Partial<Opts['actions']>
  78. const defaultOptions = {
  79. target: 'body',
  80. // `quality: 1` increases the image size by orders of magnitude - 0.8 seems to be the sweet spot.
  81. // see https://github.com/fengyuanchen/cropperjs/issues/538#issuecomment-1776279427
  82. quality: 0.8,
  83. actions: defaultActions,
  84. cropperOptions: defaultCropperOptions,
  85. } satisfies Partial<Opts>
  86. type InternalImageEditorOpts = Omit<
  87. DefinePluginOpts<Opts, keyof typeof defaultOptions>,
  88. 'actions' | 'cropperOptions'
  89. > & {
  90. actions: DefinePluginOpts<
  91. NonNullable<Opts['actions']>,
  92. keyof typeof defaultActions
  93. >
  94. cropperOptions: DefinePluginOpts<
  95. NonNullable<Opts['cropperOptions']>,
  96. keyof typeof defaultCropperOptions
  97. >
  98. }
  99. export default class ImageEditor<
  100. M extends Meta,
  101. B extends Body,
  102. > extends UIPlugin<InternalImageEditorOpts, M, B, PluginState<M, B>> {
  103. static VERSION = packageJson.version
  104. cropper: Cropper
  105. constructor(uppy: Uppy<M, B>, opts?: Opts) {
  106. super(uppy, {
  107. ...defaultOptions,
  108. ...opts,
  109. actions: {
  110. ...defaultActions,
  111. ...opts?.actions,
  112. },
  113. cropperOptions: {
  114. ...defaultCropperOptions,
  115. ...opts?.cropperOptions,
  116. },
  117. })
  118. this.id = this.opts.id || 'ImageEditor'
  119. this.title = 'Image Editor'
  120. this.type = 'editor'
  121. this.defaultLocale = locale
  122. this.i18nInit()
  123. }
  124. // eslint-disable-next-line class-methods-use-this
  125. canEditFile(file: UppyFile<M, B>): boolean {
  126. if (!file.type || file.isRemote) {
  127. return false
  128. }
  129. const fileTypeSpecific = file.type.split('/')[1]
  130. if (/^(jpe?g|gif|png|bmp|webp)$/.test(fileTypeSpecific)) {
  131. return true
  132. }
  133. return false
  134. }
  135. save = (): void => {
  136. const saveBlobCallback: BlobCallback = (blob) => {
  137. const { currentImage } = this.getPluginState()
  138. this.uppy.setFileState(currentImage!.id, {
  139. // Reinserting image's name and type, because .toBlob loses both.
  140. data: new File([blob!], currentImage!.name, { type: blob!.type }),
  141. size: blob!.size,
  142. preview: undefined,
  143. })
  144. const updatedFile = this.uppy.getFile(currentImage!.id)
  145. this.uppy.emit('thumbnail:request', updatedFile)
  146. this.setPluginState({
  147. currentImage: updatedFile,
  148. })
  149. this.uppy.emit('file-editor:complete', updatedFile)
  150. }
  151. const { currentImage } = this.getPluginState()
  152. // Fixes black 1px lines on odd-width images.
  153. // This should be removed when cropperjs fixes this issue.
  154. // (See https://github.com/transloadit/uppy/issues/4305 and https://github.com/fengyuanchen/cropperjs/issues/551).
  155. const croppedCanvas = this.cropper.getCroppedCanvas({})
  156. if (croppedCanvas.width % 2 !== 0) {
  157. this.cropper.setData({ width: croppedCanvas.width - 1 })
  158. }
  159. if (croppedCanvas.height % 2 !== 0) {
  160. this.cropper.setData({ height: croppedCanvas.height - 1 })
  161. }
  162. this.cropper
  163. .getCroppedCanvas(this.opts.cropperOptions.croppedCanvasOptions)
  164. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  165. .toBlob(saveBlobCallback, currentImage!.type, this.opts.quality)
  166. }
  167. storeCropperInstance = (cropper: Cropper): void => {
  168. this.cropper = cropper
  169. }
  170. selectFile = (file: UppyFile<M, B>): void => {
  171. this.uppy.emit('file-editor:start', file)
  172. this.setPluginState({
  173. currentImage: file,
  174. })
  175. }
  176. install(): void {
  177. this.setPluginState({
  178. currentImage: null,
  179. })
  180. const { target } = this.opts
  181. if (target) {
  182. this.mount(target, this)
  183. }
  184. }
  185. uninstall(): void {
  186. const { currentImage } = this.getPluginState()
  187. if (currentImage) {
  188. const file = this.uppy.getFile(currentImage.id)
  189. this.uppy.emit('file-editor:cancel', file)
  190. }
  191. this.unmount()
  192. }
  193. render(): JSX.Element | null {
  194. const { currentImage } = this.getPluginState()
  195. if (currentImage === null || currentImage.isRemote) {
  196. return null
  197. }
  198. return (
  199. <Editor<M, B>
  200. currentImage={currentImage}
  201. storeCropperInstance={this.storeCropperInstance}
  202. save={this.save}
  203. opts={this.opts}
  204. i18n={this.i18n}
  205. />
  206. )
  207. }
  208. }