UNPKG

@uppy/thumbnail-generator

Version:

Uppy plugin that generates small previews of images to show on your upload UI.

503 lines (441 loc) 13.8 kB
import type { DefinePluginOpts, UIPluginOptions, Uppy } from '@uppy/core' import { UIPlugin } from '@uppy/core' import type { Body, LocalUppyFile, Meta, UppyFile } from '@uppy/utils' import { dataURItoBlob, isObjectURL, isPreviewSupported } from '@uppy/utils' // @ts-ignore untyped import { rotation } from 'exifr/dist/mini.esm.mjs' import packageJson from '../package.json' with { type: 'json' } import locale from './locale.js' declare module '@uppy/core' { 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: LocalUppyFile<M, B>, targetWidth: number | null, targetHeight: number | null, ): Promise<string> { if (file.data == null) throw new Error('File data is empty') 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 } { let aspect = img.width / img.height if (deg === 90 || deg === 270) { aspect = img.height / img.width } if (width != null) { let targetWidth = width // Thumbnail shouldn’t be enlarged / upscaled, only reduced. // If img is already smaller than width/height, leave it as is. if (img.width < width) targetWidth = img.width return { width: targetWidth, height: Math.round(targetWidth / aspect), } } if (height != null) { let targetHeight = height if (img.height < height) targetHeight = img.height return { width: Math.round(targetHeight * aspect), height: targetHeight, } } 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. */ 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(() => {}) .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) } } }