fix(files): make dropped-file handling explicit

This commit is contained in:
2026-07-20 16:57:29 +02:00
parent 78b4afdec4
commit b5cfba666c
6 changed files with 345 additions and 6 deletions
+101
View File
@@ -0,0 +1,101 @@
type FileSystemHandleLike = FileSystemFileHandleLike | FileSystemDirectoryHandleLike;
type FileSystemFileHandleLike = { kind: "file"; getFile: () => Promise<File> };
type FileSystemDirectoryHandleLike = {
kind: "directory";
values?: () => AsyncIterable<FileSystemHandleLike>;
};
type WebKitFileEntryLike = {
isFile: true;
isDirectory: false;
file: (
success: (file: File) => void,
error?: (error: DOMException) => void
) => void;
};
type WebKitDirectoryEntryLike = { isFile: false; isDirectory: true };
type WebKitEntryLike = WebKitFileEntryLike | WebKitDirectoryEntryLike;
type DropItemWithFileHandles = DataTransferItem & {
getAsFileSystemHandle?: () => Promise<FileSystemHandleLike | null>;
webkitGetAsEntry?: () => unknown;
};
/**
* Resolve a browser file drop exactly once, preferring the standard file list
* before progressively trying item and directory-drag compatibility APIs.
*/
export async function resolveDroppedFiles(
dataTransfer: Pick<DataTransfer, "files" | "items">
): Promise<File[]> {
const directFiles = Array.from(dataTransfer.files);
if (directFiles.length > 0) return directFiles;
const items = Array.from(dataTransfer.items).filter(
(item) => item.kind === "file"
) as DropItemWithFileHandles[];
const itemFiles = items
.map((item) => item.getAsFile())
.filter((file): file is File => Boolean(file));
if (itemFiles.length > 0) return itemFiles;
const handleFiles = await filesFromDataTransferHandles(items);
if (handleFiles.length > 0) return handleFiles;
return filesFromWebKitEntries(items);
}
async function filesFromDataTransferHandles(
items: DropItemWithFileHandles[]
): Promise<File[]> {
const files: File[] = [];
for (const item of items) {
let handle: FileSystemHandleLike | null | undefined;
try {
handle = await item.getAsFileSystemHandle?.();
} catch {
handle = null;
}
if (!handle) continue;
files.push(...await filesFromFileSystemHandle(handle));
}
return files;
}
async function filesFromFileSystemHandle(
handle: FileSystemHandleLike
): Promise<File[]> {
if (handle.kind === "file") return [await handle.getFile()];
const values = handle.values?.();
if (!values) return [];
const files: File[] = [];
for await (const child of values) {
files.push(...await filesFromFileSystemHandle(child));
}
return files;
}
async function filesFromWebKitEntries(
items: DropItemWithFileHandles[]
): Promise<File[]> {
const entries = items.flatMap((item) => {
const entry = item.webkitGetAsEntry?.() as unknown;
return isWebKitEntryLike(entry) ? [entry] : [];
});
const files = await Promise.all(entries.map(fileFromWebKitEntry));
return files.filter((file): file is File => Boolean(file));
}
function isWebKitEntryLike(value: unknown): value is WebKitEntryLike {
if (!value || typeof value !== "object") return false;
const entry = value as { isFile?: unknown; isDirectory?: unknown };
return (
typeof entry.isFile === "boolean" &&
typeof entry.isDirectory === "boolean"
);
}
function fileFromWebKitEntry(entry: WebKitEntryLike): Promise<File | null> {
if (!entry.isFile) return Promise.resolve(null);
return new Promise((resolve, reject) => {
entry.file(resolve, reject);
});
}