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: