Przeglądaj źródła

Merge pull request #275 from goto-bus-stop/thumbnails-with-canvas

core: Resize thumbnails using <canvas>
Artur Paikin 7 lat temu
rodzic
commit
e98c914b7d
3 zmienionych plików z 170 dodań i 14 usunięć
  1. 39 4
      src/core/Core.js
  2. 129 6
      src/core/Utils.js
  3. 2 4
      src/plugins/RestoreFiles/index.js

+ 39 - 4
src/core/Core.js

@@ -299,10 +299,6 @@ class Uppy {
           preview: file.preview
           preview: file.preview
         }
         }
 
 
-        if (Utils.isPreviewSupported(fileTypeSpecific) && !isRemote) {
-          newFile.preview = Utils.getThumbnail(file.data)
-        }
-
         const isFileAllowed = this.checkRestrictions(false, newFile, fileType)
         const isFileAllowed = this.checkRestrictions(false, newFile, fileType)
         if (!isFileAllowed) return Promise.reject('File not allowed')
         if (!isFileAllowed) return Promise.reject('File not allowed')
 
 
@@ -333,12 +329,47 @@ class Uppy {
     return this.getState().files[fileID]
     return this.getState().files[fileID]
   }
   }
 
 
+  /**
+   * Generate a preview image for the given file, if possible.
+   */
+  generatePreview (file) {
+    if (Utils.isPreviewSupported(file.type.specific) && !file.isRemote) {
+      Utils.createThumbnail(file, 200).then((thumbnail) => {
+        this.setPreviewURL(file.id, thumbnail)
+      }).catch((err) => {
+        console.warn(err.stack || err.message)
+      })
+    }
+  }
+
+  /**
+   * Set the preview URL for a file.
+   */
+  setPreviewURL (fileID, preview) {
+    const { files } = this.state
+    this.setState({
+      files: Object.assign({}, files, {
+        [fileID]: Object.assign({}, files[fileID], {
+          preview: preview
+        })
+      })
+    })
+  }
+
   removeFile (fileID) {
   removeFile (fileID) {
     const updatedFiles = Object.assign({}, this.getState().files)
     const updatedFiles = Object.assign({}, this.getState().files)
+    const removedFile = updatedFiles[fileID]
     delete updatedFiles[fileID]
     delete updatedFiles[fileID]
+
     this.setState({files: updatedFiles})
     this.setState({files: updatedFiles})
     this.calculateTotalProgress()
     this.calculateTotalProgress()
     this.emit('core:file-removed', fileID)
     this.emit('core:file-removed', fileID)
+
+    // Clean up object URLs.
+    if (removedFile.preview && Utils.isObjectURL(removedFile.preview)) {
+      URL.revokeObjectURL(removedFile.preview)
+    }
+
     this.log(`Removed file: ${fileID}`)
     this.log(`Removed file: ${fileID}`)
   }
   }
 
 
@@ -419,6 +450,10 @@ class Uppy {
       this.addFile(data)
       this.addFile(data)
     })
     })
 
 
