The problem#
Last time, I showed how you can let a user have control over their data stored in a web app's OPFS by transferring directories in or out of the browser as ZIP files. But it would be more convenient if the user could just transfer folders instead without needing the extra step of going through an archive manager. There's no shortcut for getting data out of the browser: we can only save one file at a time (unless we use the Chrome-only File System Access API). But there is a cross-browser way to load multiple files, or even nested directories, into the browser.
The solution#
The HTML Drag and Drop API supports transferring multiple files and directories, although the details are a bit messy:
const target = document.getElementById("dropTarget");
// Required to make drop work.
target.addEventListener("dragover", (e) => e.preventDefault());
target.addEventListener("drop", async (e) => {
e.preventDefault();
await Promise.allSettled([...e.dataTransfer.items]
.map(async (item) => {
if (item.getAsFileSystemHandle) {
await processFileSystemHandle(dir,
await item.getAsFileSystemHandle());
} else {
await processFileSystemEntry(dir,
item.webkitGetAsEntry());
}
})
);
});
As you can see, for cross-browser support, we need to handle both
getAsFileSystemHandle()
and
webkitGetAsEntry()
. And, unfortunately, they
return different types, so those two process*()
functions really are
pretty different:
async function processFileSystemHandle(dir, handle) {
if (handle.kind === "directory") {
const subdir = await dir.getDirectoryHandle(handle.name,
{create: true});
for await (const entry of handle.values()) {
await processFileSystemHandle(subdir, entry);
}
} else /* handle.kind === "file" */ {
await writeFile(await dir.getFileHandle(handle.name,
{create: true}),
await handle.getFile());
}
}
async function processFileSystemEntry(dir, entry) {
async function readDirectory(directory) {
let dirReader = directory.createReader();
let getEntries = async () => {
const results = await (new Promise((resolve, reject) =>
dirReader.readEntries(resolve, reject)));
if (results.length) {
return [...results, ...await getEntries()];
}
return [];
};
return await getEntries();
}
if (entry.isDirectory) {
const subdir = await dir.getDirectoryHandle(entry.name,
{create: true});
for (const el of await readDirectory(entry)) {
await processFileSystemEntry(subdir, el);
}
} else /* entry.isFile */ {
const file = new Promise((resolve, reject) =>
entry.file(resolve, reject));
await writeFile(await dir.getFileHandle(entry.name,
{create: true}),
await file);
}
}
(These assume the writeFile()
helper from last week's post
to handle writing inside a Web Worker as necessary.)
The details#
Inspiration#
While researching my previous post, I was
surprised to notice that zip.js's Zip Manager
demo supports dropping multiple files, including nested folders.
The actual magic happens on this line where
it calls getAsFileSystemHandle()
(or
webkitGetAsEntry()
as a fallback for other
browsers). You can look at the example code on the page for
webkitGetAsEntry()
to see how to handle a drop
event.
Looping over DataTransferItems#
A previous version of this code used a for
loop instead of .map()
and Promise.allSettled()
, but when running in Chrome, if there were
multiple items, after processing the first item (which could be an
entire directory), the other items weren't there. I got the current
version by modifying the code to be more similar to the code in Zip
Manager, but it's not obvious why it matters. The
documentation of getAsFileSystemHandle()
says
that it must be accessed from the dragstart
or drop
event handler,
so I think that the difference is that the .map()
initiates all of the
calls from the event handler before yielding due to an await
, while
with the for
, await
ing on the first one meant the rest happened in a
different context.
Reading FileSystemDirectoryEntry
#
FileSystemDirectoryEntry.createReader()
's
documentation gives example
code for a readDirectory()
function that is subtly wrong. Worse, the
issue is a Heisenbug: the code works when debugging.
The problem is that it doesn't check that the readEntries()
callback
has completed before returning. As it's generally very fast, it will
always complete in time to be visible if we set a breakpoint. The fix I
implemented above was to wrap readEntries()
in a Promise
so we can use modern async
/await
to ensure the computation completes
before continuing.
Comments
Have something to add? Post a comment by sending an email to comments@aweirdimagination.net. You may use Markdown for formatting.
There are no comments yet.