dropTraversal.ts3.9 KBView on GitHub /**
* Walks a native browser drop event and produces a flat list of files plus the
* relative path from the dropped root. Handles both file drops and folder drops
* via `DataTransferItem.webkitGetAsEntry()` (supported in all modern browsers).
*/
export interface DroppedFile {
file: File;
/** Posix-style path relative to the drop root. e.g. "research/acme/notes.md" */
relativePath: string;
}
interface FileSystemEntryLike {
isFile: boolean;
isDirectory: boolean;
name: string;
fullPath: string;
file?: (cb: (file: File) => void, err?: (e: unknown) => void) => void;
createReader?: () => {
readEntries: (cb: (entries: FileSystemEntryLike[]) => void, err?: (e: unknown) => void) => void;
};
}
/**
* Read all DataTransferItems and return a flat list of files. Folders are
* recursively traversed. The relative path on each entry mirrors the dropped
* folder structure so callers can recreate it.
*/
export async function readDroppedItems(items: DataTransferItemList): Promise<DroppedFile[]> {
const roots: FileSystemEntryLike[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind !== 'file') continue;
// webkitGetAsEntry is the only cross-browser way to detect folders today.
const getAsEntry = (item as DataTransferItem & {
webkitGetAsEntry?: () => FileSystemEntryLike | null;
}).webkitGetAsEntry;
const entry = typeof getAsEntry === 'function' ? getAsEntry.call(item) : null;
if (entry) {
roots.push(entry);
continue;
}
// Browser without webkitGetAsEntry — fall back to the plain File.
const file = item.getAsFile();
if (file) {
// Manufacture a fake entry so the loop below can treat everything uniformly.
roots.push({
isFile: true,
isDirectory: false,
name: file.name,
fullPath: '/' + file.name,
file: (cb) => cb(file),
});
}
}
const out: DroppedFile[] = [];
for (const root of roots) {
await walkEntry(root, '', out);
}
return out;
}
async function walkEntry(
entry: FileSystemEntryLike,
parentPath: string,
out: DroppedFile[],
): Promise<void> {
if (entry.isFile) {
const file = await entryToFile(entry);
if (file) {
out.push({
file,
relativePath: parentPath ? `${parentPath}/${entry.name}` : entry.name,
});
}
return;
}
if (!entry.isDirectory || !entry.createReader) return;
const reader = entry.createReader();
// readEntries can return < all entries per call; loop until it returns [].
let batch: FileSystemEntryLike[] = await readBatch(reader);
const childPath = parentPath ? `${parentPath}/${entry.name}` : entry.name;
while (batch.length > 0) {
for (const child of batch) {
await walkEntry(child, childPath, out);
}
batch = await readBatch(reader);
}
}
function readBatch(reader: {
readEntries: (cb: (entries: FileSystemEntryLike[]) => void, err?: (e: unknown) => void) => void;
}): Promise<FileSystemEntryLike[]> {
return new Promise((resolve) => {
reader.readEntries(
(entries) => resolve(entries),
() => resolve([]),
);
});
}
function entryToFile(entry: FileSystemEntryLike): Promise<File | null> {
return new Promise((resolve) => {
if (typeof entry.file !== 'function') {
resolve(null);
return;
}
entry.file(
(file) => resolve(file),
() => resolve(null),
);
});
}
/** Returns true if any of the items represent a directory drop. */
export function dropContainsDirectories(items: DataTransferItemList): boolean {
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind !== 'file') continue;
const getAsEntry = (item as DataTransferItem & {
webkitGetAsEntry?: () => FileSystemEntryLike | null;
}).webkitGetAsEntry;
const entry = typeof getAsEntry === 'function' ? getAsEntry.call(item) : null;
if (entry?.isDirectory) return true;
}
return false;
}