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 => 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 => 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 => { const files: File[] = []; const walk = async (entry: FileSystemEntry, prefix: string): Promise => { if (files.length >= maxFiles) return; // A single unreadable file/directory (moved between drop and read, a // permissions/lock error, an OS special file) must NOT abort the whole // traversal — skip it and keep collecting the rest. if (entry.isFile) { try { const file = await fileFromFileEntry(entry as FileSystemFileEntry); if (files.length >= maxFiles) return; files.push(prefix ? renameFile(file, `${prefix}${file.name}`) : file); } catch { /* skip unreadable file */ } return; } if (entry.isDirectory) { let childEntries: FileSystemEntry[] = []; try { const reader = (entry as FileSystemDirectoryEntry).createReader(); childEntries = await readAllDirectoryEntries(reader); } catch { return; /* skip unreadable directory */ } 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 => { const entries = entriesFromDataTransfer(dataTransfer); if (entries.length === 0) { return Promise.resolve(getDataTransferFiles(dataTransfer)); } return filesFromEntries(entries).then((files) => (files.length > 0 ? files : undefined)); };