Search code examples
javascriptfile-uploaddirectoryfileapi

How to upload and list directories at firefox and chrome/chromium using change and drop events


Both mozilla and webkit browsers now allow directory upload. When directory or directories are selected at <input type="file"> element or dropped at an element, how to list all directories and files in the order which they appear in actual directory at both firefox and chrome/chromium, and perform tasks on files when all uploaded directories have been iterated?


Solution

  • Short summary: You can set webkitdirectory attributes on <input type="file"> element; attach change, drop events to it; use .createReader(), .readEntries() to get all selected/dropped files and folders, and iterate over them using e.g. Array.prototype.reduce(), Promise, and recursion.

    Note that really 2 different APIs are at play here:

    1. The webkitdirectory feature for <input type="file"> with its change event.
      • This API does not support empty folders. They get skipped.
    2. DataTransferItem.webkitGetAsEntry() with its drop event, which is part of the Drag-and-Drop API.
      • This API supports empty folders.

    Both of them work in Firefox even though they have "webkit" in the name.

    Both of them handle folder/directory hierarchies.

    As stated, if you need to support empty folders, you MUST force your users to use drag-and-drop instead the OS folder chooser shown when the <input type="file"> is clicked.

    Full code sample

    An <input type="file"> that also accepts drag-and-drop into a larger area.

    <!DOCTYPE html>
    <html>
    
    <head>
      <style type="text/css">
        input[type="file"] {
          width: 98%;
          height: 180px;
        }
        
        label[for="file"] {
          width: 98%;
          height: 180px;
        }
        
        .area {
          display: block;
          border: 5px dotted #ccc;
          text-align: center;
        }
        
        .area:after {
          display: block;
          border: none;
          white-space: pre;
          content: "Drop your files or folders here!\aOr click to select files folders";
          pointer-events: none; /* see note [drag-target] */
          position: relative;
          left: 0%;
          top: -75px;
          text-align: center;
        }
        
        .drag {
          border: 5px dotted green;
          background-color: yellow;
        }
        
        #result ul {
          list-style: none;
          margin-top: 20px;
        }
        
        #result ul li {
          border-bottom: 1px solid #ccc;
          margin-bottom: 10px;
        }
        
        #result li span {
          font-weight: bold;
          color: navy;
        }
      </style>
    </head>
    
    
    <body>
      <!-- Docs of `webkitdirectory:
          https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/webkitdirectory
      -->
      <!-- Note [drag-target]:
          When you drag something onto a <label> of an <input type="file">,
          it counts as dragging it on the <input>, so the resulting
          `event` will still have the <input> as `.target` and thus
          that one will have `.webkitdirectory`.
          But not if the <label> has further other nodes in it (e.g. <span>
          or plain text nodes), then the drag event `.target` will be that node.
          This is why we need `pointer-events: none` on the
          "Drop your files or folder here ..." text added in CSS above:
          So that that text cannot become a drag target, and our <label> stays
          the drag target.
      -->
      <label id="dropArea" class="area">
        <input id="file" type="file" directory webkitdirectory />
      </label>
      <output id="result">
        <ul></ul>
      </output>
      <script>
        var dropArea = document.getElementById("dropArea");
        var output = document.getElementById("result");
        var ul = output.querySelector("ul");
    
        function dragHandler(event) {
          event.stopPropagation();
          event.preventDefault();
          dropArea.className = "area drag";
        }
    
        function filesDroped(event) {
          var processedFiles = [];
    
          console.log(event);
          event.stopPropagation();
          event.preventDefault();
          dropArea.className = "area";
    
          function handleEntry(entry) {
            // See https://developer.mozilla.org/en-US/docs/Web/API/DataTransferItem/webkitGetAsEntry
            let file =
              "getAsEntry" in entry ? entry.getAsEntry() :
              "webkitGetAsEntry" in entry ? entry.webkitGetAsEntry()
              : entry;
            return Promise.resolve(file);
          }
    
          function handleFile(entry) {
            return new Promise(function(resolve) {
              if (entry.isFile) {
                entry.file(function(file) {
                  listFile(file, entry.fullPath).then(resolve)
                })
              } else if (entry.isDirectory) {
                var reader = entry.createReader();
                reader.readEntries(webkitReadDirectories.bind(null, entry, handleFile, resolve))
              } else {
                var entries = [entry];
                return entries.reduce(function(promise, file) {
                    return promise.then(function() {
                      return listDirectory(file)
                    })
                  }, Promise.resolve())
                  .then(function() {
                    return Promise.all(entries.map(function(file) {
                      return listFile(file)
                    })).then(resolve)
                  })
              }
            })
    
            function webkitReadDirectories(entry, callback, resolve, entries) {
              console.log(entries);
              return listDirectory(entry).then(function(currentDirectory) {
                console.log(`iterating ${currentDirectory.name} directory`, entry);
                return entries.reduce(function(promise, directory) {
                  return promise.then(function() {
                    return callback(directory)
                  });
                }, Promise.resolve())
              }).then(resolve);
            }
    
          }
    
          function listDirectory(entry) {
            console.log(entry);
            var path = (entry.fullPath || entry.webkitRelativePath.slice(0, entry.webkitRelativePath.lastIndexOf("/")));
            var cname = path.split("/").filter(Boolean).join("-");
            console.log("cname", cname)
            if (!document.getElementsByClassName(cname).length) {
              var directoryInfo = `<li><ul class=${cname}>
                          <li>
                          <span>
                            Directory Name: ${entry.name}<br>
                            Path: ${path}
                            <hr>
                          </span>
                          </li></ul></li>`;
              var curr = document.getElementsByTagName("ul");
              var _ul = curr[curr.length - 1];
              var _li = _ul.querySelectorAll("li");
              if (!document.querySelector("[class*=" + cname + "]")) {
                if (_li.length) {
                  _li[_li.length - 1].innerHTML += `${directoryInfo}`;
                } else {
                  _ul.innerHTML += `${directoryInfo}`
                }
              } else {
                ul.innerHTML += `${directoryInfo}`
              }
            }
            return Promise.resolve(entry);
          }
    
          function listFile(file, path) {
            path = path || file.webkitRelativePath || "/" + file.name;
            var filesInfo = `<li>
                            Name: ${file.name}</br> 
                            Size: ${file.size} bytes</br> 
                            Type: ${file.type}</br> 
                            Modified Date: ${file.lastModifiedDate}<br>
                            Full Path: ${path}
                          </li>`;
    
            var currentPath = path.split("/").filter(Boolean);
            currentPath.pop();
            var appended = false;
            var curr = document.getElementsByClassName(`${currentPath.join("-")}`);
            if (curr.length) {
              for (li of curr[curr.length - 1].querySelectorAll("li")) {
                if (li.innerHTML.indexOf(path.slice(0, path.lastIndexOf("/"))) > -1) {
                  li.querySelector("span").insertAdjacentHTML("afterend", `${filesInfo}`);
                  appended = true;
                  break;
                }
    
              }
              if (!appended) {
                curr[curr.length - 1].innerHTML += `${filesInfo}`;
              }
            }
            console.log(`reading ${file.name}, size: ${file.size}, path:${path}`);
            processedFiles.push(file);
            return Promise.resolve(processedFiles)
          };
    
          function processFiles(files) {
            Promise.all([].map.call(files, function(file, index) {
                return handleEntry(file, index).then(handleFile)
              }))
              .then(function() {
                console.log("complete", processedFiles)
              })
              .catch(function(err) {
                alert(err.message);
              })
          }
    
          var files;
          if (event.type === "drop" && event.target.webkitdirectory) {
            files = event.dataTransfer.items || event.dataTransfer.files;
          } else if (event.type === "change") {
            files = event.target.files;
          }
    
          if (files) {
            processFiles(files)
          }
    
        }
        dropArea.addEventListener("dragover", dragHandler);
        dropArea.addEventListener("change", filesDroped);
        dropArea.addEventListener("drop", filesDroped);
      </script>
    </body>
    
    </html>
    

    Live demo: https://plnkr.co/edit/hUa7zekNeqAuwhXi

    Compatibility issues / notes:

    • Old text (now edited out): Firefox drop event does not list selection as a Directory, but a File object having size 0, thus dropping directory at firefox does not provide representation of dropped folder, even where event.dataTransfer.getFilesAndDirectories() is utilized.

      This was fixed with Firefox 50, which added webkitGetAsEntry support (changelog, issue).

    • Firefox once had on <input type="file"> (HTMLInputElement) the function .getFilesAndDirectories() (added in this commit, issue). It was available only when the about:config preference dom.input.dirpicker was set (which was only on in Firefox Nightly, and removed again in Firefox 101, see other point below). It was removed again (made testing-only) in this commit.

    • Check out this post for the history of webkitdirectory and HTMLInputElement.getFilesAndDirectories().

    • Old text (now edited out): Firefox provides two input elements when allowdirs attribute is set; the first element allows single file uploads, the second element allows directory upload. chrome/chromium provide single <input type="file"> element where only single or multiple directories can be selected, not single file.

      The allowdirs feature was removed in Firefox 101 (code, issue). Before that, it was available via an off-by-default about:config setting dom.input.dirpicker. It was made off-by-default in Firefox 50: (code, issue). Before, it was on-by-default only in Firefox Nightly.

      This means that now, Firefox ignores the allowdirs attribute, and when clicking the Choose file button, it displays a directory-only picker (same behaviour as Chrome).

    • The webkitdirectory feature for <input type="file"> currently works everywhere except:

      • Android WebView
      • non-Edge IE
    • DataTransferItem.webkitGetAsEntry() currently works everywhere except:

      • Firefox on Android
      • non-Edge IE
    • DataTransferItem.webkitGetAsEntry() docs say:

      This function is implemented as webkitGetAsEntry() in non-WebKit browsers including Firefox at this time; it may be renamed to getAsEntry() in the future, so you should code defensively, looking for both.