feat: support uploading folders via drag-and-drop and toolbar button
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Utilities for handling drag-and-drop of files and folders.
|
||||
* Uses the File and Directory Entries API (webkitGetAsEntry) to
|
||||
* recursively read dropped directory trees, preserving relative paths.
|
||||
*/
|
||||
|
||||
interface FileWithPath extends File {
|
||||
readonly webkitRelativePath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all File entries from a FileSystemDirectoryEntry recursively.
|
||||
* Each returned File has its webkitRelativePath set to the relative path
|
||||
* within the dropped folder (e.g. "folder/sub/file.txt").
|
||||
*/
|
||||
function readDirectoryEntries(dirEntry: FileSystemDirectoryEntry): Promise<FileWithPath[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = dirEntry.createReader();
|
||||
const allEntries: FileSystemEntry[] = [];
|
||||
|
||||
// readEntries may return results in batches; keep reading until empty
|
||||
const readBatch = () => {
|
||||
reader.readEntries(
|
||||
(entries) => {
|
||||
if (entries.length === 0) {
|
||||
resolveFiles(allEntries).then(resolve, reject);
|
||||
} else {
|
||||
allEntries.push(...entries);
|
||||
readBatch();
|
||||
}
|
||||
},
|
||||
reject,
|
||||
);
|
||||
};
|
||||
readBatch();
|
||||
});
|
||||
}
|
||||
|
||||
function resolveFiles(entries: FileSystemEntry[]): Promise<FileWithPath[]> {
|
||||
const promises = entries.map((entry) => {
|
||||
if (entry.isFile) {
|
||||
return new Promise<FileWithPath[]>((resolve, reject) => {
|
||||
(entry as FileSystemFileEntry).file(
|
||||
(file) => {
|
||||
// Set webkitRelativePath directly on the original File object.
|
||||
// The property lives on File.prototype as a getter, so defining
|
||||
// an own data property on the instance safely shadows it.
|
||||
try {
|
||||
Object.defineProperty(file, 'webkitRelativePath', {
|
||||
value: entry.fullPath.replace(/^\//, ''),
|
||||
writable: false,
|
||||
configurable: true,
|
||||
});
|
||||
} catch {
|
||||
// Fallback: some environments may prevent overriding.
|
||||
// The store also falls back to file.name, which still works
|
||||
// for flat files (though nested paths would be lost).
|
||||
}
|
||||
resolve([file as unknown as FileWithPath]);
|
||||
},
|
||||
reject,
|
||||
);
|
||||
});
|
||||
} else if (entry.isDirectory) {
|
||||
return readDirectoryEntries(entry as FileSystemDirectoryEntry);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
return Promise.all(promises).then((arrays) => arrays.flat());
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of processing a drop event's DataTransfer.
|
||||
*/
|
||||
export interface DropResult {
|
||||
files: File[];
|
||||
hasDirectories: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a drop event's DataTransfer, detecting folders and recursively
|
||||
* reading their contents. Returns the list of files and whether any
|
||||
* directories were found.
|
||||
*
|
||||
* Falls back to e.dataTransfer.files when webkitGetAsEntry is unavailable.
|
||||
*/
|
||||
export async function getDroppedFilesAndFolders(dataTransfer: DataTransfer): Promise<DropResult> {
|
||||
const items = dataTransfer.items;
|
||||
|
||||
// Check if the browser supports webkitGetAsEntry
|
||||
if (items && items.length > 0 && typeof items[0].webkitGetAsEntry === 'function') {
|
||||
const entries: FileSystemEntry[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const entry = items[i].webkitGetAsEntry();
|
||||
if (entry) entries.push(entry);
|
||||
}
|
||||
|
||||
let hasDirectories = false;
|
||||
const filePromises: Promise<FileWithPath[]>[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory) {
|
||||
hasDirectories = true;
|
||||
filePromises.push(readDirectoryEntries(entry as FileSystemDirectoryEntry));
|
||||
} else if (entry.isFile) {
|
||||
filePromises.push(
|
||||
new Promise<FileWithPath[]>((resolve, reject) => {
|
||||
(entry as FileSystemFileEntry).file(
|
||||
(file) => resolve([file as FileWithPath]),
|
||||
reject,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const allFiles = (await Promise.all(filePromises)).flat();
|
||||
return { files: allFiles, hasDirectories };
|
||||
}
|
||||
|
||||
// Fallback: no entry API support
|
||||
return {
|
||||
files: Array.from(dataTransfer.files),
|
||||
hasDirectories: false,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user