Bladeren bron

Merge pull request #1440 from lakesare/improve-drag-to-upload-state

Improve drag to upload state
Artur Paikin 6 jaren geleden
bovenliggende
commit
cb27df2502

+ 10 - 1
packages/@uppy/dashboard/src/components/Dashboard.js

@@ -32,6 +32,7 @@ module.exports = function Dashboard (props) {
     { 'Uppy--isTouchDevice': isTouchDevice() },
     { 'uppy-Dashboard--animateOpenClose': props.animateOpenClose },
     { 'uppy-Dashboard--isClosing': props.isClosing },
+    { 'uppy-Dashboard--isDraggingOver': props.isDraggingOver },
     { 'uppy-Dashboard--modal': !props.inline },
     { 'uppy-size--md': props.containerWidth > 576 },
     { 'uppy-size--lg': props.containerWidth > 700 },
@@ -44,8 +45,12 @@ module.exports = function Dashboard (props) {
     <div class={dashboardClassName}
       aria-hidden={props.inline ? 'false' : props.isHidden}
       aria-label={!props.inline ? props.i18n('dashboardWindowTitle') : props.i18n('dashboardTitle')}
-      onpaste={props.handlePaste}>
+      onpaste={props.handlePaste}
 
+      onDragOver={props.handleDragOver}
+      onDragLeave={props.handleDragLeave}
+      onDrop={props.handleDrop}
+    >
       <div class="uppy-Dashboard-overlay" tabindex={-1} onclick={props.handleClickOutside} />
 
       <div class="uppy-Dashboard-inner"
@@ -68,6 +73,10 @@ module.exports = function Dashboard (props) {
           }
 
         <div class="uppy-Dashboard-innerWrap">
+          <div class="uppy-Dashboard-dropFilesHereHint">
+            {props.i18n('dropHint')}
+          </div>
+
           { (!noFiles && props.showSelectedFiles) && <PanelTopBar {...props} /> }
 
           { props.showSelectedFiles ? (

+ 101 - 63
packages/@uppy/dashboard/src/index.js

@@ -1,12 +1,12 @@
 const { Plugin } = require('@uppy/core')
 const Translator = require('@uppy/utils/lib/Translator')
-const dragDrop = require('drag-drop')
 const DashboardUI = require('./components/Dashboard')
 const StatusBar = require('@uppy/status-bar')
 const Informer = require('@uppy/informer')
 const ThumbnailGenerator = require('@uppy/thumbnail-generator')
 const findAllDOMElements = require('@uppy/utils/lib/findAllDOMElements')
 const toArray = require('@uppy/utils/lib/toArray')
+const getDroppedFiles = require('@uppy/utils/src/getDroppedFiles')
 const cuid = require('cuid')
 const ResizeObserver = require('resize-observer-polyfill').default || require('resize-observer-polyfill')
 const { defaultPickerIcon } = require('./components/icons')
@@ -76,6 +76,7 @@ module.exports = class Dashboard extends Plugin {
         myDevice: 'My Device',
         dropPasteImport: 'Drop files here, paste, %{browse} or import from',
         dropPaste: 'Drop files here, paste or %{browse}',
+        dropHint: 'Drop your files here',
         browse: 'browse',
         emptyFolderAdded: 'No files were added from empty folder',
         uploadComplete: 'Upload complete',
@@ -166,11 +167,18 @@ module.exports = class Dashboard extends Plugin {
     this.handleClickOutside = this.handleClickOutside.bind(this)
     this.toggleFileCard = this.toggleFileCard.bind(this)
     this.toggleAddFilesPanel = this.toggleAddFilesPanel.bind(this)
-    this.handleDrop = this.handleDrop.bind(this)
     this.handlePaste = this.handlePaste.bind(this)
     this.handleInputChange = this.handleInputChange.bind(this)
     this.render = this.render.bind(this)
     this.install = this.install.bind(this)
+
+    this.handleDragOver = this.handleDragOver.bind(this)
+    this.handleDragLeave = this.handleDragLeave.bind(this)
+    this.handleDrop = this.handleDrop.bind(this)
+
+    // Timeouts
+    this.makeDashboardInsidesVisibleAnywayTimeout = null
+    this.removeDragOverClassTimeout = null
   }
 
   removeTarget (plugin) {
@@ -418,49 +426,49 @@ module.exports = class Dashboard extends Plugin {
     if (this.opts.closeModalOnClickOutside) this.requestCloseModal()
   }
 
-  handlePaste (ev) {
-    const files = toArray(ev.clipboardData.items)
-    files.forEach((file) => {
-      if (file.kind !== 'file') return
-
-      const blob = file.getAsFile()
-      if (!blob) {
-        this.uppy.log('[Dashboard] File pasted, but the file blob is empty')
-        this.uppy.info('Error pasting file', 'error')
-        return
-      }
-      this.uppy.log('[Dashboard] File pasted')
-      try {
-        this.uppy.addFile({
-          source: this.id,
-          name: file.name,
-          type: file.type,
-          data: blob
-        })
-      } catch (err) {
-        // Nothing, restriction errors handled in Core
+  handlePaste (event) {
+    // 1. Let any acquirer plugin (Url/Webcam/etc.) handle pastes to the root
+    this.uppy.iteratePlugins((plugin) => {
+      if (plugin.type === 'acquirer') {
+        // Every Plugin with .type acquirer can define handleRootPaste(event)
+        plugin.handleRootPaste && plugin.handleRootPaste(event)
       }
     })
-  }
-
-  handleInputChange (ev) {
-    ev.preventDefault()
-    const files = toArray(ev.target.files)
 
+    // 2. Add all dropped files
+    const files = toArray(event.clipboardData.files)
     files.forEach((file) => {
-      try {
-        this.uppy.addFile({
-          source: this.id,
-          name: file.name,
-          type: file.type,
-          data: file
-        })
-      } catch (err) {
-        // Nothing, restriction errors handled in Core
-      }
+      this.uppy.log('[Dashboard] File pasted')
+      this.addFile(file)
     })
   }
 
+  handleInputChange (event) {
+    event.preventDefault()
+    const files = toArray(event.target.files)
+    files.forEach((file) =>
+      this.addFile(file)
+    )
+  }
+
+  addFile (file) {
+    try {
+      this.uppy.addFile({
+        source: this.id,
+        name: file.name,
+        type: file.type,
+        data: file,
+        meta: {
+          // path of the file relative to the ancestor directory the user selected.
+          // e.g. 'docs/Old Prague/airbnb.pdf'
+          relativePath: file.relativePath || null
+        }
+      })
+    } catch (err) {
+      // Nothing, restriction errors handled in Core
+    }
+  }
+
   // _Why make insides of Dashboard invisible until first ResizeObserver event is emitted?
   //  ResizeOberserver doesn't emit the first resize event fast enough, users can see the jump from one .uppy-size-- to another (e.g. in Safari)
   // _Why not apply visibility property to .uppy-Dashboard-inner?
@@ -503,6 +511,55 @@ module.exports = class Dashboard extends Plugin {
     clearTimeout(this.makeDashboardInsidesVisibleAnywayTimeout)
   }
 
+  handleDragOver (event) {
+    event.preventDefault()
+    event.stopPropagation()
+
+    clearTimeout(this.removeDragOverClassTimeout)
+    this.setPluginState({ isDraggingOver: true })
+  }
+
+  handleDragLeave (event) {
+    event.preventDefault()
+    event.stopPropagation()
+
+    clearTimeout(this.removeDragOverClassTimeout)
+    // Timeout against flickering, this solution is taken from drag-drop library. Solution with 'pointer-events: none' didn't work across browsers.
+    this.removeDragOverClassTimeout = setTimeout(() => {
+      this.setPluginState({ isDraggingOver: false })
+    }, 50)
+  }
+
+  handleDrop (event, dropCategory) {
+    event.preventDefault()
+    event.stopPropagation()
+    clearTimeout(this.removeDragOverClassTimeout)
+    // 1. Add a small (+) icon on drop
+    event.dataTransfer.dropEffect = 'copy'
+
+    // 2. Remove dragover class
+    this.setPluginState({ isDraggingOver: false })
+
+    // 3. Let any acquirer plugin (Url/Webcam/etc.) handle drops to the root
+    this.uppy.iteratePlugins((plugin) => {
+      if (plugin.type === 'acquirer') {
+        // Every Plugin with .type acquirer can define handleRootDrop(event)
+        plugin.handleRootDrop && plugin.handleRootDrop(event)
+      }
+    })
+
+    // 4. Add all dropped files
+    getDroppedFiles(event.dataTransfer)
+      .then((files) => {
+        if (files.length > 0) {
+          this.uppy.log('[Dashboard] Files were dropped')
+          files.forEach((file) =>
+            this.addFile(file)
+          )
+        }
+      })
+  }
+
   initEvents () {
     // Modal open button
     const showModalTrigger = findAllDOMElements(this.opts.trigger)
@@ -514,11 +571,6 @@ module.exports = class Dashboard extends Plugin {
       this.uppy.log('Dashboard modal trigger not found. Make sure `trigger` is set in Dashboard options unless you are planning to call openModal() method yourself', 'error')
     }
 
-    // Drag Drop
-    this.removeDragDropListener = dragDrop(this.el, (files) => {
-      this.handleDrop(files)
-    })
-
     this.startListeningToResize()
 
     this.uppy.on('plugin-remove', this.removeTarget)
@@ -545,8 +597,6 @@ module.exports = class Dashboard extends Plugin {
 
     this.stopListeningToResize()
 
-    this.removeDragDropListener()
-    // window.removeEventListener('resize', this.throttledUpdateDashboardElWidth)
     window.removeEventListener('popstate', this.handlePopState, false)
     this.uppy.off('plugin-remove', this.removeTarget)
     this.uppy.off('file-added', this.handleFileAdded)
@@ -567,23 +617,6 @@ module.exports = class Dashboard extends Plugin {
     })
   }
 
-  handleDrop (files) {
-    this.uppy.log('[Dashboard] Files were dropped')
-
-    files.forEach((file) => {
-      try {
-        this.uppy.addFile({
-          source: this.id,
-          name: file.name,
-          type: file.type,
-          data: file
-        })
-      } catch (err) {
-        // Nothing, restriction errors handled in Core
-      }
-    })
-  }
-
   render (state) {
     const pluginState = this.getPluginState()
     const { files, capabilities, allowNewUpload } = state
@@ -741,7 +774,12 @@ module.exports = class Dashboard extends Plugin {
       parentElement: this.el,
       allowedFileTypes: this.uppy.opts.restrictions.allowedFileTypes,
       maxNumberOfFiles: this.uppy.opts.restrictions.maxNumberOfFiles,
-      showSelectedFiles: this.opts.showSelectedFiles
+      showSelectedFiles: this.opts.showSelectedFiles,
+      // drag props
+      isDraggingOver: pluginState.isDraggingOver,
+      handleDragOver: this.handleDragOver,
+      handleDragLeave: this.handleDragLeave,
+      handleDrop: this.handleDrop
     })
   }
 

+ 32 - 11
packages/@uppy/dashboard/src/style.scss

@@ -566,16 +566,40 @@
     padding-top: 10px;
   }
 
-.uppy-Dashboard.drag .uppy-Dashboard-innerWrap {
-  background-color: darken($gray-50, 25%)
-}
-
-.uppy-Dashboard.drag .uppy-Dashboard-AddFilesPanel {
-  background: darken($gray-50, 20%)
+.uppy-Dashboard-dropFilesHereHint {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  visibility: hidden;
+  position: absolute;
+  top: 3px;
+  right: 3px;
+  bottom: 3px;
+  left: 3px;
+  padding-top: 90px;
+  border: 2px dashed rgb(34, 117, 215);
+  border-radius: 4px;
+  z-index: 2000;
+  text-align: center;
+  background-image: url("data:image/svg+xml,%3Csvg width='48' height='48' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M24 1v1C11.85 2 2 11.85 2 24s9.85 22 22 22 22-9.85 22-22S36.15 2 24 2V1zm0 0V0c13.254 0 24 10.746 24 24S37.254 48 24 48 0 37.254 0 24 10.746 0 24 0v1zm7.707 19.293a.999.999 0 1 1-1.414 1.414L25 16.414V34a1 1 0 1 1-2 0V16.414l-5.293 5.293a.999.999 0 1 1-1.414-1.414l7-7a.999.999 0 0 1 1.414 0l7 7z' fill='%232275D7' fill-rule='nonzero'/%3E%3C/svg%3E");
+  background-position: 50% 50%;
+  background-repeat: no-repeat;
+  color: #707070;
+  font-size: 16px;
 }
 
-.uppy-Dashboard.drag .uppy-Dashboard-files--noFiles {
-  border-color: darken($gray-50, 20%);
+.uppy-Dashboard.uppy-Dashboard--isDraggingOver{
+  .uppy-Dashboard-dropFilesHereHint{
+    visibility: visible;
+  }
+  .uppy-DashboardContent-bar,
+  .uppy-Dashboard-files,
+  .uppy-Dashboard-progressindicators {
+    opacity: 0.2;
+  }
+  .uppy-DashboardAddFiles {
+    opacity: 0.03;
+  }
 }
 
 .uppy-Dashboard-dropFilesTitle {
@@ -1260,6 +1284,3 @@ a.uppy-Dashboard-poweredBy {
   vertical-align: middle;
   width: 78%;
 }
-
-
-

+ 12 - 64
packages/@uppy/url/src/index.js

@@ -3,7 +3,7 @@ const Translator = require('@uppy/utils/lib/Translator')
 const { h } = require('preact')
 const { RequestClient } = require('@uppy/companion-client')
 const UrlUI = require('./UrlUI.js')
-const toArray = require('@uppy/utils/lib/toArray')
+const forEachDroppedOrPastedUrl = require('../utils/forEachDroppedOrPastedUrl')
 
 /**
  * Url
@@ -47,11 +47,8 @@ module.exports = class Url extends Plugin {
     // Bind all event handlers for referencability
     this.getMeta = this.getMeta.bind(this)
     this.addFile = this.addFile.bind(this)
-    this.handleDrop = this.handleDrop.bind(this)
-    this.handleDragOver = this.handleDragOver.bind(this)
-    this.handleDragLeave = this.handleDragLeave.bind(this)
-
-    this.handlePaste = this.handlePaste.bind(this)
+    this.handleRootDrop = this.handleRootDrop.bind(this)
+    this.handleRootPaste = this.handleRootPaste.bind(this)
 
     this.client = new RequestClient(uppy, {
       companionUrl: this.opts.companionUrl,
@@ -153,50 +150,17 @@ module.exports = class Url extends Plugin {
       })
   }
 
-  handleDrop (e) {
-    e.preventDefault()
-    if (e.dataTransfer.items) {
-      const items = toArray(e.dataTransfer.items)
-      items.forEach((item) => {
-        if (item.kind === 'string' && item.type === 'text/uri-list') {
-          item.getAsString((url) => {
-            this.uppy.log(`[URL] Adding file from dropped url: ${url}`)
-            this.addFile(url)
-          })
-        }
-      })
-    }
-  }
-
-  handleDragOver (e) {
-    e.preventDefault()
-    this.el.classList.add('drag')
-  }
-
-  handleDragLeave (e) {
-    e.preventDefault()
-    this.el.classList.remove('drag')
+  handleRootDrop (e) {
+    forEachDroppedOrPastedUrl(e.dataTransfer, 'drop', (url) => {
+      this.uppy.log(`[URL] Adding file from dropped url: ${url}`)
+      this.addFile(url)
+    })
   }
 
-  handlePaste (e) {
-    if (!e.clipboardData.items) {
-      return
-    }
-    const items = toArray(e.clipboardData.items)
-
-    // When a file is pasted, it appears as two items: file name string, then
-    // the file itself; Url then treats file name string as URL, which is wrong.
-    // This makes sure Url ignores paste event if it contains an actual file
-    const hasFiles = items.filter(item => item.kind === 'file').length > 0
-    if (hasFiles) return
-
-    items.forEach((item) => {
-      if (item.kind === 'string' && item.type === 'text/plain') {
-        item.getAsString((url) => {
-          this.uppy.log(`[URL] Adding file from pasted url: ${url}`)
-          this.addFile(url)
-        })
-      }
+  handleRootPaste (e) {
+    forEachDroppedOrPastedUrl(e.clipboardData, 'paste', (url) => {
+      this.uppy.log(`[URL] Adding file from pasted url: ${url}`)
+      this.addFile(url)
     })
   }
 
@@ -206,15 +170,6 @@ module.exports = class Url extends Plugin {
       addFile={this.addFile} />
   }
 
-  onMount () {
-    if (this.el) {
-      this.el.addEventListener('drop', this.handleDrop)
-      this.el.addEventListener('dragover', this.handleDragOver)
-      this.el.addEventListener('dragleave', this.handleDragLeave)
-      this.el.addEventListener('paste', this.handlePaste)
-    }
-  }
-
   install () {
     const target = this.opts.target
     if (target) {
@@ -223,13 +178,6 @@ module.exports = class Url extends Plugin {
   }
 
   uninstall () {
-    if (this.el) {
-      this.el.removeEventListener('drop', this.handleDrop)
-      this.el.removeEventListener('dragover', this.handleDragOver)
-      this.el.removeEventListener('dragleave', this.handleDragLeave)
-      this.el.removeEventListener('paste', this.handlePaste)
-    }
-
     this.unmount()
   }
 }

+ 91 - 0
packages/@uppy/url/utils/forEachDroppedOrPastedUrl.js

@@ -0,0 +1,91 @@
+const toArray = require('@uppy/utils/lib/toArray')
+
+/*
+  SITUATION
+
+    1. Cross-browser dataTransfer.items
+
+      paste in chrome [Copy Image]:
+      0: {kind: "file", type: "image/png"}
+      1: {kind: "string", type: "text/html"}
+      paste in safari [Copy Image]:
+      0: {kind: "file", type: "image/png"}
+      1: {kind: "string", type: "text/html"}
+      2: {kind: "string", type: "text/plain"}
+      3: {kind: "string", type: "text/uri-list"}
+      paste in firefox [Copy Image]:
+      0: {kind: "file", type: "image/png"}
+      1: {kind: "string", type: "text/html"}
+
+      paste in chrome [Copy Image Address]:
+      0: {kind: "string", type: "text/plain"}
+      paste in safari [Copy Image Address]:
+      0: {kind: "string", type: "text/plain"}
+      1: {kind: "string", type: "text/uri-list"}
+      paste in firefox [Copy Image Address]:
+      0: {kind: "string", type: "text/plain"}
+
+      drop in chrome [from browser]:
+      0: {kind: "string", type: "text/uri-list"}
+      1: {kind: "string", type: "text/html"}
+      drop in safari [from browser]:
+      0: {kind: "string", type: "text/uri-list"}
+      1: {kind: "string", type: "text/html"}
+      2: {kind: "file", type: "image/png"}
+      drop in firefox [from browser]:
+      0: {kind: "string", type: "text/uri-list"}
+      1: {kind: "string", type: "text/x-moz-url"}
+      2: {kind: "string", type: "text/plain"}
+
+    2. We can determine if it's a 'copypaste' or a 'drop', but we can't discern between [Copy Image] and [Copy Image Address].
+
+  CONCLUSION
+
+    1. 'paste' ([Copy Image] or [Copy Image Address], we can't discern between these two)
+      Don't do anything if there is 'file' item. .handlePaste in the DashboardPlugin will deal with all 'file' items.
+      If there are no 'file' items - handle 'text/plain' items.
+
+    2. 'drop'
+      Take 'text/uri-list' items. Safari has an additional item of .kind === 'file', and you may worry about the item being duplicated (first by DashboardPlugin, and then by UrlPlugin, now), but don't. Directory handling code won't pay attention to this particular item of kind 'file'.
+*/
+
+// Finds all links dropped/pasted from one browser window to another.
+// @param {object} dataTransfer - DataTransfer instance, e.g. e.clipboardData, or e.dataTransfer
+// @param {string} isDropOrPaste - either 'drop' or 'paste'
+// @param {function} callback - (urlString) => {}
+module.exports = function forEachDroppedOrPastedUrl (dataTransfer, isDropOrPaste, callback) {
+  const items = toArray(dataTransfer.items)
+
+  let urlItems
+
+  switch (isDropOrPaste) {
+    case 'paste': {
+      const atLeastOneFileIsDragged = items.some((item) => item.kind === 'file')
+      if (atLeastOneFileIsDragged) {
+        return
+      } else {
+        urlItems = items.filter((item) =>
+          item.kind === 'string' &&
+          item.type === 'text/plain'
+        )
+      }
+      break
+    }
+    case 'drop': {
+      urlItems = items.filter((item) =>
+        item.kind === 'string' &&
+        item.type === 'text/uri-list'
+      )
+      break
+    }
+    default: {
+      throw new Error(`isDropOrPaste must be either 'drop' or 'paste', but it's ${isDropOrPaste}`)
+    }
+  }
+
+  urlItems.forEach((item) => {
+    item.getAsString((urlString) =>
+      callback(urlString)
+    )
+  })
+}

+ 10 - 0
packages/@uppy/utils/src/getDroppedFiles/README.md

@@ -0,0 +1,10 @@
+Influenced by:
+  - https://github.com/leonadler/drag-and-drop-across-browsers
+  - https://github.com/silverwind/uppie/blob/master/uppie.js
+  - https://stackoverflow.com/a/50030399/3192470
+
+### Why do we not use `getFilesAndDirectories()` api?
+
+It's a proposed spec that seems to be barely implemented anywhere.
+Supposed to work in Firefox and Edge, but it doesn't work in Firefox, and both Firefox and Edge support `.webkitGetAsEntry()` api anyway.
+This page e.g. shows how this spec is supposed to function: https://wicg.github.io/directory-upload/, but it only works because of the polyfill.js that uses `.webkitGetAsEntry()` internally.

+ 17 - 0
packages/@uppy/utils/src/getDroppedFiles/index.js

@@ -0,0 +1,17 @@
+const webkitGetAsEntryApi = require('./utils/webkitGetAsEntryApi')
+const fallbackApi = require('./utils/fallbackApi')
+
+// Returns a promise that resolves to the array of dropped files (if a folder is dropped, and browser supports folder parsing - promise resolves to the flat array of all files in all directories).
+// Each file has .relativePath prop appended to it (e.g. "/docs/Prague/ticket_from_prague_to_ufa.pdf") if browser supports it. Otherwise it's undefined.
+//
+// @param {DataTransfer} dataTransfer
+// @returns {Promise} - Array<File>
+module.exports = function getDroppedFiles (dataTransfer) {
+  // Get all files from all subdirs. Works (at least) in Chrome, Mozilla, and Safari
+  if (dataTransfer.items[0] && 'webkitGetAsEntry' in dataTransfer.items[0]) {
+    return webkitGetAsEntryApi(dataTransfer)
+  // Otherwise just return all first-order files
+  } else {
+    return fallbackApi(dataTransfer)
+  }
+}

+ 7 - 0
packages/@uppy/utils/src/getDroppedFiles/utils/fallbackApi.js

@@ -0,0 +1,7 @@
+const toArray = require('../../../lib/toArray')
+
+// .files fallback, should be implemented in any browser
+module.exports = function fallbackApi (dataTransfer) {
+  const files = toArray(dataTransfer.files)
+  return Promise.resolve(files)
+}

+ 89 - 0
packages/@uppy/utils/src/getDroppedFiles/utils/webkitGetAsEntryApi.js

@@ -0,0 +1,89 @@
+const toArray = require('../../../lib/toArray')
+
+// Recursive function, calls the original callback() when the directory is entirely parsed.
+// @param {function} callback - called with ([ all files and directories in that directoryReader ])
+function readEntries (directoryReader, oldEntries, callback) {
+  directoryReader.readEntries(
+    (entries) => {
+      const newEntries = [...oldEntries, ...entries]
+      // According to the FileSystem API spec, readEntries() must be called until it calls the callback with an empty array.
+      if (entries.length) {
+        setTimeout(() => {
+          readEntries(directoryReader, newEntries, callback)
+        }, 0)
+      // Done iterating this particular directory
+      } else {
+        callback(newEntries)
+      }
+    },
+    // Make sure we resolve on error anyway
+    () =>
+      callback(oldEntries)
+  )
+}
+
+// @param {function} resolve - function that will be called when :files array is appended with a file
+// @param {Array<File>} files - array of files to enhance
+// @param {FileSystemFileEntry} fileEntry
+function addEntryToFiles (resolve, files, fileEntry) {
+  // Creates a new File object which can be used to read the file.
+  fileEntry.file(
+    (file) => {
+      // Preserve the relative path from the FileSystemFileEntry#fullPath, because File#webkitRelativePath is always '', at least onDrop.
+      // => "/docs/Prague/ticket_from_prague_to_ufa.pdf"
+      file.relativePath = fileEntry.fullPath
+      files.push(file)
+      resolve()
+    },
+    // Make sure we resolve on error anyway
+    () =>
+      resolve()
+  )
+}
+
+// @param {function} resolve - function that will be called when :directoryEntry is done being recursively parsed
+// @param {Array<File>} files - array of files to enhance
+// @param {FileSystemDirectoryEntry} directoryEntry
+function recursivelyAddFilesFromDirectory (resolve, files, directoryEntry) {
+  const directoryReader = directoryEntry.createReader()
+  readEntries(directoryReader, [], (entries) => {
+    const promises =
+      entries.map((entry) =>
+        createPromiseToAddFileOrParseDirectory(files, entry)
+      )
+    Promise.all(promises)
+      .then(() =>
+        resolve()
+      )
+  })
+}
+
+// @param {Array<File>} files - array of files to enhance
+// @param {(FileSystemFileEntry|FileSystemDirectoryEntry)} entry
+function createPromiseToAddFileOrParseDirectory (files, entry) {
+  return new Promise((resolve) => {
+    if (entry.isFile) {
+      addEntryToFiles(resolve, files, entry)
+    } else if (entry.isDirectory) {
+      recursivelyAddFilesFromDirectory(resolve, files, entry)
+    }
+  })
+}
+
+module.exports = function webkitGetAsEntryApi (dataTransfer) {
+  const files = []
+
+  const rootPromises = []
+
+  toArray(dataTransfer.items)
+    .forEach((item) => {
+      const entry = item.webkitGetAsEntry()
+      // :entry can be null when we drop the url e.g.
+      if (entry) {
+        rootPromises.push(createPromiseToAddFileOrParseDirectory(files, entry))
+      }
+    })
+
+  return Promise.all(rootPromises)
+    .then(() => files)
+}

+ 4 - 0
packages/@uppy/utils/types/index.d.ts

@@ -125,3 +125,7 @@ declare module '@uppy/utils/lib/settle' {
 declare module '@uppy/utils/lib/toArray' {
   export default function toArray(list: any): any[];
 }
+
+declare module '@uppy/utils/lib/getDroppedFiles' {
+  export default function getDroppedFiles(dataTransfer: DataTransfer): Promise<File[]>;
+}