file-selector
Version:
Convert DataTransfer object to a list of File objects
274 lines (244 loc) • 10.7 kB
text/typescript
import {UnexpectedObjectError} from "./error";
import {type FileWithPath, toFileWithPath, withMimeType} from "./file";
import {DEFAULT_MIME_TYPES} from "./mime-default";
const FILES_TO_IGNORE = [
// Thumbnail cache files for macOS and Windows
".DS_Store", // macOs
"Thumbs.db" // Windows
];
export interface FromEventOptions {
/**
* Extension-to-MIME lookup used to set `type` on files the browser left typeless.
* Defaults to a small built-in set of common types ({@link DEFAULT_MIME_TYPES}).
*
* The full extension-to-MIME table (~1,200 entries) is not bundled into the main entry;
* import it from the `file-selector/mime` subpath when broader coverage is needed:
*
* ```ts
* import {fromEvent} from 'file-selector';
* import {COMMON_MIME_TYPES} from 'file-selector/mime';
* await fromEvent(evt, {mimeTypes: COMMON_MIME_TYPES});
* ```
*
* See https://github.com/react-dropzone/file-selector/issues/127
*/
mimeTypes?: Map<string, string>;
}
/**
* Convert a DragEvent's DataTrasfer object to a list of File objects
* NOTE: If some of the items are folders,
* everything will be flattened and placed in the same list but the paths will be kept as a {path} property.
*
* EXPERIMENTAL: A list of https://developer.mozilla.org/en-US/docs/Web/API/FileSystemHandle objects can also be passed as an arg
* and a list of File objects will be returned.
*
* @param evt
* @param options
*/
export async function fromEvent(
evt: Event | any,
{mimeTypes = DEFAULT_MIME_TYPES}: FromEventOptions = {}
): Promise<(FileWithPath | DataTransferItem)[]> {
const items = await getFilesOrItems(evt);
// Guess a MIME type from the file extension once, over the final flat list, for any file the
// browser left typeless. DataTransferItems (returned for non-'drop' drag events) pass through as-is.
return items.map(item => (item instanceof File ? withMimeType(item, mimeTypes) : item));
}
async function getFilesOrItems(evt: Event | any): Promise<(FileWithPath | DataTransferItem)[]> {
if (isObject<DragEvent>(evt) && isDataTransfer(evt.dataTransfer)) {
return getDataTransferFiles(evt.dataTransfer, evt.type);
} else if (isChangeEvt(evt)) {
return getInputFiles(evt);
} else if (Array.isArray(evt) && evt.every(item => "getFile" in item && typeof item.getFile === "function")) {
return getFsHandleFiles(evt);
}
return [];
}
function isDataTransfer(value: any): value is DataTransfer {
return isObject(value);
}
function isChangeEvt(value: any): value is Event {
return isObject<Event>(value) && isObject(value.target);
}
function isObject<T>(v: any): v is T {
return typeof v === "object" && v !== null;
}
function getInputFiles(evt: Event) {
return fromList<FileWithPath>((evt.target as HTMLInputElement).files).map(file => toFileWithPath(file));
}
// Ee expect each handle to be https://developer.mozilla.org/en-US/docs/Web/API/FileSystemFileHandle
async function getFsHandleFiles(handles: any[]) {
const files = await Promise.all(handles.map(h => h.getFile()));
return files.map(file => toFileWithPath(file));
}
async function getDataTransferFiles(dt: DataTransfer, type: string) {
const items = fromList<DataTransferItem>(dt.items).filter(item => item.kind === "file");
// According to https://html.spec.whatwg.org/multipage/dnd.html#dndevents,
// only 'dragstart' and 'drop' has access to the data (source node)
if (type !== "drop") {
return items;
}
const files = await Promise.all(items.map(toFilePromises));
return noIgnoredFiles(flatten<FileWithPath>(files));
}
function noIgnoredFiles(files: FileWithPath[]) {
return files.filter(file => FILES_TO_IGNORE.indexOf(file.name) === -1);
}
// https://developer.mozilla.org/en-US/docs/Web/API/FileList
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransferItemList
function fromList<T>(items: DataTransferItemList | FileList | null): T[] {
// {items} can be null, e.g. an <input type="file"> with no selection
if (items === null) {
return [];
}
return Array.from(items as unknown as ArrayLike<T>);
}
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransferItem
async function toFilePromises(item: DataTransferItem) {
if (typeof item.webkitGetAsEntry !== "function") {
return fromDataTransferItem(item);
}
const entry = item.webkitGetAsEntry();
// Safari supports dropping an image node from a different window and can be retrieved using
// the DataTransferItem.getAsFile() API
// NOTE: FileSystemEntry.file() throws if trying to get the file
if (entry?.isDirectory) {
// Prefer the File System Access API for directory traversal where it's available.
// The legacy Entry API (FileSystemDirectoryReader.readEntries) throws on some platforms,
// e.g. dragging a folder from Windows Explorer into a Chromium browser, which would
// otherwise reject the entire drop and lose every file.
// See https://github.com/react-dropzone/file-selector/issues/130
const handle = await getFsHandle(item);
if (handle?.kind === "directory") {
return fromDirHandle(handle, `/${handle.name}`);
}
return fromDirEntry(entry) as any;
}
return fromDataTransferItem(item, entry);
}
function flatten<T>(items: any[]): T[] {
const result: T[] = [];
for (const item of items) {
if (Array.isArray(item)) {
result.push(...flatten<T>(item));
} else {
result.push(item);
}
}
return result;
}
async function fromDataTransferItem(item: DataTransferItem, entry?: FileSystemEntry | null) {
// Read the File synchronously, before awaiting anything. A DataTransferItem's accessors are
// only valid during the synchronous part of the drop event; awaiting `getAsFileSystemHandle()`
// (which resolves on a later task) neuters the item in Chromium, after which `getAsFile()`
// returns `null`. Capturing it up front lets us recover the drop when no handle is available.
// See https://github.com/react-dropzone/react-dropzone/issues/1435
const syncFile = item.getAsFile();
const h = await getFsHandle(item);
if (h !== null && h !== undefined) {
// Hand back the File we captured synchronously from the DataTransferItem when we have it,
// rather than `handle.getFile()`. Both wrap the same dropped file, but only the original
// preserves the File object identity that Electron's `webUtils.getPathForFile()` needs to
// resolve an on-disk path; a File from `FileSystemFileHandle.getFile()` loses it
// (electron/electron#33647), which broke drag-and-drop path access in Electron.
// See https://github.com/react-dropzone/react-dropzone/issues/1411
// `syncFile` is only null in rare cases (e.g. a cross-window drop where getAsFile() returned
// null), so fall back to the handle's File there. Either way, attach the durable handle.
const file = syncFile ?? (await h.getFile());
file.handle = h;
return toFileWithPath(file);
}
// No usable handle. `h` is `null` when Chromium has no backing handle for the item (e.g. a file
// dragged out of an archive — see the issue above) and `undefined` when the File System Access
// API is unavailable or we're not in a secure context (see
// https://github.com/react-dropzone/file-selector/issues/120). In both cases fall back to the
// File we captured synchronously rather than throwing or re-reading a now-neutered item.
if (!syncFile) {
throw new UnexpectedObjectError(item);
}
return toFileWithPath(syncFile, entry?.fullPath ?? undefined);
}
// Resolve the https://developer.mozilla.org/en-US/docs/Web/API/FileSystemHandle for a dropped item.
// Returns `undefined` when the File System Access API isn't available or we're not in a secure context.
//
// We check for a secure context because, due to a bug in Chrome (as far as we know),
// the browser crashes when calling this API (yet to be confirmed as a consistent behaviour).
// See:
// - https://issues.chromium.org/issues/40186242
// - https://github.com/react-dropzone/react-dropzone/issues/1397
async function getFsHandle(item: DataTransferItem) {
if (globalThis.isSecureContext && typeof (item as any).getAsFileSystemHandle === "function") {
return (item as any).getAsFileSystemHandle();
}
return undefined;
}
// Traverse a directory recursively using the File System Access API, returning a flat list of files.
// https://developer.mozilla.org/en-US/docs/Web/API/FileSystemDirectoryHandle
async function fromDirHandle(handle: any, path: string): Promise<FileWithPath[]> {
const files: FileWithPath[] = [];
for await (const child of handle.values()) {
const childPath = `${path}/${child.name}`;
if (child.kind === "directory") {
files.push(...(await fromDirHandle(child, childPath)));
} else {
const file = await child.getFile();
files.push(toFileWithPath(file, childPath, child));
}
}
return files;
}
// https://developer.mozilla.org/en-US/docs/Web/API/FileSystemEntry
async function fromEntry(entry: any) {
return entry.isDirectory ? fromDirEntry(entry) : fromFileEntry(entry);
}
// https://developer.mozilla.org/en-US/docs/Web/API/FileSystemDirectoryEntry
function fromDirEntry(entry: any) {
const reader = entry.createReader();
return new Promise<FileArray[]>((resolve, reject) => {
const entries: Promise<FileValue[]>[] = [];
function readEntries() {
// https://developer.mozilla.org/en-US/docs/Web/API/FileSystemDirectoryEntry/createReader
// https://developer.mozilla.org/en-US/docs/Web/API/FileSystemDirectoryReader/readEntries
reader.readEntries(
async (batch: any[]) => {
if (!batch.length) {
// Done reading directory
try {
const files = await Promise.all(entries);
resolve(files);
} catch (err) {
reject(err);
}
} else {
const items = Promise.all(batch.map(fromEntry));
entries.push(items);
// Continue reading
readEntries();
}
},
(err: any) => {
reject(err);
}
);
}
readEntries();
});
}
// https://developer.mozilla.org/en-US/docs/Web/API/FileSystemFileEntry
async function fromFileEntry(entry: any) {
return new Promise<FileWithPath>((resolve, reject) => {
entry.file(
(file: FileWithPath) => {
const fwp = toFileWithPath(file, entry.fullPath);
resolve(fwp);
},
(err: any) => {
reject(err);
}
);
});
}
// Infinite type recursion
// https://github.com/Microsoft/TypeScript/issues/3496#issuecomment-128553540
interface FileArray extends Array<FileValue> {}
type FileValue = FileWithPath | FileArray[];