123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503 |
- import { UIPlugin, Uppy, type UIPluginOptions } from '@uppy/core'
- import dataURItoBlob from '@uppy/utils/lib/dataURItoBlob'
- import isObjectURL from '@uppy/utils/lib/isObjectURL'
- import isPreviewSupported from '@uppy/utils/lib/isPreviewSupported'
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore untyped
- import { rotation } from 'exifr/dist/mini.esm.mjs'
- import type { DefinePluginOpts } from '@uppy/core/lib/BasePlugin'
- import type { Body, Meta, UppyFile } from '@uppy/utils/lib/UppyFile'
- import locale from './locale.ts'
- // 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'
- declare module '@uppy/core' {
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- export interface UppyEventMap<M extends Meta, B extends Body> {
- 'thumbnail:all-generated': () => void
- 'thumbnail:generated': (file: UppyFile<M, B>, preview: string) => void
- 'thumbnail:error': (file: UppyFile<M, B>, error: Error) => void
- 'thumbnail:request': (file: UppyFile<M, B>) => void
- 'thumbnail:cancel': (file: UppyFile<M, B>) => void
- }
- }
- interface Rotation {
- deg: number
- rad: number
- scaleX: number
- scaleY: number
- dimensionSwapped: boolean
- css: boolean
- canvas: boolean
- }
- /**
- * Save a <canvas> element's content to a Blob object.
- *
- */
- function canvasToBlob(
- canvas: HTMLCanvasElement,
- type: string,
- quality: number,
- ): Promise<Blob | File> {
- try {
- canvas.getContext('2d')!.getImageData(0, 0, 1, 1)
- } catch (err) {
- if (err.code === 18) {
- return Promise.reject(
- new Error('cannot read image, probably an svg with external resources'),
- )
- }
- }
- if (canvas.toBlob) {
- return new Promise<Blob | null>((resolve) => {
- canvas.toBlob(resolve, type, quality)
- }).then((blob) => {
- if (blob === null) {
- throw new Error(
- 'cannot read image, probably an svg with external resources',
- )
- }
- return blob
- })
- }
- return Promise.resolve()
- .then(() => {
- return dataURItoBlob(canvas.toDataURL(type, quality), {})
- })
- .then((blob) => {
- if (blob === null) {
- throw new Error('could not extract blob, probably an old browser')
- }
- return blob
- })
- }
- function rotateImage(image: HTMLImageElement, translate: Rotation) {
- let w = image.width
- let h = image.height
- if (translate.deg === 90 || translate.deg === 270) {
- w = image.height
- h = image.width
- }
- const canvas = document.createElement('canvas')
- canvas.width = w
- canvas.height = h
- const context = canvas.getContext('2d')!
- context.translate(w / 2, h / 2)
- if (translate.canvas) {
- context.rotate(translate.rad)
- context.scale(translate.scaleX, translate.scaleY)
- }
- context.drawImage(
- image,
- -image.width / 2,
- -image.height / 2,
- image.width,
- image.height,
- )
- return canvas
- }
- /**
- * Make sure the image doesn’t exceed browser/device canvas limits.
- * For ios with 256 RAM and ie
- */
- function protect(image: HTMLCanvasElement): HTMLCanvasElement {
- // https://stackoverflow.com/questions/6081483/maximum-size-of-a-canvas-element
- const ratio = image.width / image.height
- const maxSquare = 5000000 // ios max canvas square
- const maxSize = 4096 // ie max canvas dimensions
- let maxW = Math.floor(Math.sqrt(maxSquare * ratio))
- let maxH = Math.floor(maxSquare / Math.sqrt(maxSquare * ratio))
- if (maxW > maxSize) {
- maxW = maxSize
- maxH = Math.round(maxW / ratio)
- }
- if (maxH > maxSize) {
- maxH = maxSize
- maxW = Math.round(ratio * maxH)
- }
- if (image.width > maxW) {
- const canvas = document.createElement('canvas')
- canvas.width = maxW
- canvas.height = maxH
- canvas.getContext('2d')!.drawImage(image, 0, 0, maxW, maxH)
- return canvas
- }
- return image
- }
- export interface ThumbnailGeneratorOptions extends UIPluginOptions {
- thumbnailWidth?: number | null
- thumbnailHeight?: number | null
- thumbnailType?: string
- waitForThumbnailsBeforeUpload?: boolean
- lazy?: boolean
- }
- const defaultOptions = {
- thumbnailWidth: null,
- thumbnailHeight: null,
- thumbnailType: 'image/jpeg',
- waitForThumbnailsBeforeUpload: false,
- lazy: false,
- }
- type Opts = DefinePluginOpts<
- ThumbnailGeneratorOptions,
- keyof typeof defaultOptions
- >
- /**
- * The Thumbnail Generator plugin
- */
- export default class ThumbnailGenerator<
- M extends Meta,
- B extends Body,
- > extends UIPlugin<Opts, M, B> {
- static VERSION = packageJson.version
- queue: string[]
- queueProcessing: boolean
- defaultThumbnailDimension: number
- thumbnailType: string
- constructor(uppy: Uppy<M, B>, opts?: ThumbnailGeneratorOptions) {
- super(uppy, { ...defaultOptions, ...opts })
- this.type = 'modifier'
- this.id = this.opts.id || 'ThumbnailGenerator'
- this.title = 'Thumbnail Generator'
- this.queue = []
- this.queueProcessing = false
- this.defaultThumbnailDimension = 200
- this.thumbnailType = this.opts.thumbnailType
- this.defaultLocale = locale
- this.i18nInit()
- if (this.opts.lazy && this.opts.waitForThumbnailsBeforeUpload) {
- throw new Error(
- 'ThumbnailGenerator: The `lazy` and `waitForThumbnailsBeforeUpload` options are mutually exclusive. Please ensure at most one of them is set to `true`.',
- )
- }
- }
- createThumbnail(
- file: UppyFile<M, B>,
- targetWidth: number | null,
- targetHeight: number | null,
- ): Promise<string> {
- const originalUrl = URL.createObjectURL(file.data)
- const onload = new Promise<HTMLImageElement>((resolve, reject) => {
- const image = new Image()
- image.src = originalUrl
- image.addEventListener('load', () => {
- URL.revokeObjectURL(originalUrl)
- resolve(image)
- })
- image.addEventListener('error', (event) => {
- URL.revokeObjectURL(originalUrl)
- reject(event.error || new Error('Could not create thumbnail'))
- })
- })
- const orientationPromise = rotation(file.data).catch(
- () => 1,
- ) as Promise<Rotation>
- return Promise.all([onload, orientationPromise])
- .then(([image, orientation]) => {
- const dimensions = this.getProportionalDimensions(
- image,
- targetWidth,
- targetHeight,
- orientation.deg,
- )
- const rotatedImage = rotateImage(image, orientation)
- const resizedImage = this.resizeImage(
- rotatedImage,
- dimensions.width,
- dimensions.height,
- )
- return canvasToBlob(resizedImage, this.thumbnailType, 80)
- })
- .then((blob) => {
- return URL.createObjectURL(blob)
- })
- }
- /**
- * Get the new calculated dimensions for the given image and a target width
- * or height. If both width and height are given, only width is taken into
- * account. If neither width nor height are given, the default dimension
- * is used.
- */
- getProportionalDimensions(
- img: HTMLImageElement,
- width: number | null,
- height: number | null,
- deg: number,
- ): { width: number; height: number } {
- // eslint-disable-line no-shadow
- let aspect = img.width / img.height
- if (deg === 90 || deg === 270) {
- aspect = img.height / img.width
- }
- if (width != null) {
- return {
- width,
- height: Math.round(width / aspect),
- }
- }
- if (height != null) {
- return {
- width: Math.round(height * aspect),
- height,
- }
- }
- return {
- width: this.defaultThumbnailDimension,
- height: Math.round(this.defaultThumbnailDimension / aspect),
- }
- }
- /**
- * Resize an image to the target `width` and `height`.
- *
- * Returns a Canvas with the resized image on it.
- */
- // eslint-disable-next-line class-methods-use-this
- resizeImage(
- image: HTMLCanvasElement,
- targetWidth: number,
- targetHeight: number,
- ): HTMLCanvasElement {
- // Resizing in steps refactored to use a solution from
- // https://blog.uploadcare.com/image-resize-in-browsers-is-broken-e38eed08df01
- let img = protect(image)
- let steps = Math.ceil(Math.log2(img.width / targetWidth))
- if (steps < 1) {
- steps = 1
- }
- let sW = targetWidth * 2 ** (steps - 1)
- let sH = targetHeight * 2 ** (steps - 1)
- const x = 2
- while (steps--) {
- const canvas = document.createElement('canvas')
- canvas.width = sW
- canvas.height = sH
- canvas.getContext('2d')!.drawImage(img, 0, 0, sW, sH)
- img = canvas
- sW = Math.round(sW / x)
- sH = Math.round(sH / x)
- }
- return img
- }
- /**
- * Set the preview URL for a file.
- */
- setPreviewURL(fileID: string, preview: string): void {
- this.uppy.setFileState(fileID, { preview })
- }
- addToQueue(fileID: string): void {
- this.queue.push(fileID)
- if (this.queueProcessing === false) {
- this.processQueue()
- }
- }
- processQueue(): Promise<void> {
- this.queueProcessing = true
- if (this.queue.length > 0) {
- const current = this.uppy.getFile(this.queue.shift() as string)
- if (!current) {
- this.uppy.log(
- '[ThumbnailGenerator] file was removed before a thumbnail could be generated, but not removed from the queue. This is probably a bug',
- 'error',
- )
- return Promise.resolve()
- }
- return this.requestThumbnail(current)
- .catch(() => {}) // eslint-disable-line node/handle-callback-err
- .then(() => this.processQueue())
- }
- this.queueProcessing = false
- this.uppy.log('[ThumbnailGenerator] Emptied thumbnail queue')
- this.uppy.emit('thumbnail:all-generated')
- return Promise.resolve()
- }
- requestThumbnail(file: UppyFile<M, B>): Promise<void> {
- if (isPreviewSupported(file.type) && !file.isRemote) {
- return this.createThumbnail(
- file,
- this.opts.thumbnailWidth,
- this.opts.thumbnailHeight,
- )
- .then((preview) => {
- this.setPreviewURL(file.id, preview)
- this.uppy.log(
- `[ThumbnailGenerator] Generated thumbnail for ${file.id}`,
- )
- this.uppy.emit(
- 'thumbnail:generated',
- this.uppy.getFile(file.id),
- preview,
- )
- })
- .catch((err) => {
- this.uppy.log(
- `[ThumbnailGenerator] Failed thumbnail for ${file.id}:`,
- 'warning',
- )
- this.uppy.log(err, 'warning')
- this.uppy.emit('thumbnail:error', this.uppy.getFile(file.id), err)
- })
- }
- return Promise.resolve()
- }
- onFileAdded = (file: UppyFile<M, B>): void => {
- if (
- !file.preview &&
- file.data &&
- isPreviewSupported(file.type) &&
- !file.isRemote
- ) {
- this.addToQueue(file.id)
- }
- }
- /**
- * Cancel a lazy request for a thumbnail if the thumbnail has not yet been generated.
- */
- onCancelRequest = (file: UppyFile<M, B>): void => {
- const index = this.queue.indexOf(file.id)
- if (index !== -1) {
- this.queue.splice(index, 1)
- }
- }
- /**
- * Clean up the thumbnail for a file. Cancel lazy requests and free the thumbnail URL.
- */
- onFileRemoved = (file: UppyFile<M, B>): void => {
- const index = this.queue.indexOf(file.id)
- if (index !== -1) {
- this.queue.splice(index, 1)
- }
- // Clean up object URLs.
- if (file.preview && isObjectURL(file.preview)) {
- URL.revokeObjectURL(file.preview)
- }
- }
- onRestored = (): void => {
- const restoredFiles = this.uppy.getFiles().filter((file) => file.isRestored)
- restoredFiles.forEach((file) => {
- // Only add blob URLs; they are likely invalid after being restored.
- if (!file.preview || isObjectURL(file.preview)) {
- this.addToQueue(file.id)
- }
- })
- }
- onAllFilesRemoved = (): void => {
- this.queue = []
- }
- waitUntilAllProcessed = (fileIDs: string[]): Promise<void> => {
- fileIDs.forEach((fileID) => {
- const file = this.uppy.getFile(fileID)
- this.uppy.emit('preprocess-progress', file, {
- mode: 'indeterminate',
- message: this.i18n('generatingThumbnails'),
- })
- })
- const emitPreprocessCompleteForAll = () => {
- fileIDs.forEach((fileID) => {
- const file = this.uppy.getFile(fileID)
- this.uppy.emit('preprocess-complete', file)
- })
- }
- return new Promise((resolve) => {
- if (this.queueProcessing) {
- this.uppy.once('thumbnail:all-generated', () => {
- emitPreprocessCompleteForAll()
- resolve()
- })
- } else {
- emitPreprocessCompleteForAll()
- resolve()
- }
- })
- }
- install(): void {
- this.uppy.on('file-removed', this.onFileRemoved)
- this.uppy.on('cancel-all', this.onAllFilesRemoved)
- if (this.opts.lazy) {
- this.uppy.on('thumbnail:request', this.onFileAdded)
- this.uppy.on('thumbnail:cancel', this.onCancelRequest)
- } else {
- this.uppy.on('thumbnail:request', this.onFileAdded)
- this.uppy.on('file-added', this.onFileAdded)
- this.uppy.on('restored', this.onRestored)
- }
- if (this.opts.waitForThumbnailsBeforeUpload) {
- this.uppy.addPreProcessor(this.waitUntilAllProcessed)
- }
- }
- uninstall(): void {
- this.uppy.off('file-removed', this.onFileRemoved)
- this.uppy.off('cancel-all', this.onAllFilesRemoved)
- if (this.opts.lazy) {
- this.uppy.off('thumbnail:request', this.onFileAdded)
- this.uppy.off('thumbnail:cancel', this.onCancelRequest)
- } else {
- this.uppy.off('thumbnail:request', this.onFileAdded)
- this.uppy.off('file-added', this.onFileAdded)
- this.uppy.off('restored', this.onRestored)
- }
- if (this.opts.waitForThumbnailsBeforeUpload) {
- this.uppy.removePreProcessor(this.waitUntilAllProcessed)
- }
- }
- }
|