index.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503
  1. import { UIPlugin, Uppy, type UIPluginOptions } from '@uppy/core'
  2. import dataURItoBlob from '@uppy/utils/lib/dataURItoBlob'
  3. import isObjectURL from '@uppy/utils/lib/isObjectURL'
  4. import isPreviewSupported from '@uppy/utils/lib/isPreviewSupported'
  5. // eslint-disable-next-line @typescript-eslint/ban-ts-comment
  6. // @ts-ignore untyped
  7. import { rotation } from 'exifr/dist/mini.esm.mjs'
  8. import type { DefinePluginOpts } from '@uppy/core/lib/BasePlugin'
  9. import type { Body, Meta, UppyFile } from '@uppy/utils/lib/UppyFile'
  10. import locale from './locale.ts'
  11. // eslint-disable-next-line @typescript-eslint/ban-ts-comment
  12. // @ts-ignore We don't want TS to generate types for the package.json
  13. import packageJson from '../package.json'
  14. declare module '@uppy/core' {
  15. // eslint-disable-next-line @typescript-eslint/no-unused-vars
  16. export interface UppyEventMap<M extends Meta, B extends Body> {
  17. 'thumbnail:all-generated': () => void
  18. 'thumbnail:generated': (file: UppyFile<M, B>, preview: string) => void
  19. 'thumbnail:error': (file: UppyFile<M, B>, error: Error) => void
  20. 'thumbnail:request': (file: UppyFile<M, B>) => void
  21. 'thumbnail:cancel': (file: UppyFile<M, B>) => void
  22. }
  23. }
  24. interface Rotation {
  25. deg: number
  26. rad: number
  27. scaleX: number
  28. scaleY: number
  29. dimensionSwapped: boolean
  30. css: boolean
  31. canvas: boolean
  32. }
  33. /**
  34. * Save a <canvas> element's content to a Blob object.
  35. *
  36. */
  37. function canvasToBlob(
  38. canvas: HTMLCanvasElement,
  39. type: string,
  40. quality: number,
  41. ): Promise<Blob | File> {
  42. try {
  43. canvas.getContext('2d')!.getImageData(0, 0, 1, 1)
  44. } catch (err) {
  45. if (err.code === 18) {
  46. return Promise.reject(
  47. new Error('cannot read image, probably an svg with external resources'),
  48. )
  49. }
  50. }
  51. if (canvas.toBlob) {
  52. return new Promise<Blob | null>((resolve) => {
  53. canvas.toBlob(resolve, type, quality)
  54. }).then((blob) => {
  55. if (blob === null) {
  56. throw new Error(
  57. 'cannot read image, probably an svg with external resources',
  58. )
  59. }
  60. return blob
  61. })
  62. }
  63. return Promise.resolve()
  64. .then(() => {
  65. return dataURItoBlob(canvas.toDataURL(type, quality), {})
  66. })
  67. .then((blob) => {
  68. if (blob === null) {
  69. throw new Error('could not extract blob, probably an old browser')
  70. }
  71. return blob
  72. })
  73. }
  74. function rotateImage(image: HTMLImageElement, translate: Rotation) {
  75. let w = image.width
  76. let h = image.height
  77. if (translate.deg === 90 || translate.deg === 270) {
  78. w = image.height
  79. h = image.width
  80. }
  81. const canvas = document.createElement('canvas')
  82. canvas.width = w
  83. canvas.height = h
  84. const context = canvas.getContext('2d')!
  85. context.translate(w / 2, h / 2)
  86. if (translate.canvas) {
  87. context.rotate(translate.rad)
  88. context.scale(translate.scaleX, translate.scaleY)
  89. }
  90. context.drawImage(
  91. image,
  92. -image.width / 2,
  93. -image.height / 2,
  94. image.width,
  95. image.height,
  96. )
  97. return canvas
  98. }
  99. /**
  100. * Make sure the image doesn’t exceed browser/device canvas limits.
  101. * For ios with 256 RAM and ie
  102. */
  103. function protect(image: HTMLCanvasElement): HTMLCanvasElement {
  104. // https://stackoverflow.com/questions/6081483/maximum-size-of-a-canvas-element
  105. const ratio = image.width / image.height
  106. const maxSquare = 5000000 // ios max canvas square
  107. const maxSize = 4096 // ie max canvas dimensions
  108. let maxW = Math.floor(Math.sqrt(maxSquare * ratio))
  109. let maxH = Math.floor(maxSquare / Math.sqrt(maxSquare * ratio))
  110. if (maxW > maxSize) {
  111. maxW = maxSize
  112. maxH = Math.round(maxW / ratio)
  113. }
  114. if (maxH > maxSize) {
  115. maxH = maxSize
  116. maxW = Math.round(ratio * maxH)
  117. }
  118. if (image.width > maxW) {
  119. const canvas = document.createElement('canvas')
  120. canvas.width = maxW
  121. canvas.height = maxH
  122. canvas.getContext('2d')!.drawImage(image, 0, 0, maxW, maxH)
  123. return canvas
  124. }
  125. return image
  126. }
  127. export interface ThumbnailGeneratorOptions extends UIPluginOptions {
  128. thumbnailWidth?: number | null
  129. thumbnailHeight?: number | null
  130. thumbnailType?: string
  131. waitForThumbnailsBeforeUpload?: boolean
  132. lazy?: boolean
  133. }
  134. const defaultOptions = {
  135. thumbnailWidth: null,
  136. thumbnailHeight: null,
  137. thumbnailType: 'image/jpeg',
  138. waitForThumbnailsBeforeUpload: false,
  139. lazy: false,
  140. }
  141. type Opts = DefinePluginOpts<
  142. ThumbnailGeneratorOptions,
  143. keyof typeof defaultOptions
  144. >
  145. /**
  146. * The Thumbnail Generator plugin
  147. */
  148. export default class ThumbnailGenerator<
  149. M extends Meta,
  150. B extends Body,
  151. > extends UIPlugin<Opts, M, B> {
  152. static VERSION = packageJson.version
  153. queue: string[]
  154. queueProcessing: boolean
  155. defaultThumbnailDimension: number
  156. thumbnailType: string
  157. constructor(uppy: Uppy<M, B>, opts?: ThumbnailGeneratorOptions) {
  158. super(uppy, { ...defaultOptions, ...opts })
  159. this.type = 'modifier'
  160. this.id = this.opts.id || 'ThumbnailGenerator'
  161. this.title = 'Thumbnail Generator'
  162. this.queue = []
  163. this.queueProcessing = false
  164. this.defaultThumbnailDimension = 200
  165. this.thumbnailType = this.opts.thumbnailType
  166. this.defaultLocale = locale
  167. this.i18nInit()
  168. if (this.opts.lazy && this.opts.waitForThumbnailsBeforeUpload) {
  169. throw new Error(
  170. 'ThumbnailGenerator: The `lazy` and `waitForThumbnailsBeforeUpload` options are mutually exclusive. Please ensure at most one of them is set to `true`.',
  171. )
  172. }
  173. }
  174. createThumbnail(
  175. file: UppyFile<M, B>,
  176. targetWidth: number | null,
  177. targetHeight: number | null,
  178. ): Promise<string> {
  179. const originalUrl = URL.createObjectURL(file.data)
  180. const onload = new Promise<HTMLImageElement>((resolve, reject) => {
  181. const image = new Image()
  182. image.src = originalUrl
  183. image.addEventListener('load', () => {
  184. URL.revokeObjectURL(originalUrl)
  185. resolve(image)
  186. })
  187. image.addEventListener('error', (event) => {
  188. URL.revokeObjectURL(originalUrl)
  189. reject(event.error || new Error('Could not create thumbnail'))
  190. })
  191. })
  192. const orientationPromise = rotation(file.data).catch(
  193. () => 1,
  194. ) as Promise<Rotation>
  195. return Promise.all([onload, orientationPromise])
  196. .then(([image, orientation]) => {
  197. const dimensions = this.getProportionalDimensions(
  198. image,
  199. targetWidth,
  200. targetHeight,
  201. orientation.deg,
  202. )
  203. const rotatedImage = rotateImage(image, orientation)
  204. const resizedImage = this.resizeImage(
  205. rotatedImage,
  206. dimensions.width,
  207. dimensions.height,
  208. )
  209. return canvasToBlob(resizedImage, this.thumbnailType, 80)
  210. })
  211. .then((blob) => {
  212. return URL.createObjectURL(blob)
  213. })
  214. }
  215. /**
  216. * Get the new calculated dimensions for the given image and a target width
  217. * or height. If both width and height are given, only width is taken into
  218. * account. If neither width nor height are given, the default dimension
  219. * is used.
  220. */
  221. getProportionalDimensions(
  222. img: HTMLImageElement,
  223. width: number | null,
  224. height: number | null,
  225. deg: number,
  226. ): { width: number; height: number } {
  227. // eslint-disable-line no-shadow
  228. let aspect = img.width / img.height
  229. if (deg === 90 || deg === 270) {
  230. aspect = img.height / img.width
  231. }
  232. if (width != null) {
  233. return {
  234. width,
  235. height: Math.round(width / aspect),
  236. }
  237. }
  238. if (height != null) {
  239. return {
  240. width: Math.round(height * aspect),
  241. height,
  242. }
  243. }
  244. return {
  245. width: this.defaultThumbnailDimension,
  246. height: Math.round(this.defaultThumbnailDimension / aspect),
  247. }
  248. }
  249. /**
  250. * Resize an image to the target `width` and `height`.
  251. *
  252. * Returns a Canvas with the resized image on it.
  253. */
  254. // eslint-disable-next-line class-methods-use-this
  255. resizeImage(
  256. image: HTMLCanvasElement,
  257. targetWidth: number,
  258. targetHeight: number,
  259. ): HTMLCanvasElement {
  260. // Resizing in steps refactored to use a solution from
  261. // https://blog.uploadcare.com/image-resize-in-browsers-is-broken-e38eed08df01
  262. let img = protect(image)
  263. let steps = Math.ceil(Math.log2(img.width / targetWidth))
  264. if (steps < 1) {
  265. steps = 1
  266. }
  267. let sW = targetWidth * 2 ** (steps - 1)
  268. let sH = targetHeight * 2 ** (steps - 1)
  269. const x = 2
  270. while (steps--) {
  271. const canvas = document.createElement('canvas')
  272. canvas.width = sW
  273. canvas.height = sH
  274. canvas.getContext('2d')!.drawImage(img, 0, 0, sW, sH)
  275. img = canvas
  276. sW = Math.round(sW / x)
  277. sH = Math.round(sH / x)
  278. }
  279. return img
  280. }
  281. /**
  282. * Set the preview URL for a file.
  283. */
  284. setPreviewURL(fileID: string, preview: string): void {
  285. this.uppy.setFileState(fileID, { preview })
  286. }
  287. addToQueue(fileID: string): void {
  288. this.queue.push(fileID)
  289. if (this.queueProcessing === false) {
  290. this.processQueue()
  291. }
  292. }
  293. processQueue(): Promise<void> {
  294. this.queueProcessing = true
  295. if (this.queue.length > 0) {
  296. const current = this.uppy.getFile(this.queue.shift() as string)
  297. if (!current) {
  298. this.uppy.log(
  299. '[ThumbnailGenerator] file was removed before a thumbnail could be generated, but not removed from the queue. This is probably a bug',
  300. 'error',
  301. )
  302. return Promise.resolve()
  303. }
  304. return this.requestThumbnail(current)
  305. .catch(() => {}) // eslint-disable-line node/handle-callback-err
  306. .then(() => this.processQueue())
  307. }
  308. this.queueProcessing = false
  309. this.uppy.log('[ThumbnailGenerator] Emptied thumbnail queue')
  310. this.uppy.emit('thumbnail:all-generated')
  311. return Promise.resolve()
  312. }
  313. requestThumbnail(file: UppyFile<M, B>): Promise<void> {
  314. if (isPreviewSupported(file.type) && !file.isRemote) {
  315. return this.createThumbnail(
  316. file,
  317. this.opts.thumbnailWidth,
  318. this.opts.thumbnailHeight,
  319. )
  320. .then((preview) => {
  321. this.setPreviewURL(file.id, preview)
  322. this.uppy.log(
  323. `[ThumbnailGenerator] Generated thumbnail for ${file.id}`,
  324. )
  325. this.uppy.emit(
  326. 'thumbnail:generated',
  327. this.uppy.getFile(file.id),
  328. preview,
  329. )
  330. })
  331. .catch((err) => {
  332. this.uppy.log(
  333. `[ThumbnailGenerator] Failed thumbnail for ${file.id}:`,
  334. 'warning',
  335. )
  336. this.uppy.log(err, 'warning')
  337. this.uppy.emit('thumbnail:error', this.uppy.getFile(file.id), err)
  338. })
  339. }
  340. return Promise.resolve()
  341. }
  342. onFileAdded = (file: UppyFile<M, B>): void => {
  343. if (
  344. !file.preview &&
  345. file.data &&
  346. isPreviewSupported(file.type) &&
  347. !file.isRemote
  348. ) {
  349. this.addToQueue(file.id)
  350. }
  351. }
  352. /**
  353. * Cancel a lazy request for a thumbnail if the thumbnail has not yet been generated.
  354. */
  355. onCancelRequest = (file: UppyFile<M, B>): void => {
  356. const index = this.queue.indexOf(file.id)
  357. if (index !== -1) {
  358. this.queue.splice(index, 1)
  359. }
  360. }
  361. /**
  362. * Clean up the thumbnail for a file. Cancel lazy requests and free the thumbnail URL.
  363. */
  364. onFileRemoved = (file: UppyFile<M, B>): void => {
  365. const index = this.queue.indexOf(file.id)
  366. if (index !== -1) {
  367. this.queue.splice(index, 1)
  368. }
  369. // Clean up object URLs.
  370. if (file.preview && isObjectURL(file.preview)) {
  371. URL.revokeObjectURL(file.preview)
  372. }
  373. }
  374. onRestored = (): void => {
  375. const restoredFiles = this.uppy.getFiles().filter((file) => file.isRestored)
  376. restoredFiles.forEach((file) => {
  377. // Only add blob URLs; they are likely invalid after being restored.
  378. if (!file.preview || isObjectURL(file.preview)) {
  379. this.addToQueue(file.id)
  380. }
  381. })
  382. }
  383. onAllFilesRemoved = (): void => {
  384. this.queue = []
  385. }
  386. waitUntilAllProcessed = (fileIDs: string[]): Promise<void> => {
  387. fileIDs.forEach((fileID) => {
  388. const file = this.uppy.getFile(fileID)
  389. this.uppy.emit('preprocess-progress', file, {
  390. mode: 'indeterminate',
  391. message: this.i18n('generatingThumbnails'),
  392. })
  393. })
  394. const emitPreprocessCompleteForAll = () => {
  395. fileIDs.forEach((fileID) => {
  396. const file = this.uppy.getFile(fileID)
  397. this.uppy.emit('preprocess-complete', file)
  398. })
  399. }
  400. return new Promise((resolve) => {
  401. if (this.queueProcessing) {
  402. this.uppy.once('thumbnail:all-generated', () => {
  403. emitPreprocessCompleteForAll()
  404. resolve()
  405. })
  406. } else {
  407. emitPreprocessCompleteForAll()
  408. resolve()
  409. }
  410. })
  411. }
  412. install(): void {
  413. this.uppy.on('file-removed', this.onFileRemoved)
  414. this.uppy.on('cancel-all', this.onAllFilesRemoved)
  415. if (this.opts.lazy) {
  416. this.uppy.on('thumbnail:request', this.onFileAdded)
  417. this.uppy.on('thumbnail:cancel', this.onCancelRequest)
  418. } else {
  419. this.uppy.on('thumbnail:request', this.onFileAdded)
  420. this.uppy.on('file-added', this.onFileAdded)
  421. this.uppy.on('restored', this.onRestored)
  422. }
  423. if (this.opts.waitForThumbnailsBeforeUpload) {
  424. this.uppy.addPreProcessor(this.waitUntilAllProcessed)
  425. }
  426. }
  427. uninstall(): void {
  428. this.uppy.off('file-removed', this.onFileRemoved)
  429. this.uppy.off('cancel-all', this.onAllFilesRemoved)
  430. if (this.opts.lazy) {
  431. this.uppy.off('thumbnail:request', this.onFileAdded)
  432. this.uppy.off('thumbnail:cancel', this.onCancelRequest)
  433. } else {
  434. this.uppy.off('thumbnail:request', this.onFileAdded)
  435. this.uppy.off('file-added', this.onFileAdded)
  436. this.uppy.off('restored', this.onRestored)
  437. }
  438. if (this.opts.waitForThumbnailsBeforeUpload) {
  439. this.uppy.removePreProcessor(this.waitUntilAllProcessed)
  440. }
  441. }
  442. }