19

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?

guest271314
  • 1
  • 15
  • 104
  • 177
  • What piece of information are you still missing? – K Scandrett Jul 29 '17 at 23:27
  • @KScandrett None as to creating an array of files from the directories at either firefox or chromium, that am aware of. The `listDirectory()` and `listFile()` functions at Answer could probably still be improved to get and list accurate results at each browser. When last worked on those functions, if recollect correctly, it was challenging to get the correct parent directory of a file object where the directory containing the file also contained directories. There is a `TODO` at the Answer, mainly tried the `listDirectories()` and `listFiles()` at chromium – guest271314 Jul 29 '17 at 23:32
  • @KScandrett If you have developed a different approach than found at current Answer to meet requirement, post your Answer – guest271314 Jul 29 '17 at 23:39
  • @KScandrett Decided not to select the "Canonical answer required", which could have been applicable, as had already composed own Answer to getting the files as a single array portion of Question; to potentially view how other SO viewers and users approach requirement of getting files as a single array and creating a rendered directory and file tree at HTML depicting an accurate reflection of the uploaded folders' directory tree consistently at both chromium/chrome and firefox browers. – guest271314 Jul 29 '17 at 23:48

1 Answers1

11

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.

nh2
  • 24,526
  • 11
  • 79
  • 128
guest271314
  • 1
  • 15
  • 104
  • 177
  • See [File and Directory Entry API](https://wicg.github.io/entries-api/) – guest271314 Jul 20 '17 at 06:04
  • The `allowdirs` feature was **removed** in Firefox 101 ([code](https://github.com/mozilla/gecko-dev/commit/c82b0711e1dede229b337bdb2625c10ed5c91261), [issue](https://bugzilla.mozilla.org/show_bug.cgi?id=1760560)). 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](https://github.com/mozilla/gecko-dev/commit/67f00dc15b), [issue](https://bugzilla.mozilla.org/show_bug.cgi?id=1283053)). Before, it was on-by-default only in Firefox Nightly. – nh2 Jul 05 '22 at 15:18
  • Linking related question and answer: https://stackoverflow.com/questions/42633306/how-to-allow-the-user-to-pick-any-file-or-directory-in-an-input-type-file-ta/ – nh2 Jul 05 '22 at 15:22
  • "Note at 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" - I believe this is outdated: Firefox 50 added [`webkitGetAsEntry`](https://developer.mozilla.org/en-US/docs/Web/API/DataTransferItem/webkitGetAsEntry) support ([changelog](https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Releases/50#drag_and_drop_api)), 2 months after your original answer. – nh2 Jan 28 '23 at 12:30