+    this.on('core:file-added', (file) => {
+      this.generatePreview(file)
+    })
+
     // `remove-file` removes a file from `state.files`, for example when
     // `remove-file` removes a file from `state.files`, for example when
     // a user decides not to upload particular file and clicks a button to remove it
     // a user decides not to upload particular file and clicks a button to remove it
     this.on('core:file-remove', (fileID) => {
     this.on('core:file-remove', (fileID) => {

+ 129 - 6
src/core/Utils.js

@@ -114,7 +114,6 @@ function toArray (list) {
  *
  *
  */
  */
 function generateFileID (file) {
 function generateFileID (file) {
-  console.log(file)
   let fileID = file.name.toLowerCase()
   let fileID = file.name.toLowerCase()
   fileID = fileID.replace(/[^A-Z0-9]/ig, '')
   fileID = fileID.replace(/[^A-Z0-9]/ig, '')
   fileID = fileID + file.data.lastModified
   fileID = fileID + file.data.lastModified
@@ -249,15 +248,138 @@ function getFileNameAndExtension (fullFileName) {
   return [fileName, fileExt]
   return [fileName, fileExt]
 }
 }
 
 
-function getThumbnail (fileData) {
-  return URL.createObjectURL(fileData)
-}
-
 function supportsMediaRecorder () {
 function supportsMediaRecorder () {
   return typeof MediaRecorder === 'function' && !!MediaRecorder.prototype &&
   return typeof MediaRecorder === 'function' && !!MediaRecorder.prototype &&
     typeof MediaRecorder.prototype.start === 'function'
     typeof MediaRecorder.prototype.start === 'function'
 }
 }
 
 
+/**
+ * Check if a URL string is an object URL from `URL.createObjectURL`.
+ *
+ * @param {string} url
+ * @return {boolean}
+ */
+function isObjectURL (url) {
+  return url.indexOf('blob:') === 0
+}
+
+function getProportionalHeight (img, width) {
+  const aspect = img.width / img.height
+  return Math.round(width / aspect)
+}
+
+/**
+ * Create a thumbnail for the given Uppy file object.
+ *
+ * @param {{data: Blob}} file
+ * @param {number} width
+ * @return {Promise}
+ */
+function createThumbnail (file, targetWidth) {
+  const originalUrl = URL.createObjectURL(file.data)
+
+  const onload = new Promise((resolve, reject) => {
+    const image = new Image()
+    image.src = originalUrl
+    image.onload = () => {
+      URL.revokeObjectURL(originalUrl)
+      resolve(image)
+    }
+    image.onerror = () => {
+      // The onerror event is totally useless unfortunately, as far as I know
+      URL.revokeObjectURL(originalUrl)
+      reject(new Error('Could not create thumbnail'))
+    }
+  })
+
+  return onload.then((image) => {
+    const targetHeight = getProportionalHeight(image, targetWidth)
+    const canvas = resizeImage(image, targetWidth, targetHeight)
+    return canvasToBlob(canvas, 'image/jpeg')
+  }).then((blob) => {
+    return URL.createObjectURL(blob)
+  })
+}
+
+/**
+ * Resize an image to the target `width` and `height`.
+ *
+ * Returns a Canvas with the resized image on it.
+ */
+function resizeImage (image, targetWidth, targetHeight) {
+  let sourceWidth = image.width
+  let sourceHeight = image.height
+
+  if (targetHeight < image.height / 2) {
+    const steps = Math.floor(Math.log(image.width / targetWidth) / Math.log(2))
+    const stepScaled = downScaleInSteps(image, steps)
+    image = stepScaled.image
+    sourceWidth = stepScaled.sourceWidth
+    sourceHeight = stepScaled.sourceHeight
+  }
+
+  const canvas = document.createElement('canvas')
+  canvas.width = targetWidth
+  canvas.height = targetHeight
+
+  const context = canvas.getContext('2d')
+  context.drawImage(image,
+    0, 0, sourceWidth, sourceHeight,
+    0, 0, targetWidth, targetHeight)
+
+  return canvas
+}
+
+/**
+ * Downscale an image by 50% `steps` times.
+ */
+function downScaleInSteps (image, steps) {
+  let source = image
+  let currentWidth = source.width
+  let currentHeight = source.height
+
+  const canvas = document.createElement('canvas')
+  const context = canvas.getContext('2d')
+  canvas.width = currentWidth / 2
+  canvas.height = currentHeight / 2
+
+  for (let i = 0; i < steps; i += 1) {
+    context.drawImage(source,
+      // The entire source image. We pass width and height here,
+      // because we reuse this canvas, and should only scale down
+      // the part of the canvas that contains the previous scale step.
+      0, 0, currentWidth, currentHeight,
+      // Draw to 50% size
+      0, 0, currentWidth / 2, currentHeight / 2)
+    currentWidth /= 2
+    currentHeight /= 2
+    source = canvas
+  }
+
+  return {
+    image: canvas,
+    sourceWidth: currentWidth,
+    sourceHeight: currentHeight
+  }
+}
+
+/**
+ * Save a <canvas> element's content to a Blob object.
+ *
+ * @param {HTMLCanvasElement} canvas
+ * @return {Promise}
+ */
+function canvasToBlob (canvas, type, quality) {
+  if (canvas.toBlob) {
+    return new Promise((resolve) => {
+      canvas.toBlob(resolve, type, quality)
+    })
+  }
+  return Promise.resolve().then(() => {
+    return dataURItoBlob(canvas.toDataURL(type, quality), {})
+  })
+}
+
 function dataURItoBlob (dataURI, opts, toFile) {
 function dataURItoBlob (dataURI, opts, toFile) {
   // get the base64 data
   // get the base64 data
   var data = dataURI.split(',')[1]
   var data = dataURI.split(',')[1]
@@ -442,7 +564,8 @@ module.exports = {
   getFileType,
   getFileType,
   getArrayBuffer,
   getArrayBuffer,
   isPreviewSupported,
   isPreviewSupported,
-  getThumbnail,
+  isObjectURL,
+  createThumbnail,
   secondsToTime,
   secondsToTime,
   dataURItoBlob,
   dataURItoBlob,
   dataURItoFile,
   dataURItoFile,

+ 2 - 4
src/plugins/RestoreFiles/index.js

@@ -1,5 +1,4 @@
 const Plugin = require('../Plugin')
 const Plugin = require('../Plugin')
-const Utils = require('../../core/Utils')
 const ServiceWorkerStore = require('./ServiceWorkerStore')
 const ServiceWorkerStore = require('./ServiceWorkerStore')
 const IndexedDBStore = require('./IndexedDBStore')
 const IndexedDBStore = require('./IndexedDBStore')
 
 
@@ -91,13 +90,12 @@ module.exports = class RestoreFiles extends Plugin {
         data: cachedData,
         data: cachedData,
         isRestored: true
         isRestored: true
       }
       }
-      if (this.core.state.files[fileID] && Utils.isPreviewSupported(this.core.state.files[fileID].type.specific)) {
-        updatedFileData.preview = Utils.getThumbnail(cachedData)
-      }
       const updatedFile = Object.assign({}, updatedFiles[fileID],
       const updatedFile = Object.assign({}, updatedFiles[fileID],
         Object.assign({}, updatedFileData)
         Object.assign({}, updatedFileData)
       )
       )
       updatedFiles[fileID] = updatedFile
       updatedFiles[fileID] = updatedFile
+
+      this.core.generatePreview(updatedFile)
     })
     })
     this.core.setState({
     this.core.setState({
       files: updatedFiles
       files: updatedFiles