1 | const { Plugin } = require('@uppy/core')
|
2 | const Translator = require('@uppy/utils/lib/Translator')
|
3 | const dataURItoBlob = require('@uppy/utils/lib/dataURItoBlob')
|
4 | const isObjectURL = require('@uppy/utils/lib/isObjectURL')
|
5 | const isPreviewSupported = require('@uppy/utils/lib/isPreviewSupported')
|
6 | const MathLog2 = require('math-log2')
|
7 | const exifr = require('exifr/dist/mini.legacy.umd.js')
|
8 |
|
9 |
|
10 |
|
11 |
|
12 |
|
13 | module.exports = class ThumbnailGenerator extends Plugin {
|
14 | static VERSION = require('../package.json').version
|
15 |
|
16 | constructor (uppy, opts) {
|
17 | super(uppy, opts)
|
18 | this.type = 'modifier'
|
19 | this.id = this.opts.id || 'ThumbnailGenerator'
|
20 | this.title = 'Thumbnail Generator'
|
21 | this.queue = []
|
22 | this.queueProcessing = false
|
23 | this.defaultThumbnailDimension = 200
|
24 | this.thumbnailType = this.opts.thumbnailType || 'image/jpeg'
|
25 |
|
26 | this.defaultLocale = {
|
27 | strings: {
|
28 | generatingThumbnails: 'Generating thumbnails...'
|
29 | }
|
30 | }
|
31 |
|
32 | const defaultOptions = {
|
33 | thumbnailWidth: null,
|
34 | thumbnailHeight: null,
|
35 | waitForThumbnailsBeforeUpload: false,
|
36 | lazy: false
|
37 | }
|
38 |
|
39 | this.opts = { ...defaultOptions, ...opts }
|
40 |
|
41 | if (this.opts.lazy && this.opts.waitForThumbnailsBeforeUpload) {
|
42 | throw new Error('ThumbnailGenerator: The `lazy` and `waitForThumbnailsBeforeUpload` options are mutually exclusive. Please ensure at most one of them is set to `true`.')
|
43 | }
|
44 |
|
45 | this.i18nInit()
|
46 | }
|
47 |
|
48 | setOptions (newOpts) {
|
49 | super.setOptions(newOpts)
|
50 | this.i18nInit()
|
51 | }
|
52 |
|
53 | i18nInit () {
|
54 | this.translator = new Translator([this.defaultLocale, this.uppy.locale, this.opts.locale])
|
55 | this.i18n = this.translator.translate.bind(this.translator)
|
56 | this.setPluginState()
|
57 | }
|
58 |
|
59 | |
60 |
|
61 |
|
62 |
|
63 |
|
64 |
|
65 |
|
66 |
|
67 | createThumbnail (file, targetWidth, targetHeight) {
|
68 |
|
69 |
|
70 | const originalUrl = URL.createObjectURL(file.data)
|
71 |
|
72 | const onload = new Promise((resolve, reject) => {
|
73 | const image = new Image()
|
74 | image.src = originalUrl
|
75 | image.addEventListener('load', () => {
|
76 |
|
77 |
|
78 | URL.revokeObjectURL(originalUrl)
|
79 | resolve(image)
|
80 | })
|
81 | image.addEventListener('error', (event) => {
|
82 |
|
83 |
|
84 | URL.revokeObjectURL(originalUrl)
|
85 | reject(event.error || new Error('Could not create thumbnail'))
|
86 | })
|
87 | })
|
88 |
|
89 | const orientationPromise = exifr.rotation(file.data).catch(_err => 1)
|
90 |
|
91 | return Promise.all([onload, orientationPromise])
|
92 | .then(([image, orientation]) => {
|
93 | const dimensions = this.getProportionalDimensions(image, targetWidth, targetHeight, orientation.deg)
|
94 | const rotatedImage = this.rotateImage(image, orientation)
|
95 | const resizedImage = this.resizeImage(rotatedImage, dimensions.width, dimensions.height)
|
96 | return this.canvasToBlob(resizedImage, this.thumbnailType, 80)
|
97 | })
|
98 | .then(blob => {
|
99 |
|
100 |
|
101 | return URL.createObjectURL(blob)
|
102 | })
|
103 | }
|
104 |
|
105 | |
106 |
|
107 |
|
108 |
|
109 |
|
110 |
|
111 | getProportionalDimensions (img, width, height, rotation) {
|
112 | var aspect = img.width / img.height
|
113 | if (rotation === 90 || rotation === 270) {
|
114 | aspect = img.height / img.width
|
115 | }
|
116 |
|
117 | if (width != null) {
|
118 | return {
|
119 | width: width,
|
120 | height: Math.round(width / aspect)
|
121 | }
|
122 | }
|
123 |
|
124 | if (height != null) {
|
125 | return {
|
126 | width: Math.round(height * aspect),
|
127 | height: height
|
128 | }
|
129 | }
|
130 |
|
131 | return {
|
132 | width: this.defaultThumbnailDimension,
|
133 | height: Math.round(this.defaultThumbnailDimension / aspect)
|
134 | }
|
135 | }
|
136 |
|
137 | |
138 |
|
139 |
|
140 |
|
141 | protect (image) {
|
142 |
|
143 |
|
144 | var ratio = image.width / image.height
|
145 |
|
146 | var maxSquare = 5000000
|
147 | var maxSize = 4096
|
148 |
|
149 | var maxW = Math.floor(Math.sqrt(maxSquare * ratio))
|
150 | var maxH = Math.floor(maxSquare / Math.sqrt(maxSquare * ratio))
|
151 | if (maxW > maxSize) {
|
152 | maxW = maxSize
|
153 | maxH = Math.round(maxW / ratio)
|
154 | }
|
155 | if (maxH > maxSize) {
|
156 | maxH = maxSize
|
157 | maxW = Math.round(ratio * maxH)
|
158 | }
|
159 | if (image.width > maxW) {
|
160 | var canvas = document.createElement('canvas')
|
161 | canvas.width = maxW
|
162 | canvas.height = maxH
|
163 | canvas.getContext('2d').drawImage(image, 0, 0, maxW, maxH)
|
164 | image = canvas
|
165 | }
|
166 |
|
167 | return image
|
168 | }
|
169 |
|
170 | |
171 |
|
172 |
|
173 |
|
174 |
|
175 | resizeImage (image, targetWidth, targetHeight) {
|
176 |
|
177 |
|
178 |
|
179 | image = this.protect(image)
|
180 |
|
181 | var steps = Math.ceil(MathLog2(image.width / targetWidth))
|
182 | if (steps < 1) {
|
183 | steps = 1
|
184 | }
|
185 | var sW = targetWidth * Math.pow(2, steps - 1)
|
186 | var sH = targetHeight * Math.pow(2, steps - 1)
|
187 | var x = 2
|
188 |
|
189 | while (steps--) {
|
190 | var canvas = document.createElement('canvas')
|
191 | canvas.width = sW
|
192 | canvas.height = sH
|
193 | canvas.getContext('2d').drawImage(image, 0, 0, sW, sH)
|
194 | image = canvas
|
195 |
|
196 | sW = Math.round(sW / x)
|
197 | sH = Math.round(sH / x)
|
198 | }
|
199 |
|
200 | return image
|
201 | }
|
202 |
|
203 | rotateImage (image, translate) {
|
204 | var w = image.width
|
205 | var h = image.height
|
206 |
|
207 | if (translate.deg === 90 || translate.deg === 270) {
|
208 | w = image.height
|
209 | h = image.width
|
210 | }
|
211 |
|
212 | var canvas = document.createElement('canvas')
|
213 | canvas.width = w
|
214 | canvas.height = h
|
215 |
|
216 | var context = canvas.getContext('2d')
|
217 | context.translate(w / 2, h / 2)
|
218 | if (translate.canvas) {
|
219 | context.rotate(translate.rad)
|
220 | context.scale(translate.scaleX, translate.scaleY)
|
221 | }
|
222 | context.drawImage(image, -image.width / 2, -image.height / 2, image.width, image.height)
|
223 |
|
224 | return canvas
|
225 | }
|
226 |
|
227 | |
228 |
|
229 |
|
230 |
|
231 |
|
232 |
|
233 | canvasToBlob (canvas, type, quality) {
|
234 | try {
|
235 | canvas.getContext('2d').getImageData(0, 0, 1, 1)
|
236 | } catch (err) {
|
237 | if (err.code === 18) {
|
238 | return Promise.reject(new Error('cannot read image, probably an svg with external resources'))
|
239 | }
|
240 | }
|
241 |
|
242 | if (canvas.toBlob) {
|
243 | return new Promise(resolve => {
|
244 | canvas.toBlob(resolve, type, quality)
|
245 | }).then((blob) => {
|
246 | if (blob === null) {
|
247 | throw new Error('cannot read image, probably an svg with external resources')
|
248 | }
|
249 | return blob
|
250 | })
|
251 | }
|
252 | return Promise.resolve().then(() => {
|
253 | return dataURItoBlob(canvas.toDataURL(type, quality), {})
|
254 | }).then((blob) => {
|
255 | if (blob === null) {
|
256 | throw new Error('could not extract blob, probably an old browser')
|
257 | }
|
258 | return blob
|
259 | })
|
260 | }
|
261 |
|
262 | |
263 |
|
264 |
|
265 | setPreviewURL (fileID, preview) {
|
266 | this.uppy.setFileState(fileID, { preview })
|
267 | }
|
268 |
|
269 | addToQueue (item) {
|
270 | this.queue.push(item)
|
271 | if (this.queueProcessing === false) {
|
272 | this.processQueue()
|
273 | }
|
274 | }
|
275 |
|
276 | processQueue () {
|
277 | this.queueProcessing = true
|
278 | if (this.queue.length > 0) {
|
279 | const current = this.uppy.getFile(this.queue.shift())
|
280 | if (!current) {
|
281 | 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')
|
282 | return
|
283 | }
|
284 | return this.requestThumbnail(current)
|
285 | .catch(err => {})
|
286 | .then(() => this.processQueue())
|
287 | } else {
|
288 | this.queueProcessing = false
|
289 | this.uppy.log('[ThumbnailGenerator] Emptied thumbnail queue')
|
290 | this.uppy.emit('thumbnail:all-generated')
|
291 | }
|
292 | }
|
293 |
|
294 | requestThumbnail (file) {
|
295 | if (isPreviewSupported(file.type) && !file.isRemote) {
|
296 | return this.createThumbnail(file, this.opts.thumbnailWidth, this.opts.thumbnailHeight)
|
297 | .then(preview => {
|
298 | this.setPreviewURL(file.id, preview)
|
299 | this.uppy.log(`[ThumbnailGenerator] Generated thumbnail for ${file.id}`)
|
300 | this.uppy.emit('thumbnail:generated', this.uppy.getFile(file.id), preview)
|
301 | })
|
302 | .catch(err => {
|
303 | this.uppy.log(`[ThumbnailGenerator] Failed thumbnail for ${file.id}:`, 'warning')
|
304 | this.uppy.log(err, 'warning')
|
305 | this.uppy.emit('thumbnail:error', this.uppy.getFile(file.id), err)
|
306 | })
|
307 | }
|
308 | return Promise.resolve()
|
309 | }
|
310 |
|
311 | onFileAdded = (file) => {
|
312 | if (!file.preview && isPreviewSupported(file.type) && !file.isRemote) {
|
313 | this.addToQueue(file.id)
|
314 | }
|
315 | }
|
316 |
|
317 | |
318 |
|
319 |
|
320 | onCancelRequest = (file) => {
|
321 | const index = this.queue.indexOf(file.id)
|
322 | if (index !== -1) {
|
323 | this.queue.splice(index, 1)
|
324 | }
|
325 | }
|
326 |
|
327 | |
328 |
|
329 |
|
330 | onFileRemoved = (file) => {
|
331 | const index = this.queue.indexOf(file.id)
|
332 | if (index !== -1) {
|
333 | this.queue.splice(index, 1)
|
334 | }
|
335 |
|
336 |
|
337 | if (file.preview && isObjectURL(file.preview)) {
|
338 | URL.revokeObjectURL(file.preview)
|
339 | }
|
340 | }
|
341 |
|
342 | onRestored = () => {
|
343 | const { files } = this.uppy.getState()
|
344 | const fileIDs = Object.keys(files)
|
345 | fileIDs.forEach((fileID) => {
|
346 | const file = this.uppy.getFile(fileID)
|
347 | if (!file.isRestored) return
|
348 |
|
349 | if (!file.preview || isObjectURL(file.preview)) {
|
350 | this.addToQueue(file.id)
|
351 | }
|
352 | })
|
353 | }
|
354 |
|
355 | waitUntilAllProcessed = (fileIDs) => {
|
356 | fileIDs.forEach((fileID) => {
|
357 | const file = this.uppy.getFile(fileID)
|
358 | this.uppy.emit('preprocess-progress', file, {
|
359 | mode: 'indeterminate',
|
360 | message: this.i18n('generatingThumbnails')
|
361 | })
|
362 | })
|
363 |
|
364 | const emitPreprocessCompleteForAll = () => {
|
365 | fileIDs.forEach((fileID) => {
|
366 | const file = this.uppy.getFile(fileID)
|
367 | this.uppy.emit('preprocess-complete', file)
|
368 | })
|
369 | }
|
370 |
|
371 | return new Promise((resolve, reject) => {
|
372 | if (this.queueProcessing) {
|
373 | this.uppy.once('thumbnail:all-generated', () => {
|
374 | emitPreprocessCompleteForAll()
|
375 | resolve()
|
376 | })
|
377 | } else {
|
378 | emitPreprocessCompleteForAll()
|
379 | resolve()
|
380 | }
|
381 | })
|
382 | }
|
383 |
|
384 | install () {
|
385 | this.uppy.on('file-removed', this.onFileRemoved)
|
386 | if (this.opts.lazy) {
|
387 | this.uppy.on('thumbnail:request', this.onFileAdded)
|
388 | this.uppy.on('thumbnail:cancel', this.onCancelRequest)
|
389 | } else {
|
390 | this.uppy.on('file-added', this.onFileAdded)
|
391 | this.uppy.on('restored', this.onRestored)
|
392 | }
|
393 |
|
394 | if (this.opts.waitForThumbnailsBeforeUpload) {
|
395 | this.uppy.addPreProcessor(this.waitUntilAllProcessed)
|
396 | }
|
397 | }
|
398 |
|
399 | uninstall () {
|
400 | this.uppy.off('file-removed', this.onFileRemoved)
|
401 | if (this.opts.lazy) {
|
402 | this.uppy.off('thumbnail:request', this.onFileAdded)
|
403 | this.uppy.off('thumbnail:cancel', this.onCancelRequest)
|
404 | } else {
|
405 | this.uppy.off('file-added', this.onFileAdded)
|
406 | this.uppy.off('restored', this.onRestored)
|
407 | }
|
408 |
|
409 | if (this.opts.waitForThumbnailsBeforeUpload) {
|
410 | this.uppy.removePreProcessor(this.waitUntilAllProcessed)
|
411 | }
|
412 | }
|
413 | }
|