123 lines
4.2 KiB
TypeScript
123 lines
4.2 KiB
TypeScript
import { getDataTransferFiles, renameFile } from './dom';
|
|||
|
|
|
||
|
|
// Guard against pathological drops (deeply nested / huge trees) that could
|
||
|
|
// otherwise queue thousands of uploads and freeze the composer.
|
||
|
|
export const MAX_DROPPED_FILES = 500;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Synchronously collect the `FileSystemEntry` objects for every item in a
|
||
|
|
* drop's `DataTransfer`.
|
||
|
|
*
|
||
|
|
* This MUST be called synchronously inside the drop event handler: the
|
||
|
|
* `DataTransferItemList` is emptied once the handler returns, so calling
|
||
|
|
* `webkitGetAsEntry()` after an `await` yields `null`. Capture the entries
|
||
|
|
* first, then traverse them asynchronously with {@link filesFromEntries}.
|
||
|
|
*
|
||
|
|
* Returns an empty array when `webkitGetAsEntry` is unavailable (non-Chromium
|
||
|
|
* browsers), signalling the caller to fall back to the flat file list.
|
||
|
|
*/
|
||
|
|
export const entriesFromDataTransfer = (dataTransfer: DataTransfer): FileSystemEntry[] => {
|
||
|
|
const entries: FileSystemEntry[] = [];
|
||
|
|
const { items } = dataTransfer;
|
||
|
|
if (!items) return entries;
|
||
|
|
|
||
|
|
for (let i = 0; i < items.length; i += 1) {
|
||
|
|
const item = items[i];
|
||
|
|
if (item && item.kind === 'file' && typeof item.webkitGetAsEntry === 'function') {
|
||
|
|
const entry = item.webkitGetAsEntry();
|
||
|
|
if (entry) entries.push(entry);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return entries;
|
||
|
|
};
|
||
|
|
|
||
|
|
const fileFromFileEntry = (entry: FileSystemFileEntry): Promise<File> =>
|
||
|
|
new Promise((resolve, reject) => {
|
||
|
|
entry.file(resolve, reject);
|
||
|
|
});
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Read every entry from a directory reader.
|
||
|
|
*
|
||
|
|
* `readEntries` returns results in BATCHES (Chromium yields at most ~100 per
|
||
|
|
* call), so it must be called repeatedly until it resolves with an empty array.
|
||
|
|
*/
|
||
|
|
const readAllDirectoryEntries = (reader: FileSystemDirectoryReader): Promise<FileSystemEntry[]> =>
|
||
|
|
new Promise((resolve, reject) => {
|
||
|
|
const all: FileSystemEntry[] = [];
|
||
|
|
const readBatch = () => {
|
||
|
|
reader.readEntries((batch) => {
|
||
|
|
if (batch.length === 0) {
|
||
|
|
resolve(all);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
all.push(...batch);
|
||
|
|
readBatch();
|
||
|
|
}, reject);
|
||
|
|
};
|
||
|
|
readBatch();
|
||
|
|
});
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Recursively walk `FileSystemEntry` objects (as produced by
|
||
|
|
* {@link entriesFromDataTransfer}) and resolve them into a flat `File[]`,
|
||
|
|
* descending into every nested directory.
|
||
|
|
*
|
||
|
|
* Nested files keep their relative folder path as a name prefix (e.g.
|
||
|
|
* `photos/2024/pic.jpg`) so uploads remain distinguishable. Traversal stops
|
||
|
|
* once `maxFiles` files have been collected.
|
||
|
|
*/
|
||
|
|
export const filesFromEntries = async (
|
||
|
|
entries: FileSystemEntry[],
|
||
|
|
maxFiles: number = MAX_DROPPED_FILES,
|
||
|
|
): Promise<File[]> => {
|
||
|
|
const files: File[] = [];
|
||
|
|
|
||
|
|
const walk = async (entry: FileSystemEntry, prefix: string): Promise<void> => {
|
||
|
|
if (files.length >= maxFiles) return;
|
||
|
|
|
||
|
|
if (entry.isFile) {
|
||
|
|
const file = await fileFromFileEntry(entry as FileSystemFileEntry);
|
||
|
|
if (files.length >= maxFiles) return;
|
||
|
|
files.push(prefix ? renameFile(file, `${prefix}${file.name}`) : file);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (entry.isDirectory) {
|
||
|
|
const reader = (entry as FileSystemDirectoryEntry).createReader();
|
||
|
|
const childEntries = await readAllDirectoryEntries(reader);
|
||
|
|
const childPrefix = `${prefix}${entry.name}/`;
|
||
|
|
for (const child of childEntries) {
|
||
|
|
if (files.length >= maxFiles) break;
|
||
|
|
// eslint-disable-next-line no-await-in-loop
|
||
|
|
await walk(child, childPrefix);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
for (const entry of entries) {
|
||
|
|
if (files.length >= maxFiles) break;
|
||
|
|
// eslint-disable-next-line no-await-in-loop
|
||
|
|
await walk(entry, '');
|
||
|
|
}
|
||
|
|
|
||
|
|
return files;
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Extract dropped files, descending into any dropped folders.
|
||
|
|
*
|
||
|
|
* Captures the `FileSystemEntry` list synchronously (required — see
|
||
|
|
* {@link entriesFromDataTransfer}) then traverses it asynchronously. Falls back
|
||
|
|
* to the flat `dataTransfer.files` list when the directory API is unavailable
|
||
|
|
* (non-Chromium) or when no entries are exposed.
|
||
|
|
*/
|
||
|
|
export const collectDroppedFiles = (dataTransfer: DataTransfer): Promise<File[] | undefined> => {
|
||
|
|
const entries = entriesFromDataTransfer(dataTransfer);
|
||
|
|
if (entries.length === 0) {
|
||
|
|
return Promise.resolve(getDataTransferFiles(dataTransfer));
|
||
|
|
}
|
||
|
|
return filesFromEntries(entries).then((files) => (files.length > 0 ? files : undefined));
|
||
|
|
};
